diff --git a/.gitignore b/.gitignore index d98a225..132315e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +venv/ venv_openshorts/ __pycache__/ *.pyc @@ -8,8 +9,16 @@ models/ *.avi *.mkv output/ +openshorts/output/ done_split/ uploaded/ logs/ demo_and_images/ .vs/ +.atl/ +yolov8n.pt +yt_video.webm +*.ass +*.srt +*.words.json +*.log diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b5cb9b9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "ai-youtube-shorts-generator"] + path = ai-youtube-shorts-generator + url = https://github.com/samuraigpt/ai-youtube-shorts-generator +[submodule "brainrotinator"] + path = brainrotinator + url = https://github.com/lukesorvik/brainrotinator +[submodule "openshorts"] + path = openshorts + url = https://github.com/mutonby/openshorts diff --git a/ai-youtube-shorts-generator b/ai-youtube-shorts-generator deleted file mode 160000 index 063f9e9..0000000 --- a/ai-youtube-shorts-generator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 063f9e950f331fdf4a5dd787be4390fe3a960148 diff --git a/ai-youtube-shorts-generator/.claude/skills/youtube-shorts-generator/SKILL.md b/ai-youtube-shorts-generator/.claude/skills/youtube-shorts-generator/SKILL.md new file mode 100644 index 0000000..a7c8789 --- /dev/null +++ b/ai-youtube-shorts-generator/.claude/skills/youtube-shorts-generator/SKILL.md @@ -0,0 +1,156 @@ +--- +name: youtube-shorts-generator +description: Generate viral 9:16 YouTube Shorts (or TikTok/Reels clips) from a long-form YouTube URL or local video. Triggers on requests like "make shorts from this video", "extract viral clips from this YouTube link", "auto-clip this podcast", "find the best moments and crop vertical". Pipeline downloads the source, transcribes via MuAPI /openai-whisper, ranks highlights through a virality framework (hook / emotional peak / opinion bomb / revelation / conflict / quotable / story peak / practical value), dedupes overlapping candidates, and vertically auto-crops the top N as mp4s. +--- + +# YouTube Shorts Generator + +End-to-end pipeline that turns one long video into N viral-ready vertical clips. Each clip ships with a viral score (0–100), an opening hook line, and a one-sentence reason it should perform. + +Reference implementation: https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator + +## When to use this skill + +- "Generate shorts from this YouTube video" +- "Find the most viral 60-second clips in this podcast" +- "Auto-crop this interview to 9:16" +- "Give me TikTok clips from this lecture" + +If the user only wants transcription, summarization, or thumbnails — this is the wrong skill. + +## Inputs to collect before running + +Ask once, then proceed: +1. **Source** — YouTube URL (preferred) or path/URL to an mp4 +2. **`num_clips`** — default 3 +3. **`aspect_ratio`** — default `9:16` (also: `1:1`, `4:5`) +4. **`language`** — default auto-detect (forwarded to MuAPI Whisper as ISO-639-1) +5. **Output JSON path** — optional; if set, dump full result there + +If the user gave a URL and nothing else, use defaults and don't block on questions. + +## Prerequisites (verify before first run) + +- Python 3.10+ +- A MuAPI key — set `MUAPI_API_KEY` in `.env`. Powers download, transcription, highlight ranking, and clipping. If missing, stop and ask the user for it; do not invent one. +- `pip install -r requirements.txt` inside a venv + +If the repo isn't cloned yet, clone `https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator.git` into the working directory. + +## Pipeline (what to execute) + +Run the eight stages in order. Each maps to a module in `shorts_generator/`. + +1. **Download** (`downloader.py`) — pull the source video at the requested resolution (`360`/`480`/`720`/`1080`, default `720`). +2. **Transcribe** (`transcriber.py`) — MuAPI `/openai-whisper` runs Whisper server-side and returns timestamped `verbose_json` segments. Billed per minute of audio. +3. **Classify content type** — LLM tags the video (podcast / interview / tutorial / vlog / lecture / monologue) and density. Tune the highlight prompt per type. +4. **Chunk if long** (`highlights.py`) — videos > `LONG_VIDEO_THRESHOLD` (1800s default) are split into `CHUNK_SIZE_SECONDS` (1200s default) windows with `CHUNK_OVERLAP_SECONDS` (60s default) overlap so cross-boundary highlights aren't missed. +5. **Rank highlights** — LLM scans each chunk through `VIRALITY_CRITERIA`: + - **Hook moments** — strong opening line that stops the scroll + - **Emotional peaks** — laughter, anger, vulnerability, awe + - **Opinion bombs** — spicy, contrarian, debate-bait takes + - **Revelation moments** — "wait, what?" reframes + - **Conflict** — disagreement, tension, callouts + - **Quotable lines** — tight, screenshot-worthy phrasing + - **Story peaks** — climax of a narrative arc + - **Practical value** — actionable insight a viewer will save + Each candidate gets `start_time`, `end_time`, `score` 0–100, `title`, `hook_sentence`, `virality_reason`. Aim for 30–75s clips unless content dictates otherwise. +6. **Dedupe** — collapse overlaps. Rule: if two candidates overlap > 50%, keep the higher score, drop the other. +7. **Top-N selection** — sort surviving candidates by score, take `num_clips`. +8. **Vertical auto-crop** (`clipper.py`) — render each highlight at `aspect_ratio`. Auto-handles face tracking and screen recordings; no Haar cascades. + +## Invocation + +CLI (the standard path): + +```bash +python main.py "" \ + --num-clips 5 \ + --aspect-ratio 9:16 \ + --output-json result.json +``` + +Python API (when embedding in another pipeline): + +```python +from shorts_generator import generate_shorts + +result = generate_shorts( + "", + num_clips=5, + aspect_ratio="9:16", +) +for short in result["shorts"]: + print(short["score"], short["title"], short["clip_url"]) +``` + +Batch mode — `urls.txt` with one URL per line: + +```bash +xargs -a urls.txt -I{} python main.py "{}" +``` + +## CLI flags reference + +| Flag | Default | Notes | +|------|---------|-------| +| `--num-clips` | `3` | How many shorts to render | +| `--aspect-ratio` | `9:16` | `9:16` for TikTok/Reels, `1:1` square, anything else by flag | +| `--format` | `720` | Source download resolution | +| `--language` | auto | Whisper language code (e.g. `en`) | +| `--output-json` | — | Dump full result (transcript + all candidates + clip URLs) | + +## Output schema + +```json +{ + "source_video_url": "...", + "transcript": { "duration": 1873.4, "segments": [...] }, + "highlights": [ /* every candidate, before top-N cut */ ], + "shorts": [ + { + "title": "The one mistake that cost me $50K", + "start_time": 124.3, + "end_time": 187.6, + "score": 92, + "hook_sentence": "Nobody talks about this, but it killed my first startup...", + "virality_reason": "Opens with a number + regret, peaks on a contrarian lesson", + "clip_url": "https://.../short_1.mp4" + } + ] +} +``` + +When reporting back to the user, surface for each clip: rank, score, time range, title, hook, and clip URL. Skip the raw transcript unless asked. + +## Tunable knobs + +- `shorts_generator/highlights.py` + - `VIRALITY_CRITERIA` — reorder or extend signals + - `HIGHLIGHT_SYSTEM_PROMPT` — duration sweet spot, hook rules, JSON schema + - `CHUNK_SIZE_SECONDS` — 1200s default + - `LONG_VIDEO_THRESHOLD` — 1800s default + - `CHUNK_OVERLAP_SECONDS` — 60s default +- `shorts_generator/config.py` (or env vars) + - `MUAPI_POLL_INTERVAL` — 5s + - `MUAPI_POLL_TIMEOUT` — 1800s + +## Whisper transcription + +Audio is transcribed by MuAPI's `/openai-whisper` endpoint (server-side `whisper-1`, billed per minute). The CLI passes `--language` straight through; leave it empty for auto-detection, or pass an ISO-639-1 code (e.g. `en`) to lock it. + +## Failure modes — handle, don't paper over + +- **Whisper produced no segments** — likely no detectable speech or a hard language. Retry with `--language ` (correct ISO-639-1) before declaring failure. +- **API key missing or rejected** — surface the exact error; never fabricate a key. +- **Job timed out** — bump `MUAPI_POLL_TIMEOUT` and retry; don't silently truncate. +- **Highlight ranker returned <`num_clips`** — return what survived dedupe with a note; don't pad with low-score filler. + +## Done criteria + +The skill is done when: +1. `result["shorts"]` has up to `num_clips` entries, each with a working `clip_url`. +2. The user has been shown the ranked list (score, time range, title, hook, URL). +3. If `--output-json` was set, the file exists and parses. + +If any clip URL 404s on a HEAD check, re-run just the crop stage for that highlight rather than re-running the whole pipeline. diff --git a/ai-youtube-shorts-generator/.env.example b/ai-youtube-shorts-generator/.env.example new file mode 100644 index 0000000..525760c --- /dev/null +++ b/ai-youtube-shorts-generator/.env.example @@ -0,0 +1,12 @@ +# API mode (default) +MUAPI_API_KEY=your_muapi_key_here + +# Local mode (--mode local) +LLM_PROVIDER=openai # openai or gemini +OPENAI_API_KEY=your_openai_key_here +OPENAI_MODEL=gpt-4o-mini +GEMINI_API_KEY=your_gemini_key_here +GEMINI_MODEL=gemini-2.5-flash +LOCAL_WHISPER_MODEL=base +LOCAL_WHISPER_DEVICE=auto +LOCAL_OUTPUT_DIR=output diff --git a/ai-youtube-shorts-generator/.gitignore b/ai-youtube-shorts-generator/.gitignore new file mode 100644 index 0000000..e189328 --- /dev/null +++ b/ai-youtube-shorts-generator/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ +output/ +*.mp4 +.DS_Store diff --git a/ai-youtube-shorts-generator/README.md b/ai-youtube-shorts-generator/README.md new file mode 100644 index 0000000..bcae2c0 --- /dev/null +++ b/ai-youtube-shorts-generator/README.md @@ -0,0 +1,307 @@ +# AI YouTube Shorts Generator + +[![Powered by MuAPI](https://img.shields.io/badge/Powered%20by-MuAPI-6366f1?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMSAxNHYtNGgtMnYtMmg0djZoLTJ6bTAtOFY2aDJ2MmgtMnoiLz48L3N2Zz4=)](https://muapi.ai?utm_source=github&utm_medium=badge&utm_campaign=ai-youtube-shorts-generator) + + +**The open-source alternative to Opus Clip, Vidyo.ai, Klap, SubMagic, 2short.ai, and other AI clipping tools.** Drop in any long-form YouTube video and get back ranked, viral-ready 9:16 shorts — for free, with no per-clip credits, no watermarks, and full control over the highlight algorithm. + +Built for creators, agencies, and developers who don't want to pay $20–$300/month or be capped on minutes processed. Uses GPT-class LLM highlight detection and Whisper transcription to extract the most viral-worthy moments and auto-crop them vertically for TikTok, Reels, and Shorts. + +> **Building your own Opus Clip–style SaaS?** Skip the infra and ship on the same APIs that power this repo: +> - [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) — end-to-end clip selection + render +> - [Auto-Crop API](https://muapi.ai/playground/autocrop?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) — vertical reframing only + +![longshorts](https://github.com/user-attachments/assets/3f5d1abf-bf3b-475f-8abf-5e253003453a) + +

+ + Awesome Generative AI Apps + +

+ +> 🎨 **[Explore 50+ more open-source AI apps →](https://github.com/Anil-matcha/awesome-generative-ai-apps)** + +## Why Use This Instead of Opus Clip / Vidyo.ai / Klap? + +| | This repo | Opus Clip / Vidyo.ai / Klap / SubMagic | +|---|---|---| +| **Price** | Free + open source (pay only for API usage) | $20–$300/month subscriptions | +| **Per-clip credits** | None — process unlimited videos | Monthly minute caps, overage fees | +| **Watermarks** | Never | On free tiers | +| **Highlight algorithm** | Fully editable virality framework | Black box | +| **Output format** | Any aspect ratio, any resolution | Locked presets | +| **Batch processing** | `xargs` an entire URL list | Manual upload one-by-one | +| **JSON / API output** | Built-in (`--output-json`) | Limited or paid tier only | +| **Self-hostable** | Yes — runs on your machine or server | SaaS only, your videos sit on their servers | +| **White-label / embeddable** | Yes — MIT licensed, import as Python lib | No | + +## Features + +- **🎬 YouTube In, Vertical Out**: Hand it any YouTube URL — get back N viral-ready 9:16 mp4s +- **🔀 Two Modes — API (fast) or Local (offline)**: Default `--mode api` uses MuAPI for download/transcription/cropping; `--mode local` runs entirely on your machine with `yt-dlp`, `faster-whisper`, and `ffmpeg`/`opencv`, and lets you pick OpenAI or Gemini for highlight ranking +- **🤖 Virality-Aware Highlight Selection**: Clips ranked on hooks, emotional peaks, opinion bombs, revelation moments, conflict, quotable lines, story peaks, and practical value — not just generic "interesting" +- **📈 Score + Hook + Reason for Every Clip**: Each highlight comes with a viral score, an opening hook line, and a one-sentence explanation of why it works +- **🎤 Whisper Transcription, Your Choice**: Cloud (`/openai-whisper` via MuAPI) or local (`faster-whisper`, CPU or CUDA) — same downstream output shape +- **🧩 Long-Video Aware**: Videos over 30 minutes are auto-chunked with overlap so nothing gets missed +- **♻️ Smart Dedupe**: Overlapping highlights are collapsed by score so you never get two near-duplicate clips +- **🎯 Smart Vertical Crop**: API mode uses MuAPI's auto-crop; local mode runs OpenCV face tracking with motion smoothing +- **📱 Any Aspect Ratio**: 9:16 for TikTok/Reels/Shorts, 1:1 for square, anything else by flag +- **🧰 CLI + Python Library**: Use it from the shell or import `generate_shorts(...)` into your own pipeline +- **📦 JSON Output**: `--output-json` dumps the full result (transcript + every candidate highlight + final clip URLs/paths) for downstream automation + +## Quick Start (No Setup) + +Don't want to self-host? The [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) gives you the same Opus Clip–style pipeline as a single HTTP call — no Python, no dependencies, pay-per-clip instead of monthly subscriptions. + +--- + +## Installation (Self-Hosted) + +### Prerequisites + +- Python 3.10+ +- For **API mode (default)**: a MuAPI key — powers download, transcription, highlight ranking, and clipping in a single dependency +- For **Local mode** (`--mode local`): `ffmpeg` on your PATH and an LLM API key (`OPENAI_API_KEY` or `GEMINI_API_KEY`; only the LLM step is remote) + +### Steps + +1. **Clone the repository:** + ```bash + git clone https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator.git + cd AI-Youtube-Shorts-Generator + ``` + +2. **Create and activate a virtual environment:** + ```bash + python3.10 -m venv venv + source venv/bin/activate + ``` + +3. **Install Python dependencies:** + ```bash + pip install -r requirements.txt + # Only if you plan to use --mode local: + pip install -r requirements-local.txt + ``` + +4. **Set up environment variables:** + + Create a `.env` file in the project root: + ```bash + # API mode (default) + MUAPI_API_KEY=your_muapi_key_here + + # Local mode (--mode local) + LLM_PROVIDER=openai # openai or gemini + OPENAI_API_KEY=your_openai_key_here + OPENAI_MODEL=gpt-4o-mini # optional, default gpt-4o-mini + GEMINI_API_KEY=your_gemini_key_here + GEMINI_MODEL=gemini-2.5-flash # optional, default gemini-2.5-flash + LOCAL_WHISPER_MODEL=base # tiny / base / small / medium / large-v3 + LOCAL_WHISPER_DEVICE=auto # auto / cpu / cuda + LOCAL_OUTPUT_DIR=output # where local mp4s land + ``` + +## Usage + +### Single video (API mode — default) + +```bash +python main.py "https://www.youtube.com/watch?v=VIDEO_ID" +``` + +### Single video (Local mode — runs offline except for the LLM call) + +```bash +python main.py "https://www.youtube.com/watch?v=VIDEO_ID" --mode local +``` + +Local mode writes the rendered shorts to `./output/short_01.mp4`, `short_02.mp4`, … (override with `LOCAL_OUTPUT_DIR`). + +### With options + +```bash +python main.py "https://www.youtube.com/watch?v=VIDEO_ID" \ + --mode api \ + --num-clips 5 \ + --aspect-ratio 9:16 \ + --output-json result.json +``` + +### Local file or path + +In `--mode local`, you can pass a `file://` URL or a direct filesystem path and skip YouTube entirely: + +```bash +python main.py "/Users/you/Videos/input.mp4" --mode local +python main.py "file:///Users/you/Videos/input.mp4" --mode local +``` + +The Python API works the same way: + +```python +from shorts_generator import generate_shorts + +result = generate_shorts( + "/Users/you/Videos/input.mp4", + num_clips=5, + aspect_ratio="9:16", + mode="local", +) +for short in result["shorts"]: + print(short["score"], short["title"], short["clip_url"]) +``` + +Local transcription is cached as an `.srt` file in `LOCAL_OUTPUT_DIR` using the +video's base name. If the cache already exists and is newer than the source +file, the app reuses it instead of running Whisper again. + +Local downloads are also cached in `LOCAL_OUTPUT_DIR` as +`source_.mp4` when the input is a YouTube URL. If that file already +exists, the app skips `yt-dlp` and reuses the cached video. + +### Batch processing + +Create a `urls.txt` file with one URL per line, then: + +```bash +xargs -a urls.txt -I{} python main.py "{}" +``` + +### CLI flags + +| Flag | Default | Notes | +|------|---------|-------| +| `--mode` | `api` | `api` (MuAPI, fast, no setup) or `local` (remote URL, `file://`, or local path + faster-whisper + LLM provider + ffmpeg) | +| `--num-clips` | `3` | How many shorts to render | +| `--aspect-ratio` | `9:16` | Any ratio; `9:16` for TikTok/Reels, `1:1` for square | +| `--format` | `720` | Source download resolution: `360` / `480` / `720` / `1080` | +| `--language` | auto | Force Whisper language code (e.g. `en`) | +| `--output-json` | — | Dump the full result (transcript + all candidates) to a file | + +### API mode vs Local mode + +| Step | API mode (`--mode api`) | Local mode (`--mode local`) | +|---|---|---| +| Download | MuAPI `/youtube-download` | `yt-dlp` for remote URLs, direct file path for local inputs | +| Transcription | MuAPI `/openai-whisper` | `faster-whisper` (CPU or CUDA) | +| Highlight LLM | MuAPI `gpt-5-mini` | `LLM_PROVIDER=openai` uses OpenAI (`gpt-4o-mini` by default), `LLM_PROVIDER=gemini` uses Gemini (`gemini-2.5-flash` by default) | +| Vertical crop | MuAPI `/autocrop` | `ffmpeg` + OpenCV face tracking | +| Output | hosted URLs | local mp4 paths | +| Required keys | `MUAPI_API_KEY` | `OPENAI_API_KEY` or `GEMINI_API_KEY` (+ `ffmpeg` on PATH) | + +## How It Works + +1. **Download**: Fetches the source video from YouTube +2. **Transcribe**: MuAPI `/openai-whisper` produces a timestamped transcript (verbose_json segments) +3. **Detect content type**: An LLM classifies the video (podcast, interview, tutorial, vlog, etc.) and density, so the prompt can be tuned per content style +4. **Long-video chunking**: Videos > 30 min are split into 20-min overlapping chunks +5. **Highlight ranking**: An LLM scans the transcript through a virality framework — hook moments, emotional peaks, opinion bombs, revelations, conflict, quotables, story peaks, practical value — and emits ranked candidates with scores 0–100 +6. **Dedupe**: Overlapping candidates are collapsed by score (>50% overlap → keep the higher score) +7. **Top-N selection**: The top `--num-clips` candidates are selected +8. **Auto-crop**: Each highlight is rendered as a vertical short at the requested aspect ratio + +**Output**: a list of mp4 URLs plus, for each clip, its title, viral score, hook sentence, and a one-line reason explaining why it should perform. + +## Output + +Console output looks like: + +``` +======================================================================== +Highlights: 7 candidates → kept top 3 +======================================================================== + +#1 score=92 124.3s → 187.6s + title: The one mistake that cost me $50K + hook: "Nobody talks about this, but it killed my first startup..." + clip: https://.../short_1.mp4 + +#2 score=88 ... +``` + +`--output-json result.json` produces: + +```json +{ + "source_video_url": "...", + "transcript": { "duration": 1873.4, "segments": [...] }, + "highlights": [ {...}, {...}, ... ], + "shorts": [ + { + "title": "...", + "start_time": 124.3, + "end_time": 187.6, + "score": 92, + "hook_sentence": "...", + "virality_reason": "...", + "clip_url": "https://.../short_1.mp4" + } + ] +} +``` + +## Configuration + +### Highlight selection criteria +Edit `shorts_generator/highlights.py`: +- **Virality framework**: `VIRALITY_CRITERIA` — the ranked list of signals the LLM optimizes for +- **System prompt**: `HIGHLIGHT_SYSTEM_PROMPT` — duration sweet spot, hook rules, JSON schema +- **Chunk size**: `CHUNK_SIZE_SECONDS` (default 1200) — chunk length for long videos +- **Long-video threshold**: `LONG_VIDEO_THRESHOLD` (default 1800) — videos longer than this are chunked +- **Chunk overlap**: `CHUNK_OVERLAP_SECONDS` (default 60) — overlap between chunks so cross-boundary clips aren't missed + +### Polling / timeout +Edit `shorts_generator/config.py` (or set env vars): +- `MUAPI_POLL_INTERVAL` (default 5s) — seconds between job-status polls +- `MUAPI_POLL_TIMEOUT` (default 1800s) — give up after this long + +### Whisper transcription +Audio is transcribed by MuAPI's `/openai-whisper` endpoint (server-side `whisper-1`). Pass `--language ` to lock the recognition to a specific language; otherwise it auto-detects. + +## Project Structure + +``` +AI-Youtube-Shorts-Generator/ +├── main.py CLI entry point +├── requirements.txt core deps (api mode) +├── requirements-local.txt optional deps for --mode local +├── .env.example +└── shorts_generator/ + ├── config.py env / settings (MuAPI + local LLM + Whisper) + ├── muapi.py generic submit + poll wrapper + ├── downloader.py API mode: YouTube download via MuAPI + ├── transcriber.py API mode: MuAPI /openai-whisper client + ├── highlights.py shared LLM virality ranking (pluggable backend) + ├── clipper.py API mode: MuAPI /autocrop + ├── pipeline.py mode dispatcher (api ↔ local) + └── local/ --mode local backends (offline) + ├── downloader.py yt-dlp download + ├── transcriber.py faster-whisper transcription + ├── llm.py OpenAI or Gemini client selector + └── clipper.py ffmpeg cut + OpenCV vertical crop +``` + +## Troubleshooting + +### Whisper produced no segments +The video may have no detectable speech, or it may be in a language Whisper struggles with. Try passing `--language en` (or the correct ISO-639-1 code) to skip auto-detection. + +### Looking for better results? +The [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) uses an improved algorithm that produces higher-quality clips with better highlight detection. + +## Contributing + +Contributions are welcome! Please fork the repository and submit a pull request. + +## License + +This project is licensed under the MIT License. + +## Related Projects + +- [AI Influencer Generator](https://github.com/SamurAIGPT/AI-Influencer-Generator) +- [Text to Video AI](https://github.com/SamurAIGPT/Text-To-Video-AI) +- [Faceless Video Generator](https://github.com/SamurAIGPT/Faceless-Video-Generator) +- [AI B-roll Generator](https://github.com/Anil-matcha/AI-B-roll) +- [No-code YouTube Shorts Generator](https://www.vadoo.tv/clip-youtube-video) diff --git a/ai-youtube-shorts-generator/main.py b/ai-youtube-shorts-generator/main.py new file mode 100644 index 0000000..866b61f --- /dev/null +++ b/ai-youtube-shorts-generator/main.py @@ -0,0 +1,73 @@ +"""CLI entry point. + +Usage: + python main.py "https://www.youtube.com/watch?v=..." \ + --num-clips 3 --aspect-ratio 9:16 +""" +import argparse +import json +import sys + +# Windows uses 'charmap' by default, which can't encode Unicode characters +# like →. Reconfigure stdout/stderr to UTF-8 so output works on all platforms. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +from shorts_generator import generate_shorts + + +def main() -> int: + parser = argparse.ArgumentParser(description="AI YouTube Shorts Generator") + parser.add_argument("url", help="YouTube URL, file:// URL, or local file path") + parser.add_argument( + "--mode", + choices=["api", "local"], + default="api", + help="api (default, MuAPI) or local (remote URL, file://, or local path + faster-whisper + LLM provider + ffmpeg).", + ) + parser.add_argument("--num-clips", type=int, default=3, help="How many shorts to render (default: 3)") + parser.add_argument("--aspect-ratio", default="9:16", help="Output aspect ratio (default: 9:16)") + parser.add_argument("--format", default="720", help="Source download resolution: 360 / 480 / 720 / 1080 (default: 720)") + parser.add_argument("--language", default=None, help="Force Whisper language code, e.g. 'en' (default: auto-detect)") + parser.add_argument("--output-json", default=None, help="Write the full result JSON to this path") + args = parser.parse_args() + + try: + result = generate_shorts( + youtube_url=args.url, + num_clips=args.num_clips, + aspect_ratio=args.aspect_ratio, + download_format=args.format, + language=args.language, + mode=args.mode, + ) + except Exception as e: + print(f"\nFAILED: {e}", file=sys.stderr) + return 1 + + print("\n" + "=" * 72) + print(f"Mode: {result.get('mode', args.mode)}") + print(f"Source video: {result['source_video_url']}") + print(f"Highlights: {len(result['highlights'])} candidates → kept top {len(result['shorts'])}") + print("=" * 72) + for i, s in enumerate(result["shorts"], 1): + print(f"\n#{i} score={s.get('score')} {s.get('start_time'):.1f}s → {s.get('end_time'):.1f}s") + print(f" title: {s.get('title')}") + print(f" hook: {s.get('hook_sentence')}") + if s.get("clip_url"): + print(f" clip: {s['clip_url']}") + else: + print(f" clip: FAILED ({s.get('error')})") + + if args.output_json: + with open(args.output_json, "w") as f: + json.dump(result, f, indent=2) + print(f"\nFull JSON written to {args.output_json}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ai-youtube-shorts-generator/requirements-local.txt b/ai-youtube-shorts-generator/requirements-local.txt new file mode 100644 index 0000000..892ac61 --- /dev/null +++ b/ai-youtube-shorts-generator/requirements-local.txt @@ -0,0 +1,10 @@ +-r requirements.txt + +# Optional dependencies for --mode local. +yt-dlp>=2024.1.1 +faster-whisper>=1.0.0 +openai>=1.0.0 +google-genai>=1.0.0 +opencv-python>=4.8.0 +# torch is only needed if you want CUDA Whisper. CPU works without it. +# torch>=2.0 diff --git a/ai-youtube-shorts-generator/requirements.txt b/ai-youtube-shorts-generator/requirements.txt new file mode 100644 index 0000000..5160da3 --- /dev/null +++ b/ai-youtube-shorts-generator/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31 +python-dotenv>=1.0 diff --git a/ai-youtube-shorts-generator/shorts_generator/__init__.py b/ai-youtube-shorts-generator/shorts_generator/__init__.py new file mode 100644 index 0000000..cf2a70d --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/__init__.py @@ -0,0 +1,3 @@ +from .pipeline import generate_shorts + +__all__ = ["generate_shorts"] diff --git a/ai-youtube-shorts-generator/shorts_generator/clipper.py b/ai-youtube-shorts-generator/shorts_generator/clipper.py new file mode 100644 index 0000000..b7961f7 --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/clipper.py @@ -0,0 +1,41 @@ +"""Per-clip cropping via MuAPI /autocrop. + +Given the source video URL plus a highlight's start/end and a target aspect +ratio, MuAPI returns a vertically-cropped short ready for posting. +""" +from typing import Dict + +from . import muapi +from .downloader import _extract_video_url + + +def crop_clip(source_video_url: str, start_time: float, end_time: float, aspect_ratio: str = "9:16") -> str: + """Submit one autocrop job and return the URL of the rendered short.""" + payload = { + "video_url": source_video_url, + "start_time": float(start_time), + "end_time": float(end_time), + "aspect_ratio": aspect_ratio, + } + print(f"[clip] {start_time:.1f}s → {end_time:.1f}s @ {aspect_ratio}", flush=True) + result = muapi.run("autocrop", payload, label=f"autocrop({start_time:.0f}-{end_time:.0f})") + return _extract_video_url(result) + + +def crop_highlights(source_video_url: str, highlights: list, aspect_ratio: str = "9:16") -> list: + """Crop every highlight, attaching the resulting URL back onto the dict.""" + out = [] + for i, h in enumerate(highlights, 1): + print(f"[clip] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True) + try: + url = crop_clip( + source_video_url, + h["start_time"], + h["end_time"], + aspect_ratio=aspect_ratio, + ) + out.append({**h, "clip_url": url}) + except Exception as e: + print(f"[clip] {i} failed: {e}", flush=True) + out.append({**h, "clip_url": None, "error": str(e)}) + return out diff --git a/ai-youtube-shorts-generator/shorts_generator/config.py b/ai-youtube-shorts-generator/shorts_generator/config.py new file mode 100644 index 0000000..8b695bc --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/config.py @@ -0,0 +1,67 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +MUAPI_API_KEY = os.getenv("MUAPI_API_KEY", "").strip() +MUAPI_BASE_URL = os.getenv("MUAPI_BASE_URL", "https://api.muapi.ai/api/v1").rstrip("/") + +POLL_INTERVAL_SECONDS = float(os.getenv("MUAPI_POLL_INTERVAL", "5")) +POLL_TIMEOUT_SECONDS = float(os.getenv("MUAPI_POLL_TIMEOUT", "600")) + +# Local-mode (--mode local) settings — only consulted when running offline. +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip() +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip() +GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") +LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").strip().lower() +LOCAL_WHISPER_MODEL = os.getenv("LOCAL_WHISPER_MODEL", "base") +LOCAL_WHISPER_DEVICE = os.getenv("LOCAL_WHISPER_DEVICE", "auto") # auto / cpu / cuda +LOCAL_OUTPUT_DIR = os.getenv("LOCAL_OUTPUT_DIR", "output") + +# VAD (Voice Activity Detection) settings for faster-whisper +# Default threshold is 0.5; lower = more sensitive, higher = less sensitive +# Default min_speech_duration_ms is 250ms; increase to avoid tiny false positives +# Default min_silence_duration_ms is 2000ms; increase to avoid splitting mid-sentence +# DISABLED by default because VAD is too aggressive on mixed speech/music content +LOCAL_WHISPER_VAD_FILTER = os.getenv("LOCAL_WHISPER_VAD_FILTER", "false").strip().lower() == "true" +_vad_params_env = os.getenv("LOCAL_WHISPER_VAD_PARAMETERS", "") +if _vad_params_env: + import json + LOCAL_WHISPER_VAD_PARAMETERS = json.loads(_vad_params_env) +else: + # Match faster-whisper defaults when VAD is enabled + LOCAL_WHISPER_VAD_PARAMETERS = { + "threshold": 0.5, + "min_speech_duration_ms": 250, + "max_speech_duration_s": float("inf"), + "min_silence_duration_ms": 2000, + "speech_pad_ms": 400, + } + + +def require_api_key() -> str: + if not MUAPI_API_KEY: + raise RuntimeError( + "MUAPI_API_KEY is not set. Add it to your .env file or export it as an env var." + ) + return MUAPI_API_KEY + + +def require_openai_key() -> str: + if not OPENAI_API_KEY: + raise RuntimeError( + "OPENAI_API_KEY is not set. Local mode needs an OpenAI key for highlight ranking. " + "Add it to your .env or export it, or switch back to --mode api." + ) + return OPENAI_API_KEY + + +def require_gemini_key() -> str: + if not GEMINI_API_KEY: + raise RuntimeError( + "GEMINI_API_KEY is not set. Local mode needs a Gemini key when LLM_PROVIDER=gemini. " + "Add it to your .env or export it, or switch LLM_PROVIDER back to openai." + ) + return GEMINI_API_KEY diff --git a/ai-youtube-shorts-generator/shorts_generator/downloader.py b/ai-youtube-shorts-generator/shorts_generator/downloader.py new file mode 100644 index 0000000..cd84e11 --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/downloader.py @@ -0,0 +1,36 @@ +"""YouTube source video download via MuAPI /youtube-download.""" +from typing import Dict + +from . import muapi + + +def _extract_video_url(result: Dict) -> str: + """MuAPI result shapes vary by endpoint — try common keys.""" + for key in ("video_url", "url", "output_url", "result_url"): + v = result.get(key) + if isinstance(v, str) and v.startswith("http"): + return v + + output = result.get("outputs") or result.get("output") or result.get("result") or {} + if isinstance(output, dict): + for key in ("video_url", "url", "output_url"): + v = output.get(key) + if isinstance(v, str) and v.startswith("http"): + return v + if isinstance(output, list) and output and isinstance(output[0], str) and output[0].startswith("http"): + return output[0] + + raise RuntimeError(f"Could not find downloaded video URL in MuAPI response: {result}") + + +def download_youtube(video_url: str, fmt: str = "720") -> str: + """Hand a YouTube URL to MuAPI; return a hosted mp4 URL we can read from.""" + print(f"[download] requesting {video_url} @ {fmt}p", flush=True) + result = muapi.run( + "youtube-download", + {"video_url": video_url, "format": fmt}, + label="youtube-download", + ) + out = _extract_video_url(result) + print(f"[download] ready: {out}", flush=True) + return out diff --git a/ai-youtube-shorts-generator/shorts_generator/highlights.py b/ai-youtube-shorts-generator/shorts_generator/highlights.py new file mode 100644 index 0000000..9b9a11f --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/highlights.py @@ -0,0 +1,306 @@ +"""Find the most viral-worthy highlights in a transcript. + +Logic ported from ViralVadoo's transcript_analysis/highlight_generator.py: + - content-type / density detection + - chunking for long videos with overlap + - virality-criteria prompt + - score-based dedupe with overlap suppression + +The LLM call is pluggable via the `llm_fn` argument so the same prompts can +drive either MuAPI (default, --mode api) or a direct local LLM client +(--mode local). +""" +import json +import re +from typing import Callable, Dict, List, Optional + +from . import muapi + + +LLMFn = Callable[[str], str] + + +CONTENT_TYPE_PROMPT = """Analyze this video transcript sample and classify the content type. +Choose one: podcast, interview, tutorial, lecture, commentary, debate, vlog, other. +Also estimate content density: low (mostly filler/chit-chat), medium, or high (dense info/stories). +Respond with JSON only: {"content_type": "...", "density": "..."}""" + + +VIRALITY_CRITERIA = """ +Virality signals to prioritize (ranked by impact): +1. HOOK MOMENTS — statements that create immediate curiosity ("The secret is...", "Nobody talks about...", "I was completely wrong about...") +2. EMOTIONAL PEAKS — genuine surprise, laughter, anger, vulnerability, excitement; raw unscripted reactions +3. OPINION BOMBS — strong, polarizing or counter-intuitive statements that trigger agree/disagree +4. REVELATION MOMENTS — surprising facts, stats, or confessions that reframe how the viewer thinks +5. CONFLICT/TENSION — disagreement, pushback, or a problem being confronted head-on +6. QUOTABLE ONE-LINERS — a sentence that works as a standalone quote card +7. STORY PEAKS — the climax or twist of an anecdote; the payoff moment +8. PRACTICAL VALUE — a concrete tip, hack, or insight the viewer can immediately apply +""" + + +HIGHLIGHT_SYSTEM_PROMPT = """You are an elite short-form video editor who has studied thousands of viral clips on TikTok, Instagram Reels, and YouTube Shorts. You know exactly what makes viewers stop scrolling, watch to the end, and share. + +{virality_criteria} + +Content type: {content_type} | Density: {density} + +Your task: identify the most viral-worthy highlights from the transcript. + +Rules: +- Every highlight must open with a strong HOOK — a line that grabs attention within the first 3 seconds +- Duration sweet spot: 45-90 seconds. Go shorter (20-44s) only for a perfect standalone one-liner. Go longer (91-180s) only when a story arc needs full context to land +- Never cut mid-sentence or mid-thought — each clip must feel complete and self-contained +- Clips must not overlap significantly with each other +- Score 0-100 on viral potential (not general quality) +- {num_clips_instruction} +- For each highlight, identify the single best "hook_sentence" — the opening line that would make someone stop scrolling +- Explain in one sentence why this clip is viral ("virality_reason") + +Respond ONLY with valid JSON (no markdown, no explanation): +{{"highlights":[{{"title":"string","start_time":float,"end_time":float,"score":int,"hook_sentence":"string","virality_reason":"string"}}]}}""" + + +CHUNK_SIZE_SECONDS = 1200 # 20-min chunks for long videos +LONG_VIDEO_THRESHOLD = 1800 # chunk videos longer than 30 min +CHUNK_OVERLAP_SECONDS = 60 +GPT_CALL_TIMEOUT_SECONDS = 300 # cap LLM polls at 5 min — a wedged call should fail fast +MAX_HIGHLIGHT_API_ATTEMPTS = 3 + + +def call_muapi_llm(prompt: str) -> str: + """Default LLM backend: MuAPI gpt-5-mini.""" + result = muapi.run( + "gpt-5-mini", + {"prompt": prompt}, + label="gpt-5-mini", + timeout=GPT_CALL_TIMEOUT_SECONDS, + ) + + outputs = result.get("outputs") + if isinstance(outputs, list) and outputs and isinstance(outputs[0], str) and outputs[0].strip(): + return outputs[0] + + for key in ("output", "text", "response", "result", "content"): + v = result.get(key) + if isinstance(v, str) and v.strip(): + return v + if isinstance(v, dict): + inner = v.get("text") or v.get("content") + if isinstance(inner, str) and inner.strip(): + return inner + if isinstance(v, list) and v and isinstance(v[0], str): + return v[0] + + raise RuntimeError(f"Could not extract gpt-5-mini text from response: {result}") + + +def _parse_json_loose(raw: str) -> Dict: + """gpt-5-4 sometimes wraps JSON in markdown fences — strip and parse.""" + text = raw.strip() + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1: + return json.loads(text[start:end + 1]) + raise + + +def _coerce_float(value: object, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _coerce_int(value: object, default: int = 0) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _sanitize_highlights(raw_highlights: object, duration: float) -> List[Dict]: + """Normalize model output into the expected shape; skip invalid entries.""" + if not isinstance(raw_highlights, list): + return [] + + max_end = duration if duration > 0 else float("inf") + cleaned: List[Dict] = [] + for item in raw_highlights: + if not isinstance(item, dict): + continue + + start = _coerce_float(item.get("start_time"), default=-1.0) + end = _coerce_float(item.get("end_time"), default=-1.0) + if start < 0 or end <= start: + continue + + if max_end != float("inf"): + start = min(start, max_end) + end = min(end, max_end) + if end <= start: + continue + + cleaned.append( + { + "title": str(item.get("title") or "Untitled Highlight").strip(), + "start_time": start, + "end_time": end, + "score": max(0, min(100, _coerce_int(item.get("score"), default=0))), + "hook_sentence": str(item.get("hook_sentence") or "").strip(), + "virality_reason": str(item.get("virality_reason") or "").strip(), + } + ) + + return cleaned + + +def detect_content_type(transcript: Dict, llm_fn: LLMFn = call_muapi_llm) -> Dict[str, str]: + segments = transcript.get("segments", []) + sample = " ".join(s["text"] for s in segments[:25])[:3000] + prompt = f"{CONTENT_TYPE_PROMPT}\n\nTranscript sample:\n{sample}" + try: + raw = llm_fn(prompt) + return _parse_json_loose(raw) + except Exception: + return {"content_type": "other", "density": "medium"} + + +def build_transcript_text(transcript: Dict) -> str: + segments = transcript.get("segments", []) + return "\n".join(f"[{s['start']:.1f}s] {s['text'].strip()}" for s in segments) + + +def chunk_transcript(transcript: Dict) -> List[Dict]: + segments = transcript.get("segments", []) + duration = transcript.get("duration", segments[-1]["end"] if segments else 0) + chunks = [] + start = 0 + while start < duration: + end = min(start + CHUNK_SIZE_SECONDS, duration) + chunk_segs = [ + s for s in segments + if s["start"] >= start and s["end"] <= end + CHUNK_OVERLAP_SECONDS + ] + if chunk_segs: + chunk = dict(transcript) + chunk["segments"] = chunk_segs + chunk["duration"] = end - start + chunk["_offset"] = start + chunks.append(chunk) + start += CHUNK_SIZE_SECONDS - CHUNK_OVERLAP_SECONDS + return chunks + + +def call_highlight_api( + transcript_text: str, + content_info: Dict, + duration: float, + num_clips: int, + is_chunk: bool = False, + llm_fn: LLMFn = call_muapi_llm, +) -> Dict: + # Ask for ~2× the user's target so dedupe has headroom, but cap so the model + # doesn't have to generate a huge JSON payload (which times out gpt-5-mini). + target = max(num_clips * 2, 5) + natural_max = max(2 if is_chunk else 3, int(duration / 90)) + min_clips = min(target, natural_max, 8) + system = HIGHLIGHT_SYSTEM_PROMPT.format( + virality_criteria=VIRALITY_CRITERIA, + content_type=content_info.get("content_type", "other"), + density=content_info.get("density", "medium"), + num_clips_instruction=f"Generate at least {min_clips} highlights", + ) + base_prompt = f"{system}\n\nTranscript:\n{transcript_text}" + prompt = base_prompt + last_error = "unknown" + + for attempt in range(1, MAX_HIGHLIGHT_API_ATTEMPTS + 1): + raw = llm_fn(prompt) + try: + parsed = _parse_json_loose(raw) + highlights = _sanitize_highlights(parsed.get("highlights"), duration=duration) + if highlights: + return {"highlights": highlights} + last_error = "no valid highlights in response" + except Exception as e: + last_error = str(e) + + if attempt < MAX_HIGHLIGHT_API_ATTEMPTS: + print( + f"[highlights] invalid model output on attempt {attempt}/{MAX_HIGHLIGHT_API_ATTEMPTS}; retrying", + flush=True, + ) + prompt = ( + base_prompt + + "\n\nIMPORTANT: Return ONLY valid JSON with a top-level 'highlights' array." + + " Each item must include: title, start_time, end_time, score, hook_sentence, virality_reason." + + " No markdown fences, no commentary." + ) + + raise RuntimeError( + f"Highlight generator produced invalid output after {MAX_HIGHLIGHT_API_ATTEMPTS} attempts: {last_error}" + ) + + +def dedupe_highlights(highlights: List[Dict]) -> List[Dict]: + """Drop a highlight if it overlaps >50% with a higher-scoring one already kept.""" + highlights = sorted(highlights, key=lambda x: int(x.get("score", 0)), reverse=True) + kept: List[Dict] = [] + for h in highlights: + h_start = float(h["start_time"]) + h_end = float(h["end_time"]) + h_dur = h_end - h_start + overlapping = False + for k in kept: + latest_start = max(h_start, float(k["start_time"])) + earliest_end = min(h_end, float(k["end_time"])) + overlap = earliest_end - latest_start + if overlap > 0 and overlap > 0.5 * h_dur: + overlapping = True + break + if not overlapping: + kept.append(h) + return kept + + +def get_highlights( + transcript: Dict, + num_clips: int = 3, + llm_fn: Optional[LLMFn] = None, +) -> Dict: + """Main entry point — returns {highlights: [...]} sorted by score. + + `llm_fn` swaps the underlying LLM. Defaults to MuAPI gpt-5-mini; local + mode passes in a local LLM-backed callable. + """ + llm_fn = llm_fn or call_muapi_llm + duration = transcript.get("duration", 0) + content_info = detect_content_type(transcript, llm_fn=llm_fn) + print(f"[highlights] content={content_info.get('content_type')} density={content_info.get('density')} duration={duration:.0f}s", flush=True) + + if duration >= LONG_VIDEO_THRESHOLD: + chunks = chunk_transcript(transcript) + print(f"[highlights] long video — splitting into {len(chunks)} chunks", flush=True) + all_highlights: List[Dict] = [] + for i, chunk in enumerate(chunks): + offset = chunk.get("_offset", 0) + text = build_transcript_text(chunk) + print(f"[highlights] chunk {i + 1}/{len(chunks)} (offset {offset:.0f}s)", flush=True) + result = call_highlight_api(text, content_info, chunk["duration"], num_clips=num_clips, is_chunk=True, llm_fn=llm_fn) + for h in result.get("highlights", []): + h["start_time"] = float(h["start_time"]) + offset + h["end_time"] = float(h["end_time"]) + offset + all_highlights.append(h) + highlights = dedupe_highlights(all_highlights) + else: + text = build_transcript_text(transcript) + result = call_highlight_api(text, content_info, duration, num_clips=num_clips, llm_fn=llm_fn) + highlights = dedupe_highlights(result.get("highlights", [])) + + return {"highlights": highlights} diff --git a/ai-youtube-shorts-generator/shorts_generator/local/__init__.py b/ai-youtube-shorts-generator/shorts_generator/local/__init__.py new file mode 100644 index 0000000..51d2c42 --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/local/__init__.py @@ -0,0 +1,6 @@ +"""Local-mode backends — no MuAPI calls, runs on your machine. + +Used when the pipeline is invoked with mode="local". Requires the optional +deps in requirements-local.txt (yt-dlp, faster-whisper, openai, google-genai, +opencv, moviepy) plus an LLM API key for highlight ranking. +""" diff --git a/ai-youtube-shorts-generator/shorts_generator/local/clipper.py b/ai-youtube-shorts-generator/shorts_generator/local/clipper.py new file mode 100644 index 0000000..931f99c --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/local/clipper.py @@ -0,0 +1,167 @@ +"""Local clipping: ffmpeg subclip + OpenCV face-aware vertical crop. + +Two stages per highlight: + 1. Cut the source video to [start, end] with ffmpeg (re-encoded, audio kept). + 2. Reframe the cut to the target aspect ratio. For 9:16 we slide a vertical + window horizontally across the frame to keep faces centred (Haar + cascade — same approach as the original repo, no external models). +""" +import os +import subprocess +from typing import Dict, List, Optional, Tuple + +from ..config import LOCAL_OUTPUT_DIR + + +def _ratio(aspect_ratio: str) -> float: + """Parse '9:16' → 9/16, '1:1' → 1.0.""" + try: + w, h = aspect_ratio.split(":") + return float(w) / float(h) + except (ValueError, ZeroDivisionError): + return 9.0 / 16.0 + + +def _cut_subclip(source_path: str, start: float, end: float, out_path: str) -> str: + """ffmpeg -ss start -to end → re-encoded mp4 with audio.""" + cmd = [ + "ffmpeg", "-y", "-loglevel", "error", + "-i", source_path, + "-ss", f"{start:.3f}", + "-to", f"{end:.3f}", + "-c:v", "libx264", "-preset", "fast", "-crf", "20", + "-c:a", "aac", "-b:a", "128k", + out_path, + ] + subprocess.run(cmd, check=True) + return out_path + + +def _reframe_vertical(in_path: str, out_path: str, aspect_ratio: str) -> str: + """Crop the cut clip to the target aspect ratio, tracking faces if possible.""" + try: + import cv2 # type: ignore + except ImportError as e: + raise RuntimeError( + "opencv-python is required for --mode local. Install it with:\n" + " pip install -r requirements-local.txt" + ) from e + + target_ratio = _ratio(aspect_ratio) + cap = cv2.VideoCapture(in_path) + if not cap.isOpened(): + raise RuntimeError(f"could not open {in_path}") + + src_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + src_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 + + # Compute the largest crop that fits inside the frame at the target ratio. + if target_ratio < src_w / src_h: + crop_h = src_h + crop_w = int(crop_h * target_ratio) + else: + crop_w = src_w + crop_h = int(crop_w / target_ratio) + crop_w = max(2, crop_w - (crop_w % 2)) + crop_h = max(2, crop_h - (crop_h % 2)) + + face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml") + + silent_path = out_path + ".silent.mp4" + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + writer = cv2.VideoWriter(silent_path, fourcc, fps, (crop_w, crop_h)) + + last_center: Optional[Tuple[int, int]] = None + smoothing = 0.15 # how aggressively to chase a new face position + while True: + ret, frame = cap.read() + if not ret: + break + + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40)) + if len(faces) > 0: + # Pick the largest face — usually the speaker. + x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) + cx = x + w // 2 + cy = y + h // 2 + if last_center is None: + last_center = (cx, cy) + else: + lx, ly = last_center + last_center = ( + int(lx + (cx - lx) * smoothing), + int(ly + (cy - ly) * smoothing), + ) + if last_center is None: + last_center = (src_w // 2, src_h // 2) + + cx, cy = last_center + x0 = max(0, min(src_w - crop_w, cx - crop_w // 2)) + y0 = max(0, min(src_h - crop_h, cy - crop_h // 2)) + cropped = frame[y0:y0 + crop_h, x0:x0 + crop_w] + writer.write(cropped) + + cap.release() + writer.release() + + # Mux audio from the cut clip back onto the silent reframed video. + cmd = [ + "ffmpeg", "-y", "-loglevel", "error", + "-i", silent_path, + "-i", in_path, + "-c:v", "copy", + "-c:a", "aac", "-b:a", "128k", + "-map", "0:v:0", "-map", "1:a:0?", + "-shortest", + out_path, + ] + subprocess.run(cmd, check=True) + os.remove(silent_path) + return out_path + + +def crop_clip_local( + source_path: str, + start_time: float, + end_time: float, + aspect_ratio: str, + out_path: str, +) -> str: + """Cut + reframe one highlight, returning the local mp4 path.""" + cut_path = out_path + ".cut.mp4" + try: + _cut_subclip(source_path, start_time, end_time, cut_path) + _reframe_vertical(cut_path, out_path, aspect_ratio) + finally: + if os.path.exists(cut_path): + os.remove(cut_path) + return out_path + + +def crop_highlights_local( + source_path: str, + highlights: List[Dict], + aspect_ratio: str = "9:16", + out_dir: Optional[str] = None, +) -> List[Dict]: + out_dir = out_dir or LOCAL_OUTPUT_DIR + os.makedirs(out_dir, exist_ok=True) + results: List[Dict] = [] + for i, h in enumerate(highlights, 1): + out_path = os.path.join(out_dir, f"short_{i:02d}.mp4") + print(f"[clip/local] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True) + try: + crop_clip_local( + source_path, + float(h["start_time"]), + float(h["end_time"]), + aspect_ratio, + out_path, + ) + results.append({**h, "clip_url": out_path}) + except Exception as e: + print(f"[clip/local] {i} failed: {e}", flush=True) + results.append({**h, "clip_url": None, "error": str(e)}) + return results diff --git a/ai-youtube-shorts-generator/shorts_generator/local/downloader.py b/ai-youtube-shorts-generator/shorts_generator/local/downloader.py new file mode 100644 index 0000000..b18fefb --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/local/downloader.py @@ -0,0 +1,135 @@ +"""Local YouTube download via yt-dlp. + +Returns a local mp4 path so the rest of the local pipeline can read it +directly off disk. +""" +import os +import re +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse +from typing import Optional + +from ..config import LOCAL_OUTPUT_DIR + + +def _import_ytdlp(): + try: + import yt_dlp # type: ignore + except ImportError as e: + raise RuntimeError( + "yt-dlp is required for --mode local. Install it with:\n" + " pip install -r requirements-local.txt" + ) from e + return yt_dlp + + +def _format_for(fmt: str) -> str: + """Map our '720' / '1080' shorthand to a yt-dlp format selector.""" + try: + height = int(fmt) + except ValueError: + height = 720 + return ( + f"bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/" + f"best[height<={height}][ext=mp4]/best" + ) + + +def _extract_youtube_video_id(source: str) -> Optional[str]: + """Best-effort extraction of a YouTube video id from a URL.""" + parsed = urlparse(source) + host = (parsed.netloc or "").lower() + if host.startswith("www."): + host = host[4:] + + if host in ("youtu.be", "www.youtu.be"): + video_id = parsed.path.lstrip("/").split("/", 1)[0] + return video_id or None + + if "youtube.com" in host: + if parsed.path.startswith("/watch"): + qs = parse_qs(parsed.query) + video_id = qs.get("v", [""])[0] + return video_id or None + match = re.search(r"/(?:shorts|embed|live)/([^/?#&]+)", parsed.path) + if match: + return match.group(1) + + return None + + +def _resolve_local_path(source: str) -> Optional[str]: + """Return a local filesystem path if the input already points at one.""" + parsed = urlparse(source) + if parsed.scheme == "file": + raw_path = unquote(parsed.path) + if parsed.netloc and parsed.netloc not in ("", "localhost"): + raw_path = f"//{parsed.netloc}{raw_path}" + candidate = Path(raw_path).expanduser() + if candidate.exists() and candidate.is_file(): + return str(candidate.resolve()) + raise RuntimeError(f"Local file URL does not exist: {source}") + + if parsed.scheme in ("http", "https"): + return None + + candidate = Path(source).expanduser() + if candidate.exists() and candidate.is_file(): + return str(candidate.resolve()) + + if any(sep in source for sep in (os.sep, "/")) or source.startswith("~") or source.startswith("."): + raise RuntimeError(f"Local file path does not exist: {source}") + + return None + + +def _existing_download(out_dir: str, video_id: str) -> Optional[str]: + """Return a cached download path if we already have this YouTube id.""" + for ext in (".mp4", ".mkv", ".webm"): + candidate = os.path.join(out_dir, f"source_{video_id}{ext}") + if os.path.exists(candidate): + return candidate + return None + + +def download_youtube_local(video_url: str, fmt: str = "720", out_dir: Optional[str] = None) -> str: + """Download a remote URL or return a local file path unchanged.""" + local_path = _resolve_local_path(video_url) + if local_path: + print(f"[download/local] using local file: {local_path}", flush=True) + return local_path + + yt_dlp = _import_ytdlp() + out_dir = out_dir or LOCAL_OUTPUT_DIR + os.makedirs(out_dir, exist_ok=True) + + video_id = _extract_youtube_video_id(video_url) + if video_id: + cached = _existing_download(out_dir, video_id) + if cached: + print(f"[download/local] reusing cached download: {cached}", flush=True) + return cached + + print(f"[download/local] {video_url} @ {fmt}p → {out_dir}/", flush=True) + ydl_opts = { + "format": _format_for(fmt), + "outtmpl": os.path.join(out_dir, "source_%(id)s.%(ext)s"), + "merge_output_format": "mp4", + "quiet": True, + "no_warnings": True, + "noprogress": True, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(video_url, download=True) + path = ydl.prepare_filename(info) + # merge_output_format may rename the extension after merge + if not os.path.exists(path): + stem, _ = os.path.splitext(path) + for ext in (".mp4", ".mkv", ".webm"): + if os.path.exists(stem + ext): + path = stem + ext + break + + print(f"[download/local] ready: {path}", flush=True) + return path diff --git a/ai-youtube-shorts-generator/shorts_generator/local/llm.py b/ai-youtube-shorts-generator/shorts_generator/local/llm.py new file mode 100644 index 0000000..873c05d --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/local/llm.py @@ -0,0 +1,62 @@ +"""Local LLM backend — OpenAI or Gemini, selected by LLM_PROVIDER.""" +from ..config import ( + GEMINI_MODEL, + LLM_PROVIDER, + OPENAI_MODEL, + require_gemini_key, + require_openai_key, +) + + +def call_openai_llm(prompt: str) -> str: + """OpenAI Chat Completions backend used by --mode local.""" + try: + from openai import OpenAI # type: ignore + except ImportError as e: + raise RuntimeError( + "openai is required for --mode local. Install it with:\n" + " pip install -r requirements-local.txt" + ) from e + + client = OpenAI(api_key=require_openai_key()) + response = client.chat.completions.create( + model=OPENAI_MODEL, + temperature=0.7, + messages=[{"role": "user", "content": prompt}], + ) + return response.choices[0].message.content or "" + + +def call_gemini_llm(prompt: str) -> str: + """Gemini backend used by --mode local when LLM_PROVIDER=gemini.""" + try: + from google import genai # type: ignore + except ImportError as e: + raise RuntimeError( + "google-genai is required for LLM_PROVIDER=gemini. Install it with:\n" + " pip install -r requirements-local.txt" + ) from e + + client = genai.Client(api_key=require_gemini_key()) + response = client.models.generate_content( + model=GEMINI_MODEL, + contents=prompt, + config={ + "temperature": 0.2, + "response_mime_type": "application/json", + "max_output_tokens": 8192, + }, + ) + return response.text or "" + + +def call_local_llm(prompt: str) -> str: + """Dispatch to the configured local LLM provider.""" + provider = (LLM_PROVIDER or "openai").strip().lower() + if provider == "openai": + return call_openai_llm(prompt) + if provider == "gemini": + return call_gemini_llm(prompt) + raise RuntimeError( + f"Unknown LLM_PROVIDER={provider!r}. Use 'openai' or 'gemini'." + ) diff --git a/ai-youtube-shorts-generator/shorts_generator/local/transcriber.py b/ai-youtube-shorts-generator/shorts_generator/local/transcriber.py new file mode 100644 index 0000000..1811e3b --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/local/transcriber.py @@ -0,0 +1,162 @@ +"""Local transcription via faster-whisper. + +Reads a local media file and returns the same shape the highlight generator +expects: {duration, segments[start, end, text]}. +""" +import os +import re +from pathlib import Path +from typing import Dict, Optional + +from ..config import LOCAL_OUTPUT_DIR, LOCAL_WHISPER_DEVICE, LOCAL_WHISPER_MODEL + + +def _transcript_cache_path(media_path: str) -> Path: + """Return the .srt cache path for a media file.""" + cache_dir = Path(LOCAL_OUTPUT_DIR) + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / (Path(media_path).stem + ".srt") + + +def _format_srt_timestamp(seconds: float) -> str: + total_ms = max(0, int(round(seconds * 1000))) + ms = total_ms % 1000 + total_s = total_ms // 1000 + s = total_s % 60 + total_m = total_s // 60 + m = total_m % 60 + h = total_m // 60 + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def _parse_srt_timestamp(value: str) -> float: + match = re.fullmatch(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})", value.strip()) + if not match: + raise ValueError(f"Invalid SRT timestamp: {value!r}") + hours, minutes, seconds, millis = map(int, match.groups()) + return hours * 3600 + minutes * 60 + seconds + (millis / 1000.0) + + +def _write_srt_cache(media_path: str, transcript: Dict) -> Path: + cache_path = _transcript_cache_path(media_path) + lines = [] + for idx, segment in enumerate(transcript.get("segments", []), start=1): + start = _format_srt_timestamp(float(segment["start"])) + end = _format_srt_timestamp(float(segment["end"])) + text = str(segment.get("text", "")).strip().replace("\r", "").replace("\n", " ") + lines.append(str(idx)) + lines.append(f"{start} --> {end}") + lines.append(text) + lines.append("") + + cache_path.write_text("\n".join(lines), encoding="utf-8") + return cache_path + + +def _load_srt_cache(cache_path: Path) -> Dict: + content = cache_path.read_text(encoding="utf-8-sig").strip() + if not content: + return {"duration": 0.0, "segments": []} + + segments = [] + for block in re.split(r"\n\s*\n", content): + lines = [line.strip("\ufeff") for line in block.splitlines() if line.strip()] + if not lines: + continue + if "-->" not in lines[0] and len(lines) > 1 and "-->" in lines[1]: + lines = lines[1:] + if not lines or "-->" not in lines[0]: + continue + start_raw, end_raw = [part.strip() for part in lines[0].split("-->", 1)] + text = "\n".join(lines[1:]).strip() + segments.append( + { + "start": _parse_srt_timestamp(start_raw), + "end": _parse_srt_timestamp(end_raw), + "text": text, + } + ) + + duration = segments[-1]["end"] if segments else 0.0 + return {"duration": duration, "segments": segments} + + +def _resolve_device() -> str: + if LOCAL_WHISPER_DEVICE != "auto": + return LOCAL_WHISPER_DEVICE + try: + import torch # type: ignore + if torch.cuda.is_available(): + # Test that CUDA actually works (catches missing cuBLAS/cuDNN libs) + torch.zeros(1, device="cuda") + return "cuda" + except (ImportError, OSError, RuntimeError): + pass + return "cpu" + + +def transcribe_local(media_path: str, language: Optional[str] = None) -> Dict: + """Run faster-whisper on a local file path, caching the result as .srt.""" + cache_path = _transcript_cache_path(media_path) + if cache_path.exists(): + source_mtime = os.path.getmtime(media_path) + cache_mtime = cache_path.stat().st_mtime + if cache_mtime >= source_mtime: + print(f"[transcribe/local] reusing cached transcript: {cache_path}", flush=True) + cached = _load_srt_cache(cache_path) + # Treat empty cache as invalid (likely from a failed/partial run) — delete and re-transcribe + if not cached["segments"] or cached["duration"] <= 0.0: + print(f"[transcribe/local] cache is empty/invalid, deleting: {cache_path}", flush=True) + cache_path.unlink(missing_ok=True) + else: + print( + f"[transcribe/local] {len(cached['segments'])} cached segments, " + f"{cached['duration']:.0f}s of audio", + flush=True, + ) + return cached + + try: + from faster_whisper import WhisperModel # type: ignore + except ImportError as e: + raise RuntimeError( + "faster-whisper is required for --mode local. Install it with:\n" + " pip install -r requirements-local.txt" + ) from e + + device = _resolve_device() + compute_type = "float16" if device == "cuda" else "int8" + print(f"[transcribe/local] faster-whisper model={LOCAL_WHISPER_MODEL} device={device}", flush=True) + + from ..config import LOCAL_WHISPER_VAD_FILTER, LOCAL_WHISPER_VAD_PARAMETERS + + model = WhisperModel(LOCAL_WHISPER_MODEL, device=device, compute_type=compute_type) + + transcribe_kwargs = { + "audio": media_path, + "language": language, + "beam_size": 5, + "condition_on_previous_text": False, + } + if LOCAL_WHISPER_VAD_FILTER: + transcribe_kwargs["vad_filter"] = True + transcribe_kwargs["vad_parameters"] = LOCAL_WHISPER_VAD_PARAMETERS + else: + transcribe_kwargs["vad_filter"] = False + + segments_iter, info = model.transcribe(**transcribe_kwargs) + + segments = [] + for s in segments_iter: + segments.append({ + "start": float(s.start), + "end": float(s.end), + "text": (s.text or "").strip(), + }) + + duration = float(getattr(info, "duration", 0.0)) or (segments[-1]["end"] if segments else 0.0) + print(f"[transcribe/local] {len(segments)} segments, {duration:.0f}s of audio", flush=True) + transcript = {"duration": duration, "segments": segments} + cache_path = _write_srt_cache(media_path, transcript) + print(f"[transcribe/local] wrote cache: {cache_path}", flush=True) + return transcript diff --git a/ai-youtube-shorts-generator/shorts_generator/muapi.py b/ai-youtube-shorts-generator/shorts_generator/muapi.py new file mode 100644 index 0000000..7c079a2 --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/muapi.py @@ -0,0 +1,97 @@ +"""Thin MuAPI client: submit a job, poll until it finishes, return the result.""" +import time +from typing import Any, Dict, Optional + +import requests + +from .config import ( + MUAPI_BASE_URL, + POLL_INTERVAL_SECONDS, + POLL_TIMEOUT_SECONDS, + require_api_key, +) + + +class MuAPIError(RuntimeError): + pass + + +def _headers() -> Dict[str, str]: + return { + "Content-Type": "application/json", + "x-api-key": require_api_key(), + } + + +def submit(endpoint: str, payload: Dict[str, Any], retries: int = 3) -> str: + """POST to /api/v1/{endpoint} and return the request_id; retry transient errors.""" + url = f"{MUAPI_BASE_URL}/{endpoint.lstrip('/')}" + last_err: Optional[Exception] = None + for _ in range(retries): + try: + resp = requests.post(url, json=payload, headers=_headers(), timeout=120) + if resp.status_code >= 400: + raise MuAPIError(f"{endpoint} submit failed [{resp.status_code}]: {resp.text}") + data = resp.json() + request_id = data.get("request_id") or data.get("id") + if not request_id: + raise MuAPIError(f"{endpoint} response had no request_id: {data}") + return str(request_id) + except (requests.Timeout, requests.ConnectionError) as e: + last_err = e + time.sleep(2) + raise MuAPIError(f"{endpoint} submit failed after {retries} retries: {last_err}") + + +def fetch_result(request_id: str, retries: int = 3) -> Dict[str, Any]: + """GET the latest result for a request_id; retry on transient timeouts.""" + url = f"{MUAPI_BASE_URL}/predictions/{request_id}/result" + last_err: Optional[Exception] = None + for _ in range(retries): + try: + resp = requests.get(url, headers=_headers(), timeout=90) + if resp.status_code >= 400: + raise MuAPIError(f"poll failed [{resp.status_code}]: {resp.text}") + return resp.json() + except (requests.Timeout, requests.ConnectionError) as e: + last_err = e + time.sleep(2) + raise MuAPIError(f"poll failed after {retries} retries: {last_err}") + + +def poll( + request_id: str, + interval: float = POLL_INTERVAL_SECONDS, + timeout: float = POLL_TIMEOUT_SECONDS, + label: Optional[str] = None, +) -> Dict[str, Any]: + """Block until the prediction is done; return the final payload.""" + deadline = time.time() + timeout + last_status = None + while time.time() < deadline: + data = fetch_result(request_id) + status = (data.get("status") or "").lower() + if status and status != last_status: + print(f"[muapi] {label or request_id}: {status}", flush=True) + last_status = status + + if status in ("completed", "succeeded", "success"): + return data + if status in ("failed", "error"): + raise MuAPIError(f"{label or request_id} failed: {data}") + + time.sleep(interval) + + raise MuAPIError(f"{label or request_id} timed out after {timeout}s") + + +def run( + endpoint: str, + payload: Dict[str, Any], + label: Optional[str] = None, + interval: float = POLL_INTERVAL_SECONDS, + timeout: float = POLL_TIMEOUT_SECONDS, +) -> Dict[str, Any]: + """Submit then poll. Returns the final result payload.""" + request_id = submit(endpoint, payload) + return poll(request_id, interval=interval, timeout=timeout, label=label or endpoint) diff --git a/ai-youtube-shorts-generator/shorts_generator/pipeline.py b/ai-youtube-shorts-generator/shorts_generator/pipeline.py new file mode 100644 index 0000000..3d44810 --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/pipeline.py @@ -0,0 +1,123 @@ +"""End-to-end orchestrator. + +Two modes: + * mode="api" (default) — MuAPI does download / transcribe / LLM / autocrop. + Fast, no local deps, pay-per-call. + * mode="local" — yt-dlp + faster-whisper + OpenAI or Gemini + ffmpeg/opencv. + Self-hosted, LLM_PROVIDER selects OpenAI or Gemini. +""" +from typing import Dict, List, Optional + +from .clipper import crop_highlights +from .downloader import download_youtube +from .highlights import call_muapi_llm, get_highlights +from .transcriber import transcribe + + +def _run_local( + youtube_url: str, + num_clips: int, + aspect_ratio: str, + download_format: str, + language: Optional[str], +) -> Dict: + from .local.clipper import crop_highlights_local + from .local.downloader import download_youtube_local + from .local.llm import call_local_llm + from .local.transcriber import transcribe_local + + source_path = download_youtube_local(youtube_url, fmt=download_format) + + transcript = transcribe_local(source_path, language=language) + if not transcript["segments"]: + raise RuntimeError( + "Whisper produced no segments. The video may have no detectable speech." + ) + + highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_local_llm) + all_highlights: List[Dict] = highlights_result.get("highlights", []) + if not all_highlights: + raise RuntimeError("Highlight generator returned zero clips.") + + top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips] + print(f"[pipeline/local] cropping {len(top)} of {len(all_highlights)} candidates", flush=True) + + shorts = crop_highlights_local(source_path, top, aspect_ratio=aspect_ratio) + + return { + "mode": "local", + "source_video_url": source_path, + "transcript": transcript, + "highlights": all_highlights, + "shorts": shorts, + } + + +def _run_api( + youtube_url: str, + num_clips: int, + aspect_ratio: str, + download_format: str, + language: Optional[str], +) -> Dict: + source_url = download_youtube(youtube_url, fmt=download_format) + + transcript = transcribe(source_url, language=language) + if not transcript["segments"]: + raise RuntimeError( + "Whisper produced no segments. The video may have no detectable speech." + ) + + highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_muapi_llm) + all_highlights: List[Dict] = highlights_result.get("highlights", []) + if not all_highlights: + raise RuntimeError("Highlight generator returned zero clips.") + + top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips] + print(f"[pipeline] cropping {len(top)} of {len(all_highlights)} candidates", flush=True) + + shorts = crop_highlights(source_url, top, aspect_ratio=aspect_ratio) + + return { + "mode": "api", + "source_video_url": source_url, + "transcript": transcript, + "highlights": all_highlights, + "shorts": shorts, + } + + +def generate_shorts( + youtube_url: str, + num_clips: int = 3, + aspect_ratio: str = "9:16", + download_format: str = "720", + language: Optional[str] = None, + mode: str = "api", +) -> Dict: + """Run the full pipeline and return a structured result. + + Args: + youtube_url: source URL. + num_clips: how many shorts to render. + aspect_ratio: e.g. "9:16", "1:1". + download_format: source resolution ("360" / "480" / "720" / "1080"). + language: ISO-639-1 to force Whisper language detection. + mode: "api" (default, MuAPI) or "local" (yt-dlp + faster-whisper + + OpenAI or Gemini + ffmpeg). + + Returns: + { + "mode": "api" | "local", + "source_video_url": str, # hosted URL (api) or local path (local) + "transcript": {...}, + "highlights": [...], # all candidates ranked + "shorts": [...], # top `num_clips` with clip_url / local path + } + """ + mode = (mode or "api").lower() + if mode == "local": + return _run_local(youtube_url, num_clips, aspect_ratio, download_format, language) + if mode == "api": + return _run_api(youtube_url, num_clips, aspect_ratio, download_format, language) + raise ValueError(f"Unknown mode: {mode!r}. Use 'api' or 'local'.") diff --git a/ai-youtube-shorts-generator/shorts_generator/transcriber.py b/ai-youtube-shorts-generator/shorts_generator/transcriber.py new file mode 100644 index 0000000..faa8fe2 --- /dev/null +++ b/ai-youtube-shorts-generator/shorts_generator/transcriber.py @@ -0,0 +1,76 @@ +"""Transcription via MuAPI /openai-whisper. + +Sends a hosted media URL to MuAPI's Whisper endpoint and returns the segment +shape expected by the highlight generator: {duration, segments[start,end,text]}. +The API runs verbose_json server-side, so we get per-segment timestamps for free. +""" +import json +from typing import Dict, Optional + +from . import muapi + + +def _coerce_verbose(raw) -> Dict: + """The /openai-whisper result can land as a dict or a JSON string depending on + how the worker stored it. Normalise to a dict with `duration` and `segments`.""" + if isinstance(raw, str): + try: + return json.loads(raw) + except (TypeError, ValueError): + return {} + if isinstance(raw, dict): + return raw + return {} + + +def _extract_verbose_payload(result: Dict) -> Dict: + """MuAPI wraps results inconsistently across endpoints. Hunt for the + verbose_json blob (which has `segments` + `duration`).""" + for key in ("output", "result", "outputs"): + v = result.get(key) + if isinstance(v, dict) and "segments" in v: + return v + if isinstance(v, list) and v: + first = v[0] + decoded = _coerce_verbose(first) + if "segments" in decoded: + return decoded + if isinstance(v, str): + decoded = _coerce_verbose(v) + if "segments" in decoded: + return decoded + + if "segments" in result: + return result + + raise RuntimeError(f"Could not find Whisper segments in MuAPI response: {result}") + + +def transcribe(media_url: str, language: Optional[str] = None) -> Dict: + """Run MuAPI /openai-whisper on a hosted media URL. + + Returns {duration: float, segments: [{start, end, text}, ...]} so it slots + straight into the highlight generator. + """ + print(f"[transcribe] muapi /openai-whisper on {media_url}", flush=True) + payload = { + "audio_url": media_url, + "response_format": "verbose_json", + } + if language: + payload["language"] = language + + result = muapi.run("openai-whisper", payload, label="openai-whisper") + verbose = _extract_verbose_payload(result) + + segments = [] + for s in verbose.get("segments") or []: + segments.append({ + "start": float(s.get("start", 0.0)), + "end": float(s.get("end", 0.0)), + "text": (s.get("text") or "").strip(), + }) + + duration = float(verbose.get("duration") or (segments[-1]["end"] if segments else 0.0)) + print(f"[transcribe] {len(segments)} segments, {duration:.0f}s of audio", flush=True) + return {"duration": duration, "segments": segments} diff --git a/brainrotinator b/brainrotinator deleted file mode 160000 index 76983b0..0000000 --- a/brainrotinator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 76983b03b7a8bd0eb7c2450658f0a5e0029c33ad diff --git a/brainrotinator/.dockerignore b/brainrotinator/.dockerignore new file mode 100644 index 0000000..875dea3 --- /dev/null +++ b/brainrotinator/.dockerignore @@ -0,0 +1,47 @@ +#ignore these directories and files +#done_split/ +#to_split/ +__pycache__/ +#subtitles/ +Python upload bot client secrets/ +Python2 client secrets/ +#profile/cookies #docker does not support gui dont want to setup server +#vosk-model-en-us-0.42-gigaspeech/ +vosk-model-en-us-0.42-gigaspeech.zip + +#ignore files ending in .mp4 in done_split folder +done_split/*.mp4 +done_split/uploaded/*.mp4 +done_split/temp/*.mp4 +done_split/vosk vs whisper/ +done_split/Unuploaded Before Reworks/ +to_split/*.mp4 +to_split/edited/*.mp4 +subtitles/* + +to_split/edited/* +to_split/video/* +to_split/audio/* + + + +#Python upload bot client secrets/ +#Python2 client secrets/ + +#ignore these files +client_secrets.json +main.py-oauth2.json +#main.py-oauth2.json +geckodriver.log +geckodriver + +#ignore this type of file +*.mp3 +*.mp4 + +# AI Models and Data Folders (We map these with volumes instead of baking into img) +models/ +to_split/ +done_split/ +subtitles/ +.git/ diff --git a/brainrotinator/.gitignore b/brainrotinator/.gitignore new file mode 100644 index 0000000..58ec542 --- /dev/null +++ b/brainrotinator/.gitignore @@ -0,0 +1,49 @@ +#ignore these directories and files +#done_split/ +#to_split/ +__pycache__/ +#subtitles/ +Python upload bot client secrets/ +Python2 client secrets/ +profile/cookies +TiktokAutoUploader/ +vosk-model-en-us-0.42-gigaspeech/ +vosk-model-en-us-0.42-gigaspeech.zip + +#ignore files ending in .mp4 in done_split folder +done_split/*.mp4 +done_split/uploaded/*.mp4 +done_split/temp/*.mp4 +done_split/vosk vs whisper/ +done_split/Unuploaded Before Reworks/ +to_split/*.mp4 +to_split/edited/*.mp4 +subtitles/* + +to_split/edited/* +to_split/video/* +to_split/audio/* + + + +#Python upload bot client secrets/ +#Python2 client secrets/ + +#ignore these files +client_secrets.json +main.py-oauth2.json +#main.py-oauth2.json +geckodriver.log +geckodriver + +#ignore this type of file +*.mp3 +*.mp4 + +models--TinyLlama--TinyLlama-1.1B-Chat-v1.0 +profile/ +.mypy_cache/ + +.claude/ +.claude/* +.claude \ No newline at end of file diff --git a/brainrotinator/Dockerfile b/brainrotinator/Dockerfile new file mode 100644 index 0000000..535cd0d --- /dev/null +++ b/brainrotinator/Dockerfile @@ -0,0 +1,47 @@ +# Use an official Python runtime as a base image +FROM python:3.12.2 + +# Set the working directory in the container +WORKDIR /app + +# System deps: git for pip-from-git, ffmpeg (with libass for subtitle burn-in), fonts, debugging tools. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git bash nano wget ca-certificates \ + ffmpeg fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# Verify libass is built into ffmpeg (subtitle burn-in needs it). +RUN ffmpeg -hide_banner -filters 2>/dev/null | grep -q "ass " || (echo "ffmpeg lacks ass filter" && exit 1) + +# Upgrade pip and pin setuptools < 71 (newer versions break legacy setup.py +# packages like openai-whisper that import pkg_resources at module top-level). +RUN pip install --upgrade pip "setuptools<71" wheel + +# Copy requirements first so Docker can cache the installed packages layer +COPY requirements.txt /app/ + +# Install Python deps. --no-build-isolation lets setup.py-based packages reuse +# the setuptools we just pinned, instead of pip pulling latest into an isolated env. +RUN pip install --no-cache-dir --no-build-isolation -r requirements.txt + +# Copy the rest of the application code +COPY . /app + +# Geckodriver for the (optional) selenium uploaders. +RUN wget -q https://github.com/mozilla/geckodriver/releases/download/v0.32.0/geckodriver-v0.32.0-linux64.tar.gz \ + && tar -xzf geckodriver-v0.32.0-linux64.tar.gz \ + && mv geckodriver /usr/local/bin/ \ + && rm geckodriver-v0.32.0-linux64.tar.gz \ + && geckodriver --version + +# Firefox for selenium uploaders (optional). firefox-esr ships in Debian stable +# so we don't need to add the `unstable` repo (which causes openssl conflicts). +RUN apt-get update \ + && apt-get install -y --no-install-recommends firefox-esr \ + && rm -rf /var/lib/apt/lists/* + +# Gradio default port +EXPOSE 7860 + +# Default: launch the Gradio UI. Override with `docker run ... bash` for the CLI. +CMD ["python", "app.py"] diff --git a/brainrotinator/app.py b/brainrotinator/app.py new file mode 100644 index 0000000..a14c5e1 --- /dev/null +++ b/brainrotinator/app.py @@ -0,0 +1,371 @@ +"""Gradio UI for brainrotinator. + +Tabs: + - Edit: upload a file or YouTube URL, run the splitter, watch logs stream. + - Library: list / preview / delete clips in done_split/. + - Settings: read/write config.json. + +The CLI (`main.py -e/-u`) still works; this UI is additive. +""" + +from __future__ import annotations + +import io +import json +import logging +import os +import re +import shutil +import sys +import threading +import time +from contextlib import redirect_stdout +from pathlib import Path + +import gradio as gr + +from brainrotinator.video_editor import VideoEditor +from config import Config +from downloader.downloadVid import download_vid + + +PROJECT_ROOT = Path(__file__).resolve().parent +TO_SPLIT = PROJECT_ROOT / "to_split" +DONE_SPLIT = PROJECT_ROOT / "done_split" +EDITED = TO_SPLIT / "edited" + + +class _StreamCapture(io.TextIOBase): + """Tee writes into a thread-safe buffer so a Gradio generator can drain it.""" + + def __init__(self): + self._buf: list[str] = [""] + self._lock = threading.Lock() + self._ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') + + def write(self, s: str) -> int: + with self._lock: + # Strip ANSI color codes so the Gradio textbox is readable + clean_s = self._ansi_escape.sub('', s) + + # Handle carriage returns so tqdm progress bars don't spam multiple lines + if '\r' in clean_s: + parts = clean_s.split('\r') + if parts[0]: + self._buf[-1] += parts[0] + for p in parts[1:]: + if p: + self._buf[-1] = p + else: + if '\n' in clean_s: + lines = clean_s.split('\n') + self._buf[-1] += lines[0] + self._buf.extend(lines[1:]) + else: + self._buf[-1] += clean_s + return len(s) + + def drain(self) -> str: + with self._lock: + # We join the buffer but we don't clear it. + # This allows Gradio updates to be built cleanly over carriage returns without duplicates. + return "\n".join(self._buf).strip() + +ACTIVE_EDITOR = None + +def _list_to_split() -> list[str]: + if not TO_SPLIT.exists(): + return [] + return sorted(p.name for p in TO_SPLIT.glob("*.*") if p.suffix.lower() in [".mp4", ".mov", ".mkv"]) + +def _run_edit_job( + uploaded_file: str | None, + youtube_url: str, + existing_file: str | None, + chunk_duration: int, + blur: bool, + use_whisper: bool, + filter_profanity: bool, + subtitle_font_size: int, + subtitle_margin_v: int, +): + """Generator that yields cumulative log text as the edit job runs.""" + global ACTIVE_EDITOR + + TO_SPLIT.mkdir(exist_ok=True) + DONE_SPLIT.mkdir(exist_ok=True) + EDITED.mkdir(exist_ok=True) + + cfg = Config.load() + capture = _StreamCapture() + + def get_state(final=False): + if final: + return gr.update(value="Run", interactive=True), gr.update(value="Stop", interactive=False) + return gr.update(value="Running...", interactive=False), gr.update(value="Stop", interactive=True) + + def emit_sys(line: str, final=False): + # Directly write system log changes to the stream capture buffer + capture.write(line) + st = get_state(final) + return capture.drain(), st[0], st[1] + + yield emit_sys("Preparing input...\n") + + # Resolve input. + if uploaded_file: + dest = TO_SPLIT / Path(uploaded_file).name + shutil.copy(uploaded_file, dest) + input_path = dest + yield emit_sys(f"Copied uploaded file to {dest}\n") + elif existing_file: + input_path = TO_SPLIT / existing_file + yield emit_sys(f"Using existing file: {input_path}\n") + elif youtube_url.strip(): + yield emit_sys(f"Downloading {youtube_url}...\n") + try: + with redirect_stdout(capture): + download_vid(youtube_url.strip(), str(TO_SPLIT)) + st = get_state() + yield capture.drain(), st[0], st[1] + except Exception as e: + yield emit_sys(f"Download failed: {e}\n", final=True) + return + # Pick the newest mp4 in to_split that isn't in edited/. + mp4s = sorted( + [p for p in TO_SPLIT.glob("*.mp4") if p.is_file()], + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not mp4s: + yield emit_sys("No mp4 found after download.\n", final=True) + return + input_path = mp4s[0] + else: + yield emit_sys("Provide a file or a YouTube URL.\n", final=True) + return + + yield emit_sys(f"Editing: {input_path}\n") + + editor = VideoEditor( + input_path=str(input_path), + output_folder=str(DONE_SPLIT), + chunk_duration=int(chunk_duration), + name=input_path.stem, + useWhisper=use_whisper, + filterProfanityInSubtitles=filter_profanity, + voskModelDir=cfg.voskModelDir, + tinyLlamaDir=cfg.tinyLlamaDir, + subtitleFontSize=int(subtitle_font_size), + subtitleMarginV=int(subtitle_margin_v), + ) + ACTIVE_EDITOR = editor + + # Run the splitter on a background thread so we can stream prints. + error_box: list[BaseException] = [] + + def _runner(): + try: + with redirect_stdout(capture): + if blur: + editor.split_video_into_chunks_blur() + else: + editor.split_video_into_chunks() + except BaseException as e: + error_box.append(e) + + t = threading.Thread(target=_runner, daemon=True) + t.start() + + while t.is_alive(): + time.sleep(0.4) + chunk = capture.drain() + if chunk: + st = get_state() + yield chunk, st[0], st[1] + + chunk = capture.drain() + if chunk: + st = get_state() + yield chunk, st[0], st[1] + + if error_box: + yield emit_sys(f"\nERROR: {error_box[0]}\n", final=True) + ACTIVE_EDITOR = None + return + + # Move source to edited/. + try: + shutil.move(str(input_path), str(EDITED / input_path.name)) + yield emit_sys(f"\nMoved source to {EDITED / input_path.name}\n") + except Exception as e: + yield emit_sys(f"\nCouldn't move source: {e}\n") + + yield emit_sys("\nDone.\n", final=True) + ACTIVE_EDITOR = None + + +def _list_clips() -> list[str]: + if not DONE_SPLIT.exists(): + return [] + return sorted(str(p) for p in DONE_SPLIT.glob("*.mp4")) + + +def _delete_clip(path: str) -> tuple[list[str], str]: + if path and os.path.exists(path): + os.remove(path) + return _list_clips(), f"Deleted {path}" + return _list_clips(), "Nothing to delete." + + +def _update_preview(font_size: int, margin_v: int) -> str: + """Returns HTML for a phone-framed 9:16 preview matching the ASS subtitle render.""" + # ASS renders font_size on a 1080x1920 canvas. + # 1% cqw = container_width/100; font occupies font_size/1080 of canvas width. + font_size_pct = (font_size / 1080) * 100 + + # margin_v is pixels from top on a 1920px canvas. + top_pct = (margin_v / 1920) * 100 + + # ASS outline=3 at font_size=100 → ~3% of font size; 0.04em is a reasonable approximation. + shadow = "0.04em 0.04em 0 #000, -0.04em -0.04em 0 #000, 0.04em -0.04em 0 #000, -0.04em 0.04em 0 #000, 0 0.04em 0 #000, 0 -0.04em 0 #000, 0.04em 0 0 #000, -0.04em 0 0 #000" + + return f''' + + +
+ +
+ +
+
+
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ I finally found a use case... +
+
+
+
+ ''' + +def _stop_job(): + global ACTIVE_EDITOR + if ACTIVE_EDITOR: + ACTIVE_EDITOR.abort_flag = True + from brainrotinator import ffmpeg_ops + ffmpeg_ops.cancel() + return gr.update(value="Run", interactive=True), gr.update(value="Stop", interactive=False) + +def build_ui() -> gr.Blocks: + cfg = Config.load() + + with gr.Blocks(title="Brainrotinator") as demo: + gr.Markdown("# Brainrotinator\nPodcast clip automation — FFmpeg edition.") + + with gr.Tab("Edit"): + with gr.Row(): + with gr.Column(scale=1): + gr.Markdown("### Input Video") + file_in = gr.File(label="Upload mp4", file_types=[".mp4"], type="filepath") + url_in = gr.Textbox(label="...or YouTube URL") + + with gr.Row(): + to_split_in = gr.Dropdown(label="...or existing file in to_split/", choices=[None] + _list_to_split(), value=None) + refresh_files_btn = gr.Button("↻", size="sm") + + gr.Markdown("### Edit Settings") + chunk_dur = gr.Slider(15, 120, value=cfg.chunkDuration, step=1, label="Chunk duration (s)") + blur = gr.Checkbox(value=cfg.blurTopBottomOfClip, label="Blur top/bottom (letterbox)") + whisper = gr.Checkbox(value=cfg.useWhisperForTranscription, label="Use Whisper (else Vosk)") + filt = gr.Checkbox(value=cfg.filterProfanityInSubtitles, label="Filter profanity in burned subtitles") + + gr.Markdown("### Subtitle Settings") + sub_size = gr.Slider(10, 200, value=cfg.subtitleFontSize, step=1, label="Font Size") + sub_margin = gr.Slider(0, 1920, value=cfg.subtitleMarginV, step=10, label="Margin From Top (px)") + + with gr.Row(): + run_btn = gr.Button("Run", variant="primary") + stop_btn = gr.Button("Stop", variant="stop", interactive=False) + + with gr.Column(scale=1): + gr.Markdown("### Subtitle Preview") + preview_html = gr.HTML(value=_update_preview(cfg.subtitleFontSize, cfg.subtitleMarginV)) + log_box = gr.Textbox(label="Logs", lines=20, max_lines=20, autoscroll=True) + + sub_size.change(_update_preview, inputs=[sub_size, sub_margin], outputs=preview_html) + sub_margin.change(_update_preview, inputs=[sub_size, sub_margin], outputs=preview_html) + + refresh_files_btn.click(lambda: gr.update(choices=[None] + _list_to_split(), value=None), outputs=to_split_in) + + run_click_event = run_btn.click( + _run_edit_job, + inputs=[file_in, url_in, to_split_in, chunk_dur, blur, whisper, filt, sub_size, sub_margin], + outputs=[log_box, run_btn, stop_btn], + ) + + stop_btn.click(_stop_job, outputs=[run_btn, stop_btn], cancels=[run_click_event]) + + with gr.Tab("Library"): + gr.Markdown("> **Tip:** Click any filename in the list below to download it to your computer.") + gallery = gr.Files(label="Clips in done_split/", value=_list_clips()) + refresh = gr.Button("Refresh") + with gr.Row(): + to_delete = gr.Textbox(label="Path to delete") + delete_btn = gr.Button("Delete", variant="stop") + delete_status = gr.Textbox(label="Status", interactive=False) + + refresh.click(lambda: _list_clips(), outputs=gallery) + delete_btn.click(_delete_clip, inputs=to_delete, outputs=[gallery, delete_status]) + + with gr.Tab("Settings"): + gr.Markdown("Edit `config.json`. Uploader fields are still consumed by the CLI uploader.") + settings_box = gr.Code(value=json.dumps(cfg.model_dump(), indent=4), language="json", label="config.json") + save_btn = gr.Button("Save") + save_status = gr.Textbox(label="Status", interactive=False) + + def _save(text: str) -> str: + try: + new_cfg = Config.model_validate_json(text) + except Exception as e: + return f"Invalid: {e}" + new_cfg.save() + return "Saved." + + save_btn.click(_save, inputs=settings_box, outputs=save_status) + + return demo + + +if __name__ == "__main__": + ui = build_ui() + ui.queue().launch(server_name="0.0.0.0", server_port=7860) diff --git a/brainrotinator/assets/fonts/Bangers.ttf b/brainrotinator/assets/fonts/Bangers.ttf new file mode 100644 index 0000000..cd8a773 Binary files /dev/null and b/brainrotinator/assets/fonts/Bangers.ttf differ diff --git a/brainrotinator/assets/fonts/Impact.ttf b/brainrotinator/assets/fonts/Impact.ttf new file mode 100644 index 0000000..b442871 Binary files /dev/null and b/brainrotinator/assets/fonts/Impact.ttf differ diff --git a/brainrotinator/assets/swears.txt b/brainrotinator/assets/swears.txt new file mode 100644 index 0000000..d30a2f2 --- /dev/null +++ b/brainrotinator/assets/swears.txt @@ -0,0 +1,722 @@ +absofuckinglutely +anal +arse +arse-hole +arse-holes +arsehole +arseholes +arses +ass-hat +ass-hole +ass-holes +ass-pirate +assbag +assbandit +assbanger +assbite +assclown +asscock +asscracker +asses +assface +assfuck +assfucker +assfuckers +assfucks +assgoblin +asshat +asshats +asshead +assholes +asshole +asshopper +assjacker +asskiss +asskisser +asskissers +asslick +asslicker +asslickers +asslicks +asslover +asslovers +assmonkey +assmunch +assmuncher +assnigger +asspirate +assshit +assshole +asssucker +asswad +asswipe +ass +ballsack +ballsacks +bastards +bastard +batshit +batshits +beshitted +birdshit +birdshits +bitchass +bitches +bitchin +bitching +bitchings +bitchslap +bitchslaps +bitchtits +bitchy +bitch +blow job +blow jobs +blowjob +blowjobs +bogshit +boner +boshits +brotherfucker +bugfuck +bugfucker +bugfuckers +bugfucking +bugfucks +bugshit +bugshits +bullshits +bullshitted +bullshitting +bullshittings +bullshit +bumblefuck +bumblefucks +bumfuck +bumfucks +butt plug +butt-fucker +butt-fuckers +butt-pirate +buttfuck +buttfucka +buttfucker +buttfuckers +buttfucks +carpetmuncher +carpetmunchers +catshit +catshits +chickenshit +chickenshits +clit +clitface +clitfuck +clitoris +clits +clusterfuck +cock +cock sucker +cock suckers +cock-sucker +cock-suckers +cockass +cockbite +cockblock +cockblocker +cockblockers +cockblocks +cockburger +cockface +cockfucker +cockhead +cockjockey +cockknoker +cocklicker +cocklickers +cocklover +cocklovers +cockmaster +cockmongler +cockmongruel +cockmonkey +cockmuncher +cocknose +cocknugget +cocks +cockshit +cocksmith +cocksmoker +cocksuck +cocksucked +cocksucker +cocksuckers +cocksucking +cocksuckings +cocksucks +cocktease +cockteases +coochie +coochy +cooter +cornhole +cornholes +cowshit +cowshits +cum +cumbubble +cumdumpster +cumfest +cumfests +cumguzzler +cumjockey +cumjockeys +cumming +cummings +cumquat +cumquats +cumshot +cumshots +cumslut +cumtart +cunilingus +cunillingus +cunnie +cunnilingus +cunt +cuntface +cuntfuck +cuntfucker +cuntfuckers +cuntfucks +cunthole +cuntlick +cuntlicker +cuntlickers +cuntlicking +cuntlickings +cuntlicks +cuntrag +cunts +cuntslut +cuntsucker +cuntsuckers +dafuck +dammit +damned +damnit +damn +dickbag +dickbeaters +dickbrain +dickbrains +dickface +dickforbrains +dickfuck +dickfucker +dickhead +dickheads +dickhole +dickjuice +dickless +dicklick +dicklicker +dicklickers +dicklicks +dickmilk +dickmonger +dickslap +dicksucker +dicks +dickwad +dickwads +dickweasel +dickweed +dickweeds +dickwod +dick +diddlyfuck +diddlyshit +diddlyshits +dildo +dildos +dipshit +dipshits +dipstick +dipsticks +dogfuck +dogfucked +dogfucker +dogfucks +doggiestyle +doggystyle +donkyshit +donkyshits +doochbag +doodlyfuck +doodlyfucks +douche +douche-fag +douchebag +douchewaffle +dumass +dumb ass +dumbass +dumbbitch +dumbbitches +dumbfuck +dumbfucks +dumbshit +dumshit +dyke +dykes +everfucking +facefucker +facefuckers +fag +fagbag +fagfucker +faggit +faggot +faggotcock +fags +fagtard +fannyfucker +fannyfuckers +fat-ass +fat-assed +fatass +fatassed +fatfuck +fatfucker +fatfuckers +fatfucks +fellatio +feltch +fiddlyfuck +fiddlyfucker +fiddlyfucking +fiddlyfucks +fingerfuck +fingerfucked +fingerfucker +fingerfuckers +fingerfucking +fingerfuckings +fingerfucks +fistfuck +fistfucked +fistfucker +fistfuckers +fistfucking +fistfuckings +fistfucks +flamer +footfuck +footfucker +footfuckers +footfucks +footlicker +footlickers +freakfuck +freakfucks +freakyfucker +freakyfuckers +fucck +fuccks +fuck +fucka +fuckable +fuckables +fuckadelic +fuckah +fuckahs +fuckar +fuckaree +fuckarees +fuckaroo +fuckaroos +fuckaround +fuckarounds +fuckarow +fuckarows +fuckars +fuckas +fuckass +fuckathon +fuckathons +fuckbag +fuckbags +fuckbook +fuckbooks +fuckboy +fuckbrain +fuckbuddies +fuckbuddy +fuckbutt +fuckdollies +fuckdolly +fuckdub +fuckdubs +fucked +fuckedup +fuckedups +fuckem +fucker +fuckers +fuckersucker +fuckery +fuckface +fuckfaces +fuckfest +fuckfests +fuckfinger +fuckfingers +fuckfreak +fuckfreaks +fuckfriend +fuckfriends +fuckhea +fuckhead +fuckheads +fuckher +fuckhers +fuckhole +fuckies +fuckin +fuckina +fuckinas +fucking +fuckingbitch +fuckingbitchs +fuckings +fuckink +fuckinnuts +fuckinright +fuckinrights +fuckins +fuckit +fuckits +fuckknob +fuckknobs +fuckload +fuckloads +fuckme +fuckmehard +fuckmehards +fuckmes +fuckmonkey +fuckmonkeys +fuckmovie +fuckmovies +fucknut +fucknuts +fucknutt +fucko +fuckoff +fuckoffs +fuckos +fuckpig +fuckpigs +fuckpuppet +fuckpuppets +fucks +fucksome +fuckstick +fucksticks +fucktard +fucktards +fuckton +fucktons +fuckum +fuckup +fuckups +fuckwad +fuckwads +fuckwhore +fuckwhores +fuckwit +fuckwits +fuckwitt +fucky +fuckya +fuckyas +fuckybrook +fuckybrooks +fuckyou +fuckyous +fudgepacker +fudgepackers +fuuck +fuucks +gayass +gaybob +gaydo +gayfuck +gayfuckist +gaylord +gaytard +gaywad +getthefuckout +goddamnmuthafucker +goddamn +godshit +godshits +handjob +handjobs +hardon +headfuck +headfucks +henshit +henshits +homo +homodumbshit +honkey +horseshit +horseshits +hotdamns +humping +jackass +jackoff +jackoffs +jigaboo +jizz +joyshit +joyshits +kickass +kick-ass +kitchenshit +kitchenshits +kooch +kootch +kunt +labia +lezzie +mcfagget +mcfucking +Mifuckinlady +mindfuck +mindfucked +mindfucking +mindfuckings +mindfucks +minge +monsterfucker +monsterfuckers +mothafuck +mothafucka +mothafuckah +mothafuckahs +mothafuckas +mothafuckaz +mothafuckazs +mothafucked +mothafucker +mothafuckers +mothafuckin +mothafucking +mothafuckings +mothafuckins +mothafucks +motherfuck +motherfucked +motherfucker +motherfuckers +motherfuckin +motherfucking +motherfuckings +motherfuckins +motherfucks +muff +muffdive +muffdiver +muffdivers +muffdives +muffindiver +muffindivers +munging +muthafuck +muthafucka +muthafuckah +muthafuckahs +muthafuckas +muthafucks +numbfuck +numbfucks +nunfucker +nunfuckers +nut sack +nutsack +ohfuck +oldfuck +oldfucker +oldfuckers +oldfucks +omigod +omygod +overfuckingjoyed +papeshit +papeshits +pecker +peckerhead +penis +penisfucker +penispuffer +pigshit +pigshits +pissflaps +polesmoker +pollock +poon +poonani +poonany +poontang +punanny +pussies +pussy +pussylicking +puto +queef +queer +queerbait +queerhole +ratfuck +ratfucks +ratshit +ratshits +richfuck +richfucks +rimjob +rimjobs +scrote +sex +shita +shitas +shitass +shitasses +shitbag +shitbagger +shitbox +shitboxes +shitbrains +shitbreath +shitbug +shitbugs +shitcan +shitcans +shitcunt +shitdick +shitdicks +shite +shiteater +shiteaters +shited +shites +shitface +shitfaced +shitfaces +shitfire +shitfired +shitfires +shitforbrains +shitfuck +shitfucker +shitfuckers +shitfucks +shitfull +shithead +shitheads +shithole +shithouse +shithouses +shitless +shitlicker +shitlickers +shitload +shitloads +shitmonkey +shitmonkeys +shitmouse +shitmouses +shitpot +shitpots +shits +shitspitter +shitsplat +shitsplats +shitstain +shitstains +shitted +shitter +shitters +shittier +shittiest +shittin +shitting +shittings +shitty +shit +poop +skank +skullfuck +slutbag +smeg +sonofabitch +sonofbitch +sonsofbitches +splooge +spooge +strapon +strapons +stupidfuck +stupidfucker +stupidfuckers +stupidfucks +sweetfucked +sweetfucker +sweetfuckers +porn +porno +tard +testicle +thefuck +thundercunt +tit +titfuck +titfucker +titfuckers +titfuckin +titfucks +tits +tittie +titties +titty +tittyfuck +tittys +twat +twatlips +twats +twatwaffle +unclefucker +unfuck +unfucked +va-j-j +vag +vagina +vjayjay +wakethefuckup +wank +wanker +wankers +wanking +wankings +wanks +whafuck +whorebag +whoreface +whorefucker +whorefuckers +whore +harlot +wormshit +wormshits \ No newline at end of file diff --git a/brainrotinator/assets/title.txt b/brainrotinator/assets/title.txt new file mode 100644 index 0000000..fa8a398 --- /dev/null +++ b/brainrotinator/assets/title.txt @@ -0,0 +1,12 @@ + _______ __ __ __ __ +/ \ / | / | / | / | +$$$$$$$ | ______ ______ $$/ _______ ______ ______ _$$ |_ $$/ _______ ______ _$$ |_ ______ ______ +$$ |__$$ | / \ / \ / |/ \ / \ / \ / $$ | / |/ \ / \ / $$ | / \ / \ +$$ $$< /$$$$$$ |$$$$$$ |$$ |$$$$$$$ |/$$$$$$ |/$$$$$$ |$$$$$$/ $$ |$$$$$$$ | $$$$$$ |$$$$$$/ /$$$$$$ |/$$$$$$ | +$$$$$$$ |$$ | $$/ / $$ |$$ |$$ | $$ |$$ | $$/ $$ | $$ | $$ | __ $$ |$$ | $$ | / $$ | $$ | __ $$ | $$ |$$ | $$/ +$$ |__$$ |$$ | /$$$$$$$ |$$ |$$ | $$ |$$ | $$ \__$$ | $$ |/ |$$ |$$ | $$ |/$$$$$$$ | $$ |/ |$$ \__$$ |$$ | +$$ $$/ $$ | $$ $$ |$$ |$$ | $$ |$$ | $$ $$/ $$ $$/ $$ |$$ | $$ |$$ $$ | $$ $$/ $$ $$/ $$ | +$$$$$$$/ $$/ $$$$$$$/ $$/ $$/ $$/ $$/ $$$$$$/ $$$$/ $$/ $$/ $$/ $$$$$$$/ $$$$/ $$$$$$/ $$/ + + + \ No newline at end of file diff --git a/brainrotinator/brainrotinator/__init__.py b/brainrotinator/brainrotinator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brainrotinator/brainrotinator/ffmpeg_ops.py b/brainrotinator/brainrotinator/ffmpeg_ops.py new file mode 100644 index 0000000..0bc9f66 --- /dev/null +++ b/brainrotinator/brainrotinator/ffmpeg_ops.py @@ -0,0 +1,171 @@ +"""Thin wrappers around ffmpeg / ffprobe. + +Replaces the moviepy + ImageMagick + cleanvid stack with direct FFmpeg calls. +Each helper builds a command list and runs it; callers handle orchestration. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from typing import Iterable + + +_current_proc: subprocess.Popen | None = None +_cancelled: bool = False + + +def cancel() -> None: + """Kill the ffmpeg subprocess currently running in _run(), if any.""" + global _current_proc, _cancelled + if _current_proc is not None: + _cancelled = True + _current_proc.kill() + + +def _run(cmd: list[str], quiet: bool = False) -> None: + global _current_proc, _cancelled + _cancelled = False + if not quiet: + print("[ffmpeg] " + " ".join(cmd)) + # Use PIPE so subprocess never calls fileno() on sys.stdout/stderr — + # Gradio replaces them with StringIO wrappers that don't have real fds. + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + _current_proc = proc + out, err = proc.communicate() + _current_proc = None + if out: + sys.stdout.write(out.decode(errors="replace")) + if err: + sys.stderr.write(err.decode(errors="replace")) + if proc.returncode != 0 and not _cancelled: + raise subprocess.CalledProcessError(proc.returncode, cmd) + + +def probe_duration(path: str) -> float: + out = subprocess.check_output([ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "json", path, + ]) + return float(json.loads(out)["format"]["duration"]) + + +def probe_dimensions(path: str) -> tuple[int, int]: + out = subprocess.check_output([ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "json", path, + ]) + s = json.loads(out)["streams"][0] + return int(s["width"]), int(s["height"]) + + +def extract_audio_wav(input_path: str, output_wav: str, sample_rate: int = 16000) -> None: + """Extract mono PCM wav. Vosk + Whisper both accept wav directly.""" + _run([ + "ffmpeg", "-y", "-loglevel", "error", + "-i", input_path, + "-vn", "-ac", "1", "-ar", str(sample_rate), + "-f", "wav", output_wav, + ]) + + +def cut_crop_scale_burn( + input_path: str, + output_path: str, + start: float, + end: float, + target_w: int, + target_h: int, + ass_path: str | None, + blur_letterbox: bool, + mute_ranges: Iterable[tuple[float, float]] = (), + fonts_dir: str | None = None, +) -> None: + """One-pass cut + format + (optional) blur + (optional) subtitle burn + (optional) mute. + + `start`/`end` are absolute seconds in the source. + `mute_ranges` are absolute seconds too — converted to chunk-relative below. + """ + src_w, src_h = probe_dimensions(input_path) + duration = end - start + + if blur_letterbox: + # Background: blur+scale to target. Foreground: scaled source centered. + fg_h = target_h + fg_w = int(round(src_w * (fg_h / src_h))) + if fg_w > target_w: + fg_w = target_w + fg_h = int(round(src_h * (fg_w / src_w))) + vf = ( + f"[0:v]split=2[bg][fg];" + f"[bg]scale={target_w}:{target_h}:force_original_aspect_ratio=increase," + f"crop={target_w}:{target_h},gblur=sigma=20[bgb];" + f"[fg]scale={fg_w}:{fg_h}[fgs];" + f"[bgb][fgs]overlay=(W-w)/2:(H-h)/2[v]" + ) + else: + # 9:16 center crop, then scale. + target_ar = target_w / target_h + src_ar = src_w / src_h + if src_ar > target_ar: + crop_h = src_h + crop_w = int(round(src_h * target_ar)) + else: + crop_w = src_w + crop_h = int(round(src_w / target_ar)) + vf = ( + f"[0:v]crop={crop_w}:{crop_h}:(in_w-{crop_w})/2:(in_h-{crop_h})/2," + f"scale={target_w}:{target_h}[v]" + ) + + if ass_path: + ass_for_filter = _escape_ass_path(ass_path) + ass_arg = ass_for_filter + if fonts_dir: + ass_arg += f":fontsdir={_escape_ass_path(fonts_dir)}" + vf = vf.replace("[v]", f"[vbase];[vbase]ass={ass_arg}[v]") + + # Audio mute filter — convert absolute ranges to chunk-relative. + af_parts = [] + for ms_start, ms_end in mute_ranges: + rel_start = max(0.0, ms_start - start) + rel_end = max(0.0, ms_end - start) + if rel_end <= 0 or rel_start >= duration: + continue + rel_end = min(rel_end, duration) + af_parts.append( + f"volume=enable='between(t,{rel_start:.3f},{rel_end:.3f})':volume=0" + ) + af = ",".join(af_parts) if af_parts else None + + cmd = [ + "ffmpeg", "-y", + "-ss", f"{start:.3f}", "-to", f"{end:.3f}", + "-i", input_path, + "-filter_complex", vf, + "-map", "[v]", + "-map", "0:a?", + ] + if af: + cmd += ["-af", af] + cmd += [ + "-c:v", "libx264", "-preset", "medium", "-crf", "20", + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + output_path, + ] + _run(cmd) + + +def _escape_ass_path(path: str) -> str: + """ffmpeg's filtergraph parser needs Windows backslashes and `:` (drive letter) escaped.""" + p = path.replace("\\", "/") + # Escape drive-letter colon for filter syntax: C:/foo -> C\:/foo + if len(p) >= 2 and p[1] == ":": + p = p[0] + "\\:" + p[2:] + return p diff --git a/brainrotinator/brainrotinator/profanity.py b/brainrotinator/brainrotinator/profanity.py new file mode 100644 index 0000000..1b6711c --- /dev/null +++ b/brainrotinator/brainrotinator/profanity.py @@ -0,0 +1,92 @@ +"""A Python library to check for (and clean) profanity in strings. + +Forked from: https://github.com/ben174/profanity + +All credit goes to them + +I just edited line 70 so that if we have "pass" it doesnt become "p@@@" +so now it only filters whole words +""" + +import os +import random +import re + +lines = None +words = None +_censor_chars = '@#$%!' +_censor_pool = [] + +_ROOT = os.path.abspath(os.path.dirname(__file__)) + + +def get_data(path): + return os.path.join(_ROOT, 'data', path) + + +def get_words(): + if not words: + load_words() + return words + + +def get_censor_char(): + """Plucks a letter out of the censor_pool. If the censor_pool is empty, + replenishes it. This is done to ensure all censor chars are used before + grabbing more (avoids ugly duplicates). + + """ + global _censor_pool + if not _censor_pool: + # censor pool is empty. fill it back up. + _censor_pool = list(_censor_chars) + return _censor_pool.pop(random.randrange(len(_censor_pool))) + + +def set_censor_characters(censor_chars): + """Sets the pool of censor characters. Input should be a single string + containing all the censor charcters you'd like to use. + Example: "@#$%^" + + """ + global _censor_chars + _censor_chars = censor_chars + + +def contains_profanity(input_text): + """Checks the input_text for any profanity and returns True if it does. + Otherwise, returns False. + """ + return input_text != censor(input_text) + + +def censor(input_text, filterAnyOccurance:bool = False): + """ Returns the input string with profanity replaced with a random string + of characters plucked from the censor_characters pool. + + """ + ret = input_text + words = get_words() + for word in words: + if filterAnyOccurance: + curse_word = re.compile(re.escape(word), re.IGNORECASE) + #old implementation, filters any occurance of the word even if its part of another word + curse_word = re.compile(r'\b' + re.escape(word) + r'\b', re.IGNORECASE) + cen = "".join(get_censor_char() for i in list(word)) + ret = curse_word.sub(cen, ret) + return ret + + +def load_words(wordlist=None): + """ Loads and caches the profanity word list. Input file (if provided) + should be a flat text file with one profanity entry per line. + + """ + global words + if not wordlist: + # no wordlist was provided, load the wordlist from the local store + filename = get_data('wordlist.txt') + f = open(filename) + wordlist = f.readlines() + wordlist = [w.strip() for w in wordlist if w] + words = wordlist diff --git a/brainrotinator/brainrotinator/subtitles.py b/brainrotinator/brainrotinator/subtitles.py new file mode 100644 index 0000000..90d2fd6 --- /dev/null +++ b/brainrotinator/brainrotinator/subtitles.py @@ -0,0 +1,103 @@ +"""Subtitle helpers: SRT -> styled ASS, plus profanity mute-range extraction. + +Replaces moviepy's TextClip/SubtitlesClip and the cleanvid subprocess. +""" + +from __future__ import annotations + +import os +import re +from typing import Iterable + +import pysrt +import pysubs2 + + +def srt_to_ass( + srt_path: str, + ass_path: str, + font_name: str = "Bangers", + font_size: int = 90, + primary_color: str = "&H00FFFFFF", # white (AABBGGRR) + outline_color: str = "&H00000000", # black + outline: int = 3, + shadow: int = 0, + alignment: int = 8, # top-center; 2 = bottom-center, 5 = middle-center + margin_v: int = 480, # vertical offset; tuned for 1920px tall video + play_res_x: int = 1080, + play_res_y: int = 1920, +) -> None: + """Convert SRT to ASS with a styled `Default` style. + + Designed for vertical 1080x1920 output. Caller still needs the .ttf font + available to libass — either installed system-wide or referenced via + `fontsdir=...` in the ffmpeg `ass=` filter. + """ + subs = pysubs2.load(srt_path, encoding="utf-8") + subs.info["PlayResX"] = str(play_res_x) + subs.info["PlayResY"] = str(play_res_y) + subs.info["ScaledBorderAndShadow"] = "yes" + + style = subs.styles["Default"] + style.fontname = font_name + style.fontsize = font_size + style.primarycolor = pysubs2.Color(255, 255, 255, 0) + style.outlinecolor = pysubs2.Color(0, 0, 0, 0) + style.bold = True + style.outline = outline + style.shadow = shadow + style.alignment = alignment + style.marginv = margin_v + + subs.save(ass_path, format_="ass") + + +def load_swears(swears_path: str) -> list[str]: + with open(swears_path, "r", encoding="utf-8") as f: + return [w.strip().lower() for w in f if w.strip()] + + +def mute_ranges_from_srt( + srt_path: str, + swears: Iterable[str], + pad_seconds: float = 0.05, +) -> list[tuple[float, float]]: + """Return (start, end) seconds for every subtitle line containing a swear. + + Uses word-boundary matching so 'ass' doesn't match 'class'. + Pads each range by `pad_seconds` on both sides to account for transcription drift. + """ + swear_list = [s for s in swears if s] + if not swear_list: + return [] + pattern = re.compile( + r"\b(" + "|".join(re.escape(w) for w in swear_list) + r")\b", + re.IGNORECASE, + ) + + ranges: list[tuple[float, float]] = [] + for sub in pysrt.open(srt_path, encoding="utf-8"): + if not pattern.search(sub.text): + continue + start_s = _pysrt_to_seconds(sub.start) - pad_seconds + end_s = _pysrt_to_seconds(sub.end) + pad_seconds + ranges.append((max(0.0, start_s), end_s)) + return _merge_overlapping(ranges) + + +def _pysrt_to_seconds(t) -> float: + return t.hours * 3600 + t.minutes * 60 + t.seconds + t.milliseconds / 1000.0 + + +def _merge_overlapping(ranges: list[tuple[float, float]]) -> list[tuple[float, float]]: + if not ranges: + return [] + ranges = sorted(ranges) + merged = [ranges[0]] + for s, e in ranges[1:]: + ps, pe = merged[-1] + if s <= pe: + merged[-1] = (ps, max(pe, e)) + else: + merged.append((s, e)) + return merged diff --git a/brainrotinator/brainrotinator/transcribe.py b/brainrotinator/brainrotinator/transcribe.py new file mode 100644 index 0000000..eb3fb51 --- /dev/null +++ b/brainrotinator/brainrotinator/transcribe.py @@ -0,0 +1,339 @@ +from io import BytesIO +import re +import subprocess + +import requests +from tqdm import tqdm + +import torch +from vosk import Model, KaldiRecognizer, SetLogLevel +import os + +from huggingface_hub import snapshot_download +from termcolor import colored +from . import profanity +import whisper +from whisper.utils import get_writer +from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline +import zipfile + + +class Transcribe: + #@param output_path - path to the output srt file + def __init__(self, audioPath, output_path, name, filterProfanityInSubtitles: bool, voskModelDir, tinyLlamaDir): + self.audioPath = audioPath #path for where the mp3 file is + self.output_path = output_path #subtitles save path + self.name = name #name of the video (so we can name the srt correctly) + self.filterProfanityInSubtitles = filterProfanityInSubtitles #if true, then we filter out profanity in the subtitles + self.tinyLlamaDir = tinyLlamaDir + self.voskModelDir = voskModelDir + + + + def transcribeVideoVosk(self) -> None: + #transcribe the video to srt file + print(colored("Transcribing video...", "green")) + + unFilteredSrtFileName = os.path.join(self.output_path, f"{self.name}_notClean.srt") + print(colored(f"File name: {unFilteredSrtFileName}", "yellow")) + #check if the file already exists, if it does then return + if os.path.exists(unFilteredSrtFileName): + print(colored("SRT file already exists. Skipping...", "red")) + return + + + + currentWorkingDirectory = os.getcwd() + + + #vosk model + #------------------------------------------------------------------------------------ + + + #if model does not exist, download it + #RUN apt-get install -y unzip \ + + #if voskmodel directory is empty string, not provided used working diretory + voskDir = '' + if not self.voskModelDir: + voskDir= currentWorkingDirectory + else: + voskDir = self.voskModelDir + + modelPath = os.path.join(voskDir, "vosk-model-en-us-0.42-gigaspeech") + url = "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip" + zip_filename = "vosk-model-en-us-0.42-gigaspeech.zip" + + if not os.path.exists(modelPath): + print(f"Downloading Vosk model from {url}...") + # Streamed download with a tqdm progress bar so users can see progress on slow links. + with requests.get(url, stream=True, timeout=60) as response: + response.raise_for_status() + total = int(response.headers.get("content-length", 0)) + with open(zip_filename, "wb") as f, tqdm( + total=total, unit="B", unit_scale=True, desc="Vosk model" + ) as bar: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + f.write(chunk) + bar.update(len(chunk)) + + print("Extracting...") + with zipfile.ZipFile(zip_filename, "r") as zip_ref: + zip_ref.extractall(voskDir) + os.remove(zip_filename) + else: + print(f"Model already exists at {modelPath}.") + + + SAMPLE_RATE = 16000 + + SetLogLevel(-1) + + print("Vosk model path: ", modelPath) + subtitles = '' + model = Model(model_path=modelPath) + rec = KaldiRecognizer(model, SAMPLE_RATE) + rec.SetWords(True) + + + + with subprocess.Popen(["ffmpeg", "-loglevel", "quiet", "-i", + self.audioPath, + "-ar", str(SAMPLE_RATE) , "-ac", "1", "-f", "s16le", "-"], + stdout=subprocess.PIPE).stdout as stream: + data = stream.read() + stream = BytesIO(data) #have to copy the stream into a buffer in memory + subtitles = rec.SrtResult(stream, words_per_line=1) #srt save has to be first read from stream or the timestamp will be wrong + print(subtitles) + + result : str = extract_words_from_srt(None, subtitles) + print(result) + stream.close() + + f= open(unFilteredSrtFileName, "w") + f.write(subtitles) + f.close() + print(colored(f"Subtitles saved to {unFilteredSrtFileName}", "yellow")) + + transcriptionPath = os.path.join(self.output_path, f"{self.name}_transcription.txt") + with open(transcriptionPath, "w") as f: + f.write(result) + f.close() + #------------------------------------------------------------------------------------ + + + #Filter out profanity in the subtitles, then save + #if profanity filter off do not filter + cleanSrtFileName = os.path.join(self.output_path, f"{self.name}.srt") + f = open(cleanSrtFileName, "w") + if self.filterProfanityInSubtitles: + subtitles = self.filterProfanity(subtitles) + f.write(subtitles) + f.close() + print(colored(f"Subtitles saved to {cleanSrtFileName}", "yellow")) + #------------------------------------------------------------------------------------ + + #get the Ai summary + summary :str = self.llmSummarize(result) + print(colored(f"Summary before: {summary}", "yellow")) + summary = self.filterProfanity(summary) #filter out profanity + summary = summary.replace("\"", "") #regex any " within summary + print(colored(f"Summary after all filters: {summary}", "green")) + summary_save_path = os.path.join(self.output_path, f"{self.name}_summary.txt") + f = open(summary_save_path, "w") + f.write(summary) + f.close() + print(colored(f"Summary saved to {summary_save_path}", "yellow")) + + + print(colored("Transcription complete.", "green")) + + def transcribeVideoWhisper(self) -> None: + #transcribe the video to srt file + print(colored("Transcribing video...", "green")) + + + unFilteredSrtFileName = os.path.join(self.output_path, f"{self.name}_notClean.srt") + print(colored(f"File name: {unFilteredSrtFileName}", "yellow")) + if os.path.exists(unFilteredSrtFileName): + print(colored("SRT file already exists. Skipping...", "red")) + return + + currentWorkingDirectory = os.getcwd() + + + #Whisper model + #------------------------------------------------------------------------------------ + subtitles = '' + model_name = "large-v3" + device = "cuda" if torch.cuda.is_available() else "cpu" + model = whisper.load_model(model_name).to(device) + filename =self.name + "_notClean.srt" + + language = "en" if model_name.endswith(".en") else None + subtitles : dict = model.transcribe(self.audioPath, language=language, temperature=0.0, word_timestamps=True) + + transcript = str(subtitles["text"]) + print(subtitles) + print(colored(f"Transcript: {transcript}", "yellow")) + + transcriptionPath = os.path.join(self.output_path, f"{self.name}_transcription.txt") + with open(transcriptionPath, "w") as f: + f.write(transcript) + f.close() + + writer = get_writer("srt", output_dir=self.output_path) #returns a writer object + writer(subtitles, filename, max_words_per_line=1 ) #writes the result to the file, specify keyword argument (kwargs) to set max words per line + + del model + torch.cuda.empty_cache() + #------------------------------------------------------------------------------------ + + with open(unFilteredSrtFileName, "r") as f: + subtitles = f.read() + f.close() + print(subtitles) + + #filter out profanity, then save + #we use this srt for the final video, and the unfiltered srt for detecting when to mute profanity + cleanSrtFileName = os.path.join(self.output_path, f"{self.name}.srt") + f = open(cleanSrtFileName, "w") + if self.filterProfanityInSubtitles: + subtitles = self.filterProfanity(subtitles) + f.write(subtitles) + f.close() + print(colored(f"Subtitles saved to {cleanSrtFileName}", "yellow")) + #------------------------------------------------------------------------------------ + + #Get the Ai summary + summary :str = self.llmSummarize(transcript) + print(colored(f"Summary before: {summary}", "yellow")) + #filter out profanity in the summary + summary = self.filterProfanity(summary) + summary = summary.replace("\"", "") #regex any " within summary + print(colored(f"Summary after all filters: {summary}", "green")) + summary_save_path = os.path.join(self.output_path, f"{self.name}_summary.txt") + f = open(summary_save_path, "w") + f.write(summary) + f.close() + print(colored(f"Summary saved to {summary_save_path}", "yellow")) + + + print(colored("Transcription complete.", "green")) + + def filterProfanity (self, input: str) -> str : + print(colored("Filtering profanity...", "green")) + profanity.set_censor_characters("*#@!") + + #Set swearWords to the contents of the file + swearsFileLocation = os.path.join(os.getcwd(), "assets", "swears.txt") + print(colored(f"swearsFileLocation: {swearsFileLocation}", "yellow")) + f = open(swearsFileLocation, "r") + swearWords = f.readlines() + swearWords = [w.strip() for w in swearWords if w] + f.close() + profanity.load_words(swearWords) + #print(swearWords) + + return profanity.censor(input) + + + def llmSummarize(self, input:str) -> None: + print(colored("Summarizing text using Tinyllama...", "green")) + llamaDir = '' + + if not self.tinyLlamaDir: #if tinyLlamaDir is empty string, use current working directory + llamaDir= os.getcwd() + else: + llamaDir = self.tinyLlamaDir + + localModelPath = os.path.join(llamaDir, "models--TinyLlama--TinyLlama-1.1B-Chat-v1.0") + model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" + + if not os.path.exists(localModelPath): + print(f"Downloading {model_name} -> {localModelPath} (resumable)...") + # snapshot_download is resumable and shows progress, unlike from_pretrained's silent download. + snapshot_download( + repo_id=model_name, + local_dir=localModelPath, + local_dir_use_symlinks=False, + ) + else: + print(f"Model already exists at {localModelPath}.") + + + pipe = pipeline("text-generation", model=localModelPath, torch_dtype=torch.bfloat16, device_map="auto") + input = input + "\n Summarize the above transcript into a catchy title for youtube. Do not include any additional text before or after the title. Make the title one sentence long." + + + # We use the tokenizer's chat template to format each message - see https://huggingface.co/docs/transformers/main/en/chat_templating + messages = [ + { + "role": "system", + "content": "You always respond with one catchy youtube title, no longer than one sentence. Do not include any additional text before or after the title.", + }, + {"role": "user", "content": input}, + ] + prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) + outputs = pipe(prompt, max_new_tokens=256, do_sample=True, temperature=0.7, top_k=50, top_p=0.95) + generatedText = outputs[0]["generated_text"] + print(generatedText) + #keep anything after <|assistant|> in the output + generatedText = generatedText.split("<|assistant|>")[1] + print(colored(f"{generatedText}", "yellow")) + + del pipe + torch.cuda.empty_cache() + + #filter out any whitespace before + generatedText = generatedText.strip() + #only include the /line in string + generatedText = generatedText.split("\n")[0] + + + #filter out any mention of catchy youube title that chatbot sometimes includes + generatedText = re.sub(r'(?i)Catchy YouTube title', '', generatedText) + generatedText = re.sub(r'(?i)YouTube Title', '', generatedText) + generatedText = re.sub(r'(?i)Chatbot', '', generatedText) + generatedText = re.sub(r'(?i)one sentence', '', generatedText) + + print(colored(f"Ai title after filter out catchy youtube title: \n{generatedText}", "green")) + + #filter any whitespace after + generatedText = generatedText.strip() + + print(colored(f"Ai title after filter whitespace & only include first line: \n{generatedText}", "green")) + + #truncate the text to 100 characters (youtbe limits titles to 100 chars) + generatedText = generatedText[:100] + print(colored(f" \n Ai title after trunkate to 100 chars: \n {generatedText} \n", "yellow")) + + + + return generatedText + +def extract_words_from_srt(srt_file = None, input:str = None): + + content ='' + if srt_file: + with open(srt_file, 'r', encoding='utf-8') as file: + content = file.read() + if input: + content = input + # Remove numbers (sequence numbers), timestamps, and extra lines + cleaned_text = re.sub(r'\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}', '', content) + + # Remove any remaining empty lines or numbers + cleaned_text = re.sub(r'\n\d+\n', '\n', cleaned_text) + + # Remove any leftover newlines that appear more than twice in a row + cleaned_text = re.sub(r'\n+', '\n', cleaned_text).strip() + + #merge all lines into one line + cleaned_text = cleaned_text.replace("\n", " ") + + return cleaned_text + + + \ No newline at end of file diff --git a/brainrotinator/brainrotinator/video_editor.py b/brainrotinator/brainrotinator/video_editor.py new file mode 100644 index 0000000..c17e30a --- /dev/null +++ b/brainrotinator/brainrotinator/video_editor.py @@ -0,0 +1,153 @@ +"""Video editor — FFmpeg-only. + +Replaces the old moviepy/ImageMagick/cleanvid stack. For each chunk we do: + 1. Extract wav for transcription (via ffmpeg). + 2. Run Vosk or Whisper -> {name}_notClean.srt + {name}.srt + {name}_summary.txt. + 3. Convert {name}.srt -> {name}.ass with our subtitle style. + 4. Build mute ranges from {name}_notClean.srt against swears.txt. + 5. Single ffmpeg pass: cut + crop/scale (or blur letterbox) + burn ASS + mute. +""" + +from __future__ import annotations + +import os + +from termcolor import colored + +from . import ffmpeg_ops +from . import subtitles as subs_mod +from .transcribe import Transcribe + + +TARGET_W = 1080 +TARGET_H = 1920 + + +class VideoEditor: + def __init__( + self, + input_path: str, + output_folder: str, + chunk_duration: int, + name: str, + useWhisper: bool, + filterProfanityInSubtitles: bool, + voskModelDir: str, + tinyLlamaDir: str, + subtitleFontSize: int = 100, + subtitleMarginV: int = 400, + ): + self.input_path = input_path + self.output_folder = output_folder + self.chunk_duration = chunk_duration + self.name = name + self.useWhisper = useWhisper + self.filterProfanityInSubtitles = filterProfanityInSubtitles + self.voskModelDir = voskModelDir + self.tinyLlamaDir = tinyLlamaDir + self.subtitleFontSize = subtitleFontSize + self.subtitleMarginV = subtitleMarginV + self.abort_flag = False + + def split_video_into_chunks(self): + self._split(blur_letterbox=False) + + def split_video_into_chunks_blur(self): + self._split(blur_letterbox=True) + + def _split(self, blur_letterbox: bool) -> None: + print(colored(f"\n Splitting video: {self.input_path}", "green")) + duration = int(ffmpeg_ops.probe_duration(self.input_path)) + os.makedirs(self.output_folder, exist_ok=True) + + project_root = os.getcwd() + subtitles_path = os.path.join(project_root, "subtitles") + os.makedirs(subtitles_path, exist_ok=True) + assets_dir = os.path.join(project_root, "assets") + fonts_dir = os.path.join(assets_dir, "fonts") + font_path = os.path.join(fonts_dir, "Bangers.ttf") + swears_path = os.path.join(assets_dir, "swears.txt") + swears = subs_mod.load_swears(swears_path) if os.path.exists(swears_path) else [] + + for start in range(0, duration, self.chunk_duration): + if self.abort_flag: + print(colored("\n[!] Editing aborted by user.", "red")) + break + + end = min(start + self.chunk_duration, duration) + + num = int(end / 60) + chunk_name = f"{self.name}_{num}" + output_video = os.path.join(self.output_folder, f"{chunk_name}.mp4") + + if os.path.exists(output_video): + print(colored(f"File {chunk_name} already exists. Skipping...", "yellow")) + continue + + wav_path = os.path.join(subtitles_path, f"{chunk_name}.wav") + chunk_for_transcription = os.path.join(subtitles_path, f"{chunk_name}_src.mp4") + + # 1. Cut a temp source clip + extract wav for transcription. + # (We re-encode the final clip in step 5; this temp is just for audio.) + ffmpeg_ops._run([ + "ffmpeg", "-y", "-loglevel", "error", + "-ss", str(start), "-to", str(end), + "-i", self.input_path, + "-c", "copy", + chunk_for_transcription, + ]) + ffmpeg_ops.extract_audio_wav(chunk_for_transcription, wav_path) + + # 2. Transcribe. + print(colored(f"Transcribing: {wav_path}", "yellow")) + transcribe = Transcribe( + wav_path, + subtitles_path, + name=chunk_name, + filterProfanityInSubtitles=self.filterProfanityInSubtitles, + voskModelDir=self.voskModelDir, + tinyLlamaDir=self.tinyLlamaDir, + ) + if self.useWhisper: + transcribe.transcribeVideoWhisper() + else: + transcribe.transcribeVideoVosk() + + os.remove(wav_path) + os.remove(chunk_for_transcription) + + srt_clean = os.path.join(subtitles_path, f"{chunk_name}.srt") + srt_unfiltered = os.path.join(subtitles_path, f"{chunk_name}_notClean.srt") + ass_path = os.path.join(subtitles_path, f"{chunk_name}.ass") + + # 3. Build styled ASS for burn-in. + subs_mod.srt_to_ass( + srt_clean, + ass_path, + font_name="Bangers", + font_size=self.subtitleFontSize, + margin_v=self.subtitleMarginV, + ) + + # 4. Compute mute ranges from the unfiltered SRT (absolute seconds in source = chunk-relative + start). + chunk_relative_ranges = subs_mod.mute_ranges_from_srt(srt_unfiltered, swears) + absolute_ranges = [(s + start, e + start) for s, e in chunk_relative_ranges] + if absolute_ranges: + print(colored(f"Muting {len(absolute_ranges)} profanity range(s)", "yellow")) + + # 5. Final single-pass render. + print(colored(f"Rendering: {output_video}", "green")) + ffmpeg_ops.cut_crop_scale_burn( + input_path=self.input_path, + output_path=output_video, + start=float(start), + end=float(end), + target_w=TARGET_W, + target_h=TARGET_H, + ass_path=ass_path, + blur_letterbox=blur_letterbox, + mute_ranges=absolute_ranges, + fonts_dir=fonts_dir, + ) + print(colored(f"Saved: {output_video}", "green")) + print(colored("-------------------------------------------", "green")) diff --git a/brainrotinator/config.json b/brainrotinator/config.json new file mode 100644 index 0000000..2745cd8 --- /dev/null +++ b/brainrotinator/config.json @@ -0,0 +1,30 @@ +{ + "tags": [ + "chuckle Sandwich", + "jschlatt", + "ted nivison", + "slimecicle", + "gaming", + "comedy", + "tucker", + "jambo" + ], + "description": "#shorts", + "howManyUploads": 1, + "howManyHoursBetweenSchedule": 0, + "howManyMinsBetweenUpload": 5, + "howManyHoursLongToSleep": 23, + "sleepXMinsBeforeStartingUploader": 0, + "chunkDuration": 58, + "uploadToYoutube": true, + "uploadToInstagram": true, + "uploadToTiktok": false, + "blurTopBottomOfClip": true, + "useWhisperForTranscription": false, + "filterProfanityInSubtitles": false, + "subtitleFontSize": 100, + "subtitleMarginV": 480, + "firefoxHeadless": true, + "voskModelDir": "models", + "tinyLlamaDir": "models" +} diff --git a/brainrotinator/config.py b/brainrotinator/config.py new file mode 100644 index 0000000..c7e5cef --- /dev/null +++ b/brainrotinator/config.py @@ -0,0 +1,59 @@ +"""Typed config for brainrotinator. + +Replaces the old `global` block in main.py and the dict access scattered +across the codebase. Loaded from `config.json` at the project root. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from pydantic import BaseModel, Field + + +CONFIG_PATH = Path(__file__).resolve().parent / "config.json" + + +class Config(BaseModel): + # Upload metadata + tags: list[str] = Field(default_factory=list) + description: str = "#shorts" + + # Upload scheduling + howManyUploads: int = 1 + howManyHoursBetweenSchedule: int = 0 + howManyMinsBetweenUpload: int = 5 + howManyHoursLongToSleep: int = 23 + sleepXMinsBeforeStartingUploader: int = 0 + + # Editing + chunkDuration: int = 58 + blurTopBottomOfClip: bool = True + useWhisperForTranscription: bool = False + filterProfanityInSubtitles: bool = False + + # Subtitles + subtitleFontSize: int = 100 + subtitleMarginV: int = 480 + + # Upload targets + uploadToYoutube: bool = True + uploadToInstagram: bool = True + uploadToTiktok: bool = False + + # Selenium + firefoxHeadless: bool = True + + # Model dirs (empty = use cwd) + voskModelDir: str = "models" + tinyLlamaDir: str = "models" + + @classmethod + def load(cls, path: Path | str = CONFIG_PATH) -> "Config": + with open(path) as f: + return cls.model_validate(json.load(f)) + + def save(self, path: Path | str = CONFIG_PATH) -> None: + with open(path, "w") as f: + json.dump(self.model_dump(), f, indent=4) diff --git a/brainrotinator/docker-compose.yml b/brainrotinator/docker-compose.yml new file mode 100644 index 0000000..014ea03 --- /dev/null +++ b/brainrotinator/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + brainrotinator: + build: . + image: brainrotinator:latest + ports: + - "7860:7860" + volumes: + # Video input and output + - ./to_split:/app/to_split + - ./done_split:/app/done_split + # AI Models (so they aren't re-downloaded on container restart) + - ./models:/app/models + - ./models/whisper:/root/.cache/whisper + command: python app.py diff --git a/brainrotinator/downloader/__init__.py b/brainrotinator/downloader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brainrotinator/downloader/combineAudioVideo.py b/brainrotinator/downloader/combineAudioVideo.py new file mode 100644 index 0000000..9ce1f6c --- /dev/null +++ b/brainrotinator/downloader/combineAudioVideo.py @@ -0,0 +1,30 @@ +import os +import subprocess + +""" +Function to combine the audio and video of a video using ffmpeg-python +@param videoPath: the path to the video +@param audioPath: the path to the audio +@param outputPath: the path to save the output to +@param outputName: the name of the output file +""" +def CombineAudioVideo(videoPath, audioPath, outputPath, outputName): + try: + outputFilePath = os.path.join(outputPath, outputName) + + + subprocess_command = [ + 'ffmpeg', + '-y', # Overwrite output file without asking + '-i', videoPath, # Input video + '-i', audioPath, # Input audio + '-c', 'copy', # Codec copy (no re-encoding) + outputFilePath # Output path + ] + + subprocess.run(subprocess_command, check=True) + + print(f"Combined audio and video saved to: {outputFilePath}") + + except Exception as e: + print(f"Error combining audio and video: {e}") diff --git a/brainrotinator/downloader/downloadVid.py b/brainrotinator/downloader/downloadVid.py new file mode 100644 index 0000000..af08a33 --- /dev/null +++ b/brainrotinator/downloader/downloadVid.py @@ -0,0 +1,63 @@ +import sys +import os +import subprocess +import re +from termcolor import colored + + +def _sanitize_title(title: str) -> str: + return re.sub(r'[^\w\-]', '_', title)[:100] + + +def download_vid(url: str, path: str) -> None: + os.makedirs(path, exist_ok=True) + + # Probe the video title first so we can predict the output filename. + title_result = subprocess.run( + ["yt-dlp", "--print", "title", "--no-playlist", url], + capture_output=True, text=True, check=True, + ) + raw_title = title_result.stdout.strip() + safe_title = _sanitize_title(raw_title) + output_path = os.path.join(path, safe_title + ".mp4") + + print(colored(f"Downloading: {raw_title}", "green")) + print(colored(f"Output: {output_path}", "blue")) + + proc = subprocess.Popen( + [ + "yt-dlp", + "--no-playlist", + "-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best", + "--merge-output-format", "mp4", + "-o", output_path, + url, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out, err = proc.communicate() + if out: + sys.stdout.write(out.decode(errors="replace")) + if err: + sys.stderr.write(err.decode(errors="replace")) + if proc.returncode != 0: + raise subprocess.CalledProcessError(proc.returncode, ["yt-dlp"]) + + print(colored(f"Saved to: {output_path}", "green")) + return output_path + + +def main(): + if len(sys.argv) < 3: + print("Usage: python downloadVid.py ") + sys.exit(1) + + url = sys.argv[1] + path = sys.argv[2] + print(f"URL: {url}\nPath: {path}") + download_vid(url, path) + + +if __name__ == "__main__": + main() diff --git a/brainrotinator/main.py b/brainrotinator/main.py new file mode 100644 index 0000000..3300b53 --- /dev/null +++ b/brainrotinator/main.py @@ -0,0 +1,426 @@ +from datetime import datetime, timedelta +import os +import time +import argparse +import random +import shutil +import json + +from brainrotinator.video_editor import VideoEditor +from uploaders.uploader_selenium import upload_video_youtube, upload_video_Instagram +from uploaders.upload_tiktok import upload_video_Tiktok +from downloader.downloadVid import download_vid +from config import Config + +class Color: + PURPLE = '\033[95m' + CYAN = '\033[96m' + DARKCYAN = '\033[36m' + BLUE = '\033[94m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + END = '\033[0m' + + + +""" +Function to upload the chunks to YouTube +Uploads videos from the done_split folder, uploads until reaches howManyUploads, then sleeps for 23 hours + +time between uploads = howManyMinsBetweenUpload + random wait of 0-5 mins + +@param output_directory - path to the output directory(folder with the clips of our videos) +@param howManyUploads - how many times to upload before sleeping for 24 hours +@param howManyHoursBetweenUpload - how many hours delay between each upload scheduled to be uploaded +@param howManyMinsBetweenUpload - how many minutes to wait between each upload +@param howManyHoursLongToSleep - how many hours to sleep after uploading howManyUploads times +@param tags - list of tags to add to the video +@param description_video - description of the video + +""" +def uploadVideos(doneVideosDirectory : str, + cfg: Config, + ) -> None: + howManyUploads = cfg.howManyUploads + howManyHoursBetweenSchedule = cfg.howManyHoursBetweenSchedule + howManyMinsBetweenUpload = cfg.howManyMinsBetweenUpload + howManyHoursLongToSleep = cfg.howManyHoursLongToSleep + tags = cfg.tags + description_video = cfg.description + print(Color.GREEN + "Done Videos directory: " + doneVideosDirectory + Color.END) + print(Color.BLUE + "Video Upload Order order: \n" + str(os.listdir(doneVideosDirectory)) + "\n\n" + Color.END) + + i : int = 0 + for videoFileName in os.listdir(doneVideosDirectory): + print(Color.BLUE+ "Video file: " + videoFileName + Color.END) + + if videoFileName.endswith(".mp4"): + print(Color.GREEN + f"i = {i} \n" + videoFileName + Color.END) + + #if we have uploaded howmanyUploads times and is not first upload since starting program, sleep for howManyHoursLongToSleep + if(i % howManyUploads == 0 and i != 0) : + timeToSleepInHours = (howManyHoursLongToSleep*60*60) + print(Color.GREEN + f"Sleeping for {howManyHoursLongToSleep} hrs since uploaded {howManyUploads} vids... \n calculated: {timeToSleepInHours/60/60} hrs " + Color.END) + + nextUploadTime = datetime.now() + timedelta(seconds=timeToSleepInHours) + print(Color.BLUE+ f" => next upload at {nextUploadTime}" + Color.END) + + #Sleep for howManyHoursLongToSleep hours + time.sleep(timeToSleepInHours) + i = 0 #reset the counter to 0 after sleeping for 24 hours + + #if we are not on the first video, sleep for hour since we have uploaded a video + if(i != 0) : + print(Color.GREEN + "Time now: " + str(datetime.now()) + Color.END) + + + randomSleepSeconds = random.randint(0, 300) + sleepTimeSeconds = (howManyMinsBetweenUpload * 60) + randomSleepSeconds + + now = datetime.now() + nextUploadTime = now + timedelta(seconds=sleepTimeSeconds) + + print(Color.BLUE+ f" => next upload at {nextUploadTime}" + Color.END) + print(Color.GREEN + "Sleeping for:"+str(sleepTimeSeconds /60) + "minutes" + Color.END) + time.sleep(sleepTimeSeconds) #sleep for 1 hour before uploading the next video + + + print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END) + if cfg.uploadToYoutube: + print(Color.GREEN+ "Uploading video to YouTube..." + Color.END) + + videoFilePath :str = os.path.join(doneVideosDirectory, videoFileName) + print(Color.GREEN+"Uploading video from: \n" + videoFilePath + Color.END) + + videoFileNameWithoutMP4ForSummary :str = os.path.splitext(videoFileName)[0] + print(Color.GREEN + "Base name: " + videoFileNameWithoutMP4ForSummary + Color.END) + + #if "_filtered" in videoFileNameWithoutMP4: remove the "_filtered" from the name + #ai summary file is named without the "_filtered" so we need to remove it from the name + if "_filtered" in videoFileNameWithoutMP4ForSummary: + videoFileNameWithoutMP4ForSummary = videoFileNameWithoutMP4ForSummary.replace("_filtered", "") + print(Color.GREEN + "Base name after removing '_filtered': " + videoFileNameWithoutMP4ForSummary + Color.END) + + project_root :str = os.getcwd() + subtitles_path :str = os.path.join(project_root, "subtitles") + print(Color.YELLOW + "Subtitles path: " + subtitles_path + Color.END) + + AISummaryPath: str = os.path.join(subtitles_path, f"{videoFileNameWithoutMP4ForSummary}_summary.txt") + + print(Color.YELLOW + "Summary path: " + AISummaryPath + Color.END) + + AiTitle :str = "" + + try: + with open(AISummaryPath, "r") as f: + AiTitle = f.read() + except FileNotFoundError: + print(Color.RED + "Summary file not found: " + AISummaryPath + Color.END) + AiTitle = "default" + + print(Color.YELLOW + "Ai Title: " + AiTitle + Color.END) + print(Color.BLUE+ f" => Scheduling video to be uploaded in {i * howManyHoursBetweenSchedule} hours" + Color.END) + + # Calculate the scheduled time based on the number of iterations + now = datetime.now() + random_mins = random.randint(0, 10) + howManyHoursLaterToSchedule = now + timedelta(hours=(i * howManyHoursBetweenSchedule), minutes=random_mins) #hr+rand mins to avoid getting banned + + # Format the time in ISO 8601 format + formatted_time = howManyHoursLaterToSchedule.strftime("%m/%d/%Y, %H:%M") + print("Current time:", now) + print(f"{i*howManyHoursBetweenSchedule} hours later:", howManyHoursLaterToSchedule) + print("ISO format:", formatted_time) + + #initialize the json object + upload_json = {} + + #if we are on the first video, upload immediately or if we are not scheduling the video + if (i == 0 or howManyHoursBetweenSchedule == 0): + print(Color.GREEN + "Uploading immediately..." + Color.END) + upload_json = { + "title": AiTitle, + "description": description_video, + "tags": tags, + } + + #if we are not on the first video, schedule the video to be uploaded in future + else : + print(Color.GREEN + "Scheduling video to be uploaded in the future..." + Color.END) + upload_json = { + "title": AiTitle, + "description": description_video, + "tags": tags, + "schedule": formatted_time + } + + json_file_path :str = os.path.join(doneVideosDirectory, "upload.json") + with open(json_file_path, "w") as json_file: + json.dump(upload_json, json_file) + print(Color.GREEN + "JSON file path: " + json_file_path + Color.END) + + if cfg.uploadToYoutube: + upload_video_youtube(videoFilePath, json_file_path, headless=cfg.firefoxHeadless) + print(Color.GREEN+ "Uploaded video to youtube: " + videoFileName + Color.END) + + print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END) + + copyOfTags = tags.copy() #copy the tags so we dont modify the original list + + for x in range(len(copyOfTags)): + copyOfTags[x] = "#" + copyOfTags[x].replace(" ", "") + + AiTitle = AiTitle + " " + " ".join(copyOfTags) + + print(Color.GREEN + "Title for TikTok+Instagram: " + AiTitle + Color.END) + + upload_json = { + "title": AiTitle, + "description": AiTitle, + } + with open(json_file_path, "w") as json_file: + json.dump(upload_json, json_file) + + if cfg.uploadToInstagram: + print(Color.GREEN+ "Uploading video to Instagram..." + Color.END) + upload_video_Instagram(videoFilePath, json_file_path, headless=cfg.firefoxHeadless) + print(Color.GREEN + "Uploaded to Instagram " + Color.END) + + print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END) + + + cookies : str = os.path.join(os.getcwd(), "cookies-tiktok.txt") + if cfg.uploadToTiktok: + print(Color.GREEN + "Uploading to TikTok..." + Color.END) + upload_video_Tiktok(video_path=videoFilePath, description= AiTitle, cookies = cookies) + print(Color.GREEN + "Uploaded to TikTok " + Color.END) + + #----------------------------------------------------------------------------- + + move_files_to_uploaded(videoFileName, doneVideosDirectory, videoFilePath) #move the uploaded video to the uploaded folder + + i += 1 #increment the counter (only if we just uploaded a video, if not then dont increment hour time) + + +# Move the uploaded video to the "uploaded" directory, so we don't upload it again if the script crashes +def move_files_to_uploaded(videoFileName: str, doneVideoDirectory: str, videoFilePath) -> None: + + uploaded_directory = os.path.join(doneVideoDirectory, "uploaded") + print(Color.GREEN + "Uploaded directory: " + uploaded_directory + Color.END) + + newVideoFilePath = os.path.join(uploaded_directory, videoFileName) + shutil.move(videoFilePath, newVideoFilePath) + print(Color.GREEN + f"Moved uploaded video to: {newVideoFilePath}" + Color.END) + + + + + +""" +Function that goes through the to_split folder and splits each video into chunks +If no mp4 files in the folder, prompts user to add mp4 files to the folder, or exit editing +Only splits mp4 files +@param folder_path - the path to the folder with the video +@param output_directory - the path to the output folder +@param chunk_duration - the duration of each chunk in seconds +@param edited_directory - the path to the edited folder +@param gui - boolean to check if the program is running in GUI mode +@param splitOneVideo - boolean to check if the program is splitting one video +""" +def splitVideos(folder_path : str, + output_directory : str, + cfg: Config, + edited_directory: str, + gui: bool = False, + splitOneVideo: bool = False) -> None: + chunk_duration = cfg.chunkDuration + print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END) + if(splitOneVideo): + print(Color.GREEN + "Splitting one video..." + Color.END) + else: + print(Color.GREEN + "Splitting all videos in to_Split folder..." + Color.END) + + #for each video in the folder, split the video into 60 second chunks + for video_file in os.listdir(folder_path): + print(Color.BLUE + "Video file: " + video_file + Color.END ) + + if video_file.endswith(".mp4"): + input_video_path = os.path.join(folder_path, video_file) + print("Input video path: " + input_video_path) + + base_name = os.path.splitext(video_file)[0] # Get the base name without .mp4 + print(Color.GREEN + "Base name: " + base_name + Color.END) + + #initialize the class with (video_path, output_folder, chunk_duration, vname) + video_editor = VideoEditor(input_path=input_video_path, + output_folder=output_directory, + chunk_duration=chunk_duration, + name=base_name, + useWhisper=cfg.useWhisperForTranscription, + filterProfanityInSubtitles=cfg.filterProfanityInSubtitles, + voskModelDir=cfg.voskModelDir, + tinyLlamaDir=cfg.tinyLlamaDir) + if cfg.blurTopBottomOfClip: + print (Color.RED + "Split the video into chunks using blur" + Color.END) + video_editor.split_video_into_chunks_blur() + else : + print (Color.RED + "Split the video into chunks without blur" + Color.END) + video_editor.split_video_into_chunks() + + print (Color.GREEN + "Done editing the video into chunks" + Color.END) + + + # Move the original video to the "edited" directory + new_file_path = os.path.join(edited_directory, video_file) + + print("Input video path: " + input_video_path) + print("New file path: " + new_file_path) + + while(True): + try: + shutil.move(input_video_path, new_file_path) + print(Color.GREEN + f"Moved original video to: {new_file_path}" + Color.END) + break + except Exception as e: + print(Color.RED + "Error moving file: " + str(e) + Color.END) + time.sleep(5) + #if we are only splitting one video, break out of the loop + if splitOneVideo: + return + + #if reach here then no mp4 files in the folder (since would have broken out of the loop if there was a mp4 file) + #prompt user to give url to download new youtube video + if(gui): + print("GUI mode") + #return ui.promptUserToGiveVideoPath(folder_path, output_directory, chunk_duration, edited_directory) + return + else: + print("CLI mode") + return promptUserForURLCLI(folder_path, output_directory, cfg, edited_directory) + + +def promptUserForURLCLI(folder_path : str, + output_directory : str, + cfg: Config, + edited_directory: str) -> None: + print(Color.RED + "No mp4 files in the folder. Please add mp4 files to the folder." + Color.END) + #wait for user input + waiting : bool = True + while (waiting): + answer: str = input(Color.YELLOW + "Enter a url for a youtube video to download to edit \n or type 'exit' to stop editing \n or type 'yes' if you added a file to the 'to_split' folder \n" + Color.END) + + if(answer == "exit"): + print(Color.GREEN + "exiting video editing..." + Color.END) + return + if (answer == "yes"): + waiting = False + print(Color.GREEN + "Continuing program..." + Color.END) + return splitVideos(folder_path, output_directory, cfg, edited_directory) #call the function again to split the video into chunks + #check if answer is url + else : + try: + print(Color.GREEN + "Downloading video..." + Color.END) + #print url and folder path we are dolownloading to + print(Color.BLUE + "URL: " + answer + Color.END) + print(Color.BLUE + "Save Path: " + folder_path + Color.END) + + print(Color.GREEN + "\n--------------------------------------------" + Color.END) + + #call the download_vid function to download the video + #downloads the highest quality audio and video + #saves it to the to_split folder + download_vid(answer, folder_path) + + #call the function again to split the video, checks if there are mp4 files in the folder, should be since we just downloaded a video + return splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory) + + except Exception as e: + print(Color.RED + "Error downloading video: " + str(e) + Color.END) + waiting = True + + + + + +#default to none if no arguments provided +def main(editBool = None, uploadBool = None, gui: bool = False) -> None: + + # Get the current working directory (where your Python script is located) + project_root = os.getcwd() + + #read title.txt file to print title + with open(os.path.join('assets', 'title.txt'), 'r') as file: + title = file.read().strip() + print(Color.RED + title + Color.END) + + time.sleep(2) + + # Define relative paths from the project root + folder_path = os.path.join(project_root, "to_split") + output_directory = os.path.join(project_root, "done_split") + edited_directory = os.path.join(folder_path, "edited") + + print(Color.GREEN + "Folder path: " + folder_path + Color.END) + print(Color.GREEN + "Output directory: " + output_directory + Color.END) + print(Color.GREEN + "Edited directory: " + edited_directory + Color.END) + + + # Load the config file + cfg = Config.load() + MinsBefore = cfg.sleepXMinsBeforeStartingUploader + print(Color.GREEN + "Config: " + str(cfg.model_dump()) + Color.END) + + #if a flag is provided, run the program only for that flag + if(editBool or uploadBool): + # Split one of the videos into chunks, move into edited folder + if editBool: + splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory, gui=gui) + return + + # Upload the chunks to YouTube within the done_split folder + # do this until there are no more videos to upload, then go back to editing + if uploadBool: + print(Color.BLUE + "Sleeping for " + str(MinsBefore) + " minutes" + Color.END) + time.sleep(MinsBefore * 60) + uploadVideos(output_directory, cfg) + return + + #else run the program for both flags, edit one vid -> upload -> edit another vid -> upload -> etc + i : int = 0 + while(True): + # Split one of the videos into chunks, move into edited folder + splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory, gui=gui, splitOneVideo=True) + + #Upload the chunks to YouTube within the done_split folder + #if we are on the first video, sleep for MinsBefore minutes in order to delay the first upload + if(i == 0): + print(Color.BLUE + "Sleeping for " + str(MinsBefore) + " minutes" + Color.END) + #print when next upload will be + next_upload = datetime.now() + timedelta(seconds=MinsBefore * 60) + print(Color.BLUE+ f" => next upload at {next_upload}" + Color.END) + time.sleep(MinsBefore * 60) + + uploadVideos(output_directory, cfg) + i += 1 + +# Run the main function if the name of the module is __main__ +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Process videos for YouTube.') + parser.add_argument('--edit', '-e', action='store_true', help='Split videos into chunks. \n \ + Edits all mp4 files within the to_split folder. \n \ + Without uploading the videos after editing.') + parser.add_argument('--upload', '-u', action='store_true', help='Upload video chunks to YouTube. \n \ + Uploads all mp4 files within the done_split folder.') + args = parser.parse_args() #parse the arguments, so we can use them in the program, shows up in help message for functions + + if not args.edit and not args.upload: + print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END) + print(Color.YELLOW + "\n No arguments provided. \n \ + Defualt functionality is to edit one video into chunks, then upload the chunks to YouTube, and continue edit-upload as a loop. \n \ + Please use --edit to only edit videos from to_split folder, or --upload to only upload videos from done_split folder. \n" + Color.END) + + + main(editBool=args.edit, uploadBool=args.upload) #call the main function with the arguments provided diff --git a/brainrotinator/readme.md b/brainrotinator/readme.md new file mode 100644 index 0000000..82bf581 --- /dev/null +++ b/brainrotinator/readme.md @@ -0,0 +1,427 @@ + + + + + + + +[![LinkedIn][linkedin-shield]][linkedin-url] + + + + +
+
+ Logo +
+
+

Brainrotinator

+ +### Podcast Clip Automation Using AI + + - Edits long form content into clips with subtitles using FFmpeg (libass for burn-in) + - Web UI built with Gradio for editing — CLI still works for headless / cron use + - Transcribes audio using Vosk or Whisper Models (your choice) + - Mutes audio where profanity is detected using FFmpeg's `volume` filter driven by SRT timestamps + - Uses TinyLlamma LLM to generate titles based on transcription for YouTube and Instagram. + - Automatically uploads to YouTube, Instagram, and Tiktok based on schedule given in config file using Selenium Firefox. + - Downloads videos from youtube using given URL using Pytube + - Thank you Timofei for the inspiration and name of the project. + +
+ + + + +
+ Table of Contents +
    +
  1. About The Project
  2. +
  3. Architecture
  4. +
  5. Getting Started + +
  6. +
  7. Running the Program + +
  8. +
  9. Things to note
  10. +
  11. Vosk or Whisper
  12. +
  13. License
  14. +
  15. Contact
  16. +
  17. Acknowledgments
  18. +
+
+ + + + +## About The Project + +### Video Created and Uploaded Using Brainrotinator + + +https://github.com/user-attachments/assets/36b5d927-ecde-4099-b9f5-687d2b0108e5 + + +[Watch the demo on youtube](https://www.youtube.com/shorts/p__GGpKI9-w) + +[Original Video](https://www.youtube.com/watch?v=Ue_jnmeBO_I) + +I initially made this as a joke. +I have been editing youtube videos myself for around 10 years now. I wanted to see if I could automate the horrible podcast clips I see on youtube shorts using python. + +It was really fun working with AI models to make some cool features for this project + + +## New Gradio Layout: +image + + + +## Architecture + +The editor is **FFmpeg-only** as of the v2 rewrite. moviepy, ImageMagick, and cleanvid have all been removed. Setup is dramatically simpler — `pip install -r requirements.txt` and a working `ffmpeg` binary is enough to edit videos. + +### Application Flow + +```mermaid +flowchart TD + subgraph Input + A1[Upload MP4] + A2[YouTube URL\nyt-dlp download] + A3[Existing file\nin to_split/] + end + + subgraph UI["Entry Points"] + B1[app.py\nGradio Web UI] + B2[main.py\nCLI] + end + + subgraph Editor["brainrotinator/ — VideoEditor"] + C1[Split into chunks\nvideo_editor.py] + C2{Blur mode?} + C3[Blur letterbox\nffmpeg_ops.py] + C4[Center crop 9:16\nffmpeg_ops.py] + C5[Transcribe audio\ntranscribe.py] + C6{Whisper\nor Vosk?} + C7[Whisper model] + C8[Vosk model] + C9[Generate SRT\nsubtitles.py] + C10[Detect profanity\nprofanity.py / swears.txt] + C11[Burn subtitles\nlibass / ffmpeg_ops.py] + C12[Mute profanity\nFFmpeg volume filter] + C13[Generate title\nTinyLlama LLM] + end + + subgraph Output["done_split/"] + D1[Final MP4 clips\nwith subtitles] + end + + subgraph Uploaders + E1[YouTube\nyoutube_uploader_selenium] + E2[Instagram\nInstagram_Uploader] + E3[TikTok\nupload_tiktok.py] + end + + A1 & A2 & A3 --> B1 + A1 & A2 & A3 --> B2 + B1 & B2 --> C1 + C1 --> C2 + C2 -->|Yes| C3 + C2 -->|No| C4 + C3 & C4 --> C5 + C5 --> C6 + C6 -->|Whisper| C7 + C6 -->|Vosk| C8 + C7 & C8 --> C9 + C9 --> C10 + C10 --> C11 + C10 --> C12 + C11 & C12 --> C13 + C13 --> D1 + D1 --> E1 & E2 & E3 +``` + +### Repo Layout + +```text +brainrotinator/ # Editor package — pure FFmpeg + ffmpeg_ops.py # Cut, crop, blur, burn-in, mute wrappers + subtitles.py # SRT → styled ASS, profanity → mute-range list + transcribe.py # Vosk / Whisper / TinyLlama (lazy, resumable) + video_editor.py # Per-chunk orchestration + profanity.py # Text-profanity censor +uploaders/ # Selenium-based uploaders + login.py, uploader_selenium.py, upload_tiktok.py + Instagram_Uploader/, youtube_uploader_selenium/ +downloader/ # yt-dlp downloader module + downloadVid.py, combineAudioVideo.py +assets/ # Static files + fonts/, swears.txt, title.txt +to_split/, done_split/, subtitles/ # Runtime media directories +models/ # Persistent AI models storage (Vosk/TinyLllama/Whisper) +app.py # Gradio Web UI entrypoint +main.py # CLI entrypoint +config.py / config.json # Pydantic configuration model +Dockerfile / docker-compose.yml # Containerization setup +``` + +

(back to top)

+ + +## Getting Started + +Editing requires only Python, FFmpeg (with libass), and ~8 GB of disk for models on first run. The selenium uploaders additionally need Firefox + geckodriver and per-platform cookies. + +### Install with Docker + +The best way to run Brainrotinator with Docker is using **Docker Compose**. This automatically handles mounting the `to_split`, `done_split`, and `models` directories so your files and AI models are saved locally on your machine, working seamlessly across Windows, Mac, and Linux without complex path variables. + +1. Ensure your `config.json` points the AI models to the synced `models` folder: + ```json + "voskModelDir": "models", + "tinyLlamaDir": "models" + ``` + +2. Build and start the container in the background. (Use `--build` the first time you run this, or after pulling in new code updates): + ```sh + docker compose up -d --build + ``` + *Note: On subsequent runs, you can just use `docker compose up -d` to start the container instantly without docker checking for build updates.* + +Then open http://localhost:7860. + +To view logs or access the container shell: +* **Logs:** `docker compose logs -f` +* **Shell:** `docker compose exec brainrotinator bash` + +If you'll use the uploader, run `python login.py` **on your host** first (it needs a GUI) so cookies are present in the mounted volume before the container starts. + +#### Save output locally without Gradio + +You can also run the CLI editor directly via Docker Compose (bypassing the web UI). Finished clips land in `done_split/` on your host machine, so no browser download is needed: + +```sh +docker compose run --rm brainrotinator python main.py -e +``` + +Drop your source mp4s into `to_split/` before running. + +

(back to top)

+ + +### Install without Docker + + +**Prerequisites** + +* Python 3.10+ +* FFmpeg with libass (`ffmpeg -filters | grep " ass "` should list it; most distro packages and the official Windows builds include it) +* ~10 GB VRAM if you'll use Whisper; CPU is fine for Vosk +* ~4 GB for the TinyLlama model, ~4 GB for the Vosk model (downloaded automatically on first use) +* Firefox + geckodriver — only if you'll use the uploader + +**Steps** + +1. Clone the repo and `cd` in. +2. Install Python deps: + ```sh + pip install -r requirements.txt + ``` +3. Make sure `ffmpeg` is on your `PATH`. No `IMAGEMAGICK_BINARY` / `FFMPEG_BINARY` env vars are needed anymore. +4. *(Uploader only)* install geckodriver v0.32.0 and put it on your `PATH`, then run `python login.py` once on a machine with a GUI to capture cookies. +5. Launch: + * Gradio UI: `python app.py` → http://localhost:7860 + * Or CLI: `python main.py` (see [CLI](#cli) below) + + + +## Running the Program + +### Gradio UI + +```sh +python app.py +``` + +Tabs: + +* **Edit** — upload an mp4 or paste a YouTube URL, set chunk length / blur / Vosk-vs-Whisper / profanity filter, watch logs stream as the splitter runs. +* **Library** — list everything in `done_split/`. **Click a filename to download it** to your computer. Delete clips you don't want. +* **Settings** — edit `config.json` in-browser, validated against the pydantic schema before save. + +### CLI + +```sh +python main.py # default loop: edit one video → upload from done_split → repeat +python main.py -e # edit only (consume to_split/, write to done_split/) +python main.py -u # upload only (consume done_split/ on the schedule in config.json) +``` + +When the editor runs out of videos in `to_split/`, the CLI prompts for a YouTube URL and downloads it via `yt-dlp`. + +### Config + +`config.json` is now validated by `config.Config` (`config.py`). Defaults are filled in for any missing keys. + +```json +{ + "tags": ["chuckle Sandwich", "jschlatt", "ted nivison", "slimecicle", "gaming", "comedy"], + "description": "#shorts", + + "howManyUploads": 1, + "howManyHoursBetweenSchedule": 0, + "howManyMinsBetweenUpload": 5, + "howManyHoursLongToSleep": 23, + "sleepXMinsBeforeStartingUploader": 0, + + "chunkDuration": 58, + "blurTopBottomOfClip": true, + "useWhisperForTranscription": false, + "filterProfanityInSubtitles": false, + + "uploadToYoutube": true, + "uploadToInstagram": true, + "uploadToTiktok": false, + "firefoxHeadless": true, + + "voskModelDir": "", + "tinyLlamaDir": "" +} +``` + +| Key | Meaning | +|---|---| +| `tags` | YouTube tags; also used as #hashtags appended to the IG/TikTok caption | +| `description` | YouTube description; prepended before tags for IG/TikTok | +| `howManyUploads` | Uploads per cycle before sleeping `howManyHoursLongToSleep` | +| `howManyHoursBetweenSchedule` | Hours between each scheduled upload (YouTube/TikTok only — IG ignores) | +| `howManyMinsBetweenUpload` | Base delay between uploads, plus a 0–5 min jitter | +| `chunkDuration` | Clip length in seconds | +| `blurTopBottomOfClip` | `true` = blurred letterbox; `false` = center-crop to 9:16 | +| `useWhisperForTranscription` | `true` = Whisper, `false` = Vosk | +| `filterProfanityInSubtitles` | Censor swears in burned-in subtitles (audio is muted regardless) | +| `firefoxHeadless` | Must be `true` inside Docker (no display) | +| `voskModelDir`, `tinyLlamaDir` | Where to cache models. Empty = current working directory | + +

(back to top)

+ +## Things to note + +* **Editor vs uploader**: the editor is FFmpeg-only and should keep working indefinitely. The selenium uploaders depend on YouTube/IG/TikTok DOM layout and **will break** when those sites change. I am not maintaining them. +* **`swears.txt`** is the source of truth for what gets muted. Add or remove words to taste. Matching is word-bounded so `ass` won't match `class`. +* **TikTok uploads** require cookies from https://github.com/wkaisertexas/tiktok-uploader — and you will hit captchas. A paid solver like sadcaptcha can fix it; this repo doesn't include one. +* **Headless selenium**: cookies must already exist or the upload will crash. Run `login.py` on a machine with a GUI first. +* **Models** download lazily on first transcription. Vosk shows a `tqdm` progress bar; TinyLlama uses `huggingface_hub.snapshot_download` (resumable). +* **libass fonts**: the burn-in filter is invoked with `fontsdir=fonts/`, so any `.ttf` you drop in `fonts/` is available. Default is `Bangers.ttf`. +## Vosk or whisper +### Whisper + +**Pros:** +- Really accurate +- Better profanity filter due to accuracy + +**Cons:** +- Subtitles linger/timing is bad +- Late/early profanity mute due to timing + +### Vosk + +**Pros:** +- Timing is really good +- Timing of muting profanity very good + +**Cons:** +- Not very accurate, so words might not get filtered +- A lot of the words are not accurate + +### Notes +Maybe adapt whisper to use https://github.com/m-bain/whisperX for better timing + +* I used vosk for the example video in readme +* Vosk vs whisper comparison in the demo_and_images folder + +

(back to top)

+ + +## License + +Do not Sell this program. Do not use it for your own cloud service you are selling like [this](https://www.opus.pro/). +Other than that do what you like with it. + + + + + +## Acknowledgments + +Thank you to the following projects for making this possible. + +* [Vosk](https://alphacephei.com/vosk/) +* [Whisper](https://github.com/openai/whisper) +* [TinyLlama](https://huggingface.co/TinyLlama) +* [Text Profanity Filter](https://github.com/ben174/profanity) +* [CleanVid (mute profanity in audio)](https://github.com/mmguero/cleanvid) +* [FFmpeg](https://ffmpeg.org/) + [libass](https://github.com/libass/libass) +* [pysubs2](https://github.com/tkarabela/pysubs2) (SRT → ASS conversion) +* [Gradio](https://www.gradio.app/) +* [Youtube selenium uploader (also what I used to make the instagram uploader)](https://github.com/linouk23/youtube_uploader_selenium) +* [yt-dlp for downloading youtube videos](https://github.com/ytdl-org/youtube-dl) +* [TiktokUploader](https://github.com/wkaisertexas/tiktok-uploader) +* [readme Template](https://github.com/othneildrew/Best-README-Template/blob/main/README.md) + + +### TODO +- Add support for different models (current ones are outdated) +- Change preview of subtitles, maybe run subtitles through llm to get emojis or custom colors per line +- Color param for text +- CV to focus crop around face/person talking + +

(back to top)

+ + + + + +[contributors-shield]: https://img.shields.io/github/contributors/othneildrew/Best-README-Template.svg?style=for-the-badge +[contributors-url]: https://github.com/othneildrew/Best-README-Template/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/othneildrew/Best-README-Template.svg?style=for-the-badge +[forks-url]: https://github.com/othneildrew/Best-README-Template/network/members +[stars-shield]: https://img.shields.io/github/stars/othneildrew/Best-README-Template.svg?style=for-the-badge +[stars-url]: https://github.com/othneildrew/Best-README-Template/stargazers +[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge +[issues-url]: https://github.com/othneildrew/Best-README-Template/issues +[license-shield]: https://img.shields.io/github/license/othneildrew/Best-README-Template.svg?style=for-the-badge +[license-url]: https://github.com/othneildrew/Best-README-Template/blob/master/LICENSE.txt +[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555 +[linkedin-url]: https://www.linkedin.com/in/luke-sorvik/ +[product-screenshot]: images/screenshot.png +[Next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white +[Next-url]: https://nextjs.org/ +[React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB +[React-url]: https://reactjs.org/ +[Vue.js]: https://img.shields.io/badge/Vue.js-35495E?style=for-the-badge&logo=vuedotjs&logoColor=4FC08D +[Vue-url]: https://vuejs.org/ +[Angular.io]: https://img.shields.io/badge/Angular-DD0031?style=for-the-badge&logo=angular&logoColor=white +[Angular-url]: https://angular.io/ +[Svelte.dev]: https://img.shields.io/badge/Svelte-4A4A55?style=for-the-badge&logo=svelte&logoColor=FF3E00 +[Svelte-url]: https://svelte.dev/ +[Laravel.com]: https://img.shields.io/badge/Laravel-FF2D20?style=for-the-badge&logo=laravel&logoColor=white +[Laravel-url]: https://laravel.com +[Bootstrap.com]: https://img.shields.io/badge/Bootstrap-563D7C?style=for-the-badge&logo=bootstrap&logoColor=white +[Bootstrap-url]: https://getbootstrap.com +[JQuery.com]: https://img.shields.io/badge/jQuery-0769AD?style=for-the-badge&logo=jquery&logoColor=white +[JQuery-url]: https://jquery.com diff --git a/brainrotinator/requirements.txt b/brainrotinator/requirements.txt new file mode 100644 index 0000000..ba509ef --- /dev/null +++ b/brainrotinator/requirements.txt @@ -0,0 +1,89 @@ +accelerate==0.34.2 +attrs==24.2.0 +beautifulsoup4==4.12.3 +certifi==2024.8.30 +cffi==1.17.1 +charset-normalizer==3.3.2 +click==8.1.7 +filelock==3.16.1 +fsspec==2024.9.0 +geckodriver-autoinstaller==0.1.0 +gradio>=4.44.0 +pydantic>=2.0 +huggingface-hub>=0.28.1 +idna==3.10 +Jinja2==3.1.4 +jsoncodable==0.1.7 +jsonpickle==3.3.0 +k-selenium-cookies==0.0.4 +llvmlite==0.43.0 +MarkupSafe==2.1.5 +more-itertools==10.5.0 +mpmath==1.3.0 +networkx==3.3 +noraise==0.0.16 +numba==0.60.0 +numpy==2.0.2 +nvidia-cublas-cu12==12.1.3.1 +nvidia-cuda-cupti-cu12==12.1.105 +nvidia-cuda-nvrtc-cu12==12.1.105 +nvidia-cuda-runtime-cu12==12.1.105 +nvidia-cudnn-cu12==8.9.2.26 +nvidia-cufft-cu12==11.0.2.54 +nvidia-curand-cu12==10.3.2.106 +nvidia-cusolver-cu12==11.4.5.107 +nvidia-cusparse-cu12==12.1.0.106 +nvidia-nccl-cu12==2.20.5 +nvidia-nvjitlink-cu12==12.6.68 +nvidia-nvtx-cu12==12.1.105 +openai-whisper==20240930 +outcome==1.3.0.post0 +packaging==24.1 +pillow==10.4.0 +platformdirs==4.3.6 +psutil==6.0.0 +pycparser==2.22 +pyperclip==1.9.0 +PySocks==1.7.1 +pysrt==1.1.2 +pysubs2==1.7.3 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +yt-dlp>=2024.1.0 +pytz==2024.2 +PyYAML==6.0.2 +regex==2024.9.11 +requests==2.32.3 +safetensors==0.4.5 +selenium>=4.15.0 +selenium-browser==0.0.15 +selenium-firefox==2.0.8 +setuptools==69.1.1 +six==1.16.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.6 +srt==3.5.3 +sympy==1.13.3 +termcolor==2.4.0 +tiktok-uploader==1.0.16 +tiktoken==0.7.0 +tldextract==5.1.2 +tokenizers==0.19.1 +toml==0.10.2 +tomli==2.0.1 +torch==2.3.1 +tqdm==4.66.5 +transformers==4.44.2 +trio==0.26.2 +trio-websocket==0.11.1 +triton==2.3.1 +typing_extensions==4.12.2 +urllib3>=2.0 +vosk==0.3.45 +webdriver-manager==4.0.2 +websocket-client==1.8.0 +websockets==13.0.1 +wheel==0.43.0 +wsproto==1.2.0 +xpath-utils==0.0.3 diff --git a/brainrotinator/requirementsCookies.txt b/brainrotinator/requirementsCookies.txt new file mode 100644 index 0000000..d61820d --- /dev/null +++ b/brainrotinator/requirementsCookies.txt @@ -0,0 +1,3 @@ +selenium==4.9.0 +selenium-browser==0.0.15 +selenium-firefox==2.0.8 \ No newline at end of file diff --git a/brainrotinator/to_split/.gitkeep b/brainrotinator/to_split/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/brainrotinator/uploaders/Instagram_Uploader/Constant.py b/brainrotinator/uploaders/Instagram_Uploader/Constant.py new file mode 100644 index 0000000..11cb700 --- /dev/null +++ b/brainrotinator/uploaders/Instagram_Uploader/Constant.py @@ -0,0 +1,54 @@ +class Constant: + """A class for storing constants for YoutubeUploader class""" + YOUTUBE_URL = 'https://www.youtube.com' + YOUTUBE_STUDIO_URL = 'https://studio.youtube.com' + YOUTUBE_UPLOAD_URL = 'https://www.youtube.com/upload' + USER_WAITING_TIME = 1 + VIDEO_TITLE = 'title' + VIDEO_DESCRIPTION = 'description' + VIDEO_EDIT = 'edit' + VIDEO_TAGS = 'tags' + TEXTBOX_ID = 'textbox' + TEXT_INPUT = 'text-input' + RADIO_LABEL = 'radioLabel' + UPLOADING_STATUS_CONTAINER = '/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[2]/div/div[1]/ytcp-video-upload-progress[@uploading=""]' + NOT_MADE_FOR_KIDS_LABEL = 'VIDEO_MADE_FOR_KIDS_NOT_MFK' + + + + Click_Add = '//*[@id="mount_0_0_7N"]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/a/div/div[1]/div/div/svg' + + UPLOAD_DIALOG = '//ytcp-uploads-dialog' + ADVANCED_BUTTON_ID = 'toggle-button' + TAGS_CONTAINER_ID = 'tags-container' + + TAGS_INPUT = 'text-input' + NEXT_BUTTON = 'next-button' + PUBLIC_BUTTON = 'PUBLIC' + VIDEO_URL_CONTAINER = "//span[@class='video-url-fadeable style-scope ytcp-video-info']" + VIDEO_URL_ELEMENT = "//a[@class='style-scope ytcp-video-info']" + HREF = 'href' + ERROR_CONTAINER = '//*[@id="error-message"]' + VIDEO_NOT_FOUND_ERROR = 'Could not find video_id' + DONE_BUTTON = 'done-button' + INPUT_FILE_VIDEO = "//input[@type='file']" + INPUT_FILE_THUMBNAIL = "//input[@id='file-loader']" + + # Playlist + VIDEO_PLAYLIST = 'playlist_title' + PL_DROPDOWN_CLASS = 'ytcp-video-metadata-playlists' + PL_SEARCH_INPUT_ID = 'search-input' + PL_ITEMS_CONTAINER_ID = 'items' + PL_ITEM_CONTAINER = '//span[text()="{}"]' + PL_NEW_BUTTON_CLASS = 'new-playlist-button' + PL_CREATE_PLAYLIST_CONTAINER_ID = 'create-playlist-form' + PL_CREATE_BUTTON_CLASS = 'create-playlist-button' + PL_DONE_BUTTON_CLASS = 'done-button' + + #Schedule + VIDEO_SCHEDULE = 'schedule' + SCHEDULE_CONTAINER_ID = 'second-container-expand-button' + SCHEDULE_DATE_ID = 'datepicker-trigger' + SCHEDULE_DATE_TEXTBOX = '/html/body/ytcp-date-picker/tp-yt-paper-dialog/div/form/tp-yt-paper-input/tp-yt-paper-input-container/div[2]/div/iron-input/input' + SCHEDULE_TIME = '//*[@id="input-1"]/input' + \ No newline at end of file diff --git a/brainrotinator/uploaders/Instagram_Uploader/instagramUploader.py b/brainrotinator/uploaders/Instagram_Uploader/instagramUploader.py new file mode 100644 index 0000000..2b78b5e --- /dev/null +++ b/brainrotinator/uploaders/Instagram_Uploader/instagramUploader.py @@ -0,0 +1,221 @@ +"""This module implements uploading videos on YouTube via Selenium using metadata JSON file + to extract its title, description etc.""" + +from typing import DefaultDict, Optional, Tuple +from selenium_firefox.firefox import Firefox +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from collections import defaultdict +from datetime import datetime +import json +import time +from .Constant import * +from pathlib import Path +import logging +import platform + +logging.basicConfig() + +import random + + + +#used to generate a random wait time to perform the actions on the browser +def random_time(): + rand : int = random.randint(1, 3) + print("Random time: ", rand) + return rand + +def load_metadata(metadata_json_path: Optional[str] = None) -> DefaultDict[str, str]: + if metadata_json_path is None: + return defaultdict(str) + with open(metadata_json_path, encoding='utf-8') as metadata_json_file: + return defaultdict(str, json.load(metadata_json_file)) + + +class InstagramUploader: + """A class for uploading videos on Instagram via Selenium using metadata JSON file + to extract its title, description etc""" + + def __init__(self, video_path: str, metadata_json_path: Optional[str] = None, + thumbnail_path: Optional[str] = None, + profile_path: Optional[str] = str(Path.cwd()) + "/profile", + headless : bool = True) -> None: + self.video_path = video_path + self.thumbnail_path = thumbnail_path + self.metadata_dict = load_metadata(metadata_json_path) + self.browser = Firefox(profile_path=profile_path, pickle_cookies=True, full_screen=False, headless=headless) + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.DEBUG) + self.__validate_inputs() + print("headless for instagram =" +str(headless)) + + + self.is_mac = False + if not any(os_name in platform.platform() for os_name in ["Windows", "Linux"]): + self.is_mac = True + + self.logger.debug("Use profile path: {}".format(self.browser.source_profile_path)) + + def __validate_inputs(self): + if not self.metadata_dict[Constant.VIDEO_TITLE]: + self.logger.warning( + "The video title was not found in a metadata file") + self.metadata_dict[Constant.VIDEO_TITLE] = Path( + self.video_path).stem + self.logger.warning("The video title was set to {}".format( + Path(self.video_path).stem)) + if not self.metadata_dict[Constant.VIDEO_DESCRIPTION]: + self.logger.warning( + "The video description was not found in a metadata file") + + def upload(self): + try: + self.login() + return self.__upload() + except Exception as e: + print(e) + self.__quit() + raise + + def login(self): + self.browser.get("https://www.instagram.com/") + time.sleep(1) + + if self.browser.has_cookies_for_current_website(): + self.browser.load_cookies() + self.logger.debug("Loaded cookies from {}".format(self.browser.cookies_folder_path)) + time.sleep(1) + self.browser.refresh() + else: + self.logger.info('Please sign in and then press enter') + input() + self.browser.get("https://www.instagram.com/") + time.sleep(3) + self.browser.save_cookies() + self.logger.debug("Saved cookies to {}".format(self.browser.cookies_folder_path)) + + def __clear_field(self, field): + field.click() + time.sleep(3) + if self.is_mac: + field.send_keys(Keys.COMMAND + 'a') + else: + field.send_keys(Keys.CONTROL + 'a') + time.sleep(2) + field.send_keys(Keys.BACKSPACE) + + def __write_in_field(self, field, string, select_all=False): + if select_all: + self.__clear_field(field) + else: + field.click() + time.sleep(random_time()) + + field.send_keys(string) + + #find element, if not found, wait 3 seconds and try again + #by - the type of element to find + #value - the value of the element to find + #name - the name of the element to find + #return - the element found + def find_element(self, by, value: str, name: str): + element = None + startTime = time.time() + while element is None: + element = self.browser.find(by, value) + time.sleep(3) + print(f"{name} not found") + #if 1 minute has passed, break the loop + if time.time() - startTime > 60: + break + + print(f"{name} found") + return element + + def __upload(self) -> bool: + self.browser.get("https://www.instagram.com/?next=%2F") + uploading_status_container = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'button._a9--:nth-child(2)', "uploading status container") + uploading_status_container.click() + print("clicked no updates") + time.sleep(2) + upload_button = None + upload_button = InstagramUploader.find_element(self,By.XPATH, '/html/body/div[1]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/a', "upload button") + upload_button.click() + print("clicked create") + time.sleep(3) + self.browser.find(By.XPATH, '/html/body/div[1]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/div/div/div[1]/a[1]').click() + print("clicked post") + time.sleep(3) + + absolute_video_path = str(Path.cwd() / self.video_path) + videoUpload = InstagramUploader.find_element(self,By.CSS_SELECTOR,'._ac69', "video upload") + videoUpload.send_keys(absolute_video_path) + print("gave video path: ", absolute_video_path) + + + time.sleep(3) + next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '._acap', "next") + next.click() + print("clicked next") + time.sleep(3) + size = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.xnz67gz > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(1) > button:nth-child(1)', "size") + size.click() + print("clicked size") + time.sleep(3) + phone_resolution = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.x1i10hfl:nth-child(5)', "phone resolution") + phone_resolution.click() + print("clicked phone resolution") + + clickOff = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.xnz67gz > div:nth-child(1) > div:nth-child(3)', "click off") + clickOff.click() + time.sleep(3) + next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.x1f6kntn', "next") + next.click() + print("clicked next") + time.sleep(3) + next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.xyamay9 > div:nth-child(1)', "next") + next.click() + print("clicked next") + time.sleep(3) + + description_field = self.browser.find(By.XPATH, '/html/body/div[6]/div[1]/div/div[3]/div/div/div/div/div/div/div/div[2]/div[2]/div/div/div/div[1]/div[2]/div/div[1]/div[1]') + + video_description :str = self.metadata_dict[Constant.VIDEO_DESCRIPTION] + video_description = video_description.replace("\n", Keys.ENTER) + if video_description: + description_field = self.browser.find(By.CSS_SELECTOR, '.x1hnll1o') + print("Video Description: ", video_description) + description_field.click() + time.sleep(2) + [description_field.send_keys(c) for c in video_description] #send_keys(video_description) + print("Video description added") + time.sleep(2) + share = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.x1f6kntn', "share") + share.click() + print("clicked share") + + #uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER) + uploading_status_container_done = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.x5yr21d:nth-child(1) > div:nth-child(1) > div:nth-child(2)', "uploading status container done") + + self.__quit() + return True + + + + + def __get_video_id(self) -> Optional[str]: + video_id = None + try: + video_url_container = self.browser.find( + By.XPATH, Constant.VIDEO_URL_CONTAINER) + video_url_element = self.browser.find(By.XPATH, Constant.VIDEO_URL_ELEMENT, element=video_url_container) + video_id = video_url_element.get_attribute( + Constant.HREF).split('/')[-1] + except: + self.logger.warning(Constant.VIDEO_NOT_FOUND_ERROR) + pass + return video_id + + def __quit(self): + self.browser.driver.quit() diff --git a/brainrotinator/uploaders/__init__.py b/brainrotinator/uploaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brainrotinator/uploaders/login.py b/brainrotinator/uploaders/login.py new file mode 100644 index 0000000..d77b656 --- /dev/null +++ b/brainrotinator/uploaders/login.py @@ -0,0 +1,23 @@ +import os +from .youtube_uploader_selenium import YouTubeUploader +from .Instagram_Uploader.instagramUploader import InstagramUploader +from termcolor import colored + +def login_youtube(metadata:str) -> None: + print(colored("Logging in to Youtube", "blue")) + uploader = YouTubeUploader("dummy", metadata, headless= False) + uploader.login() + +def login_instagram(metadata:str) -> None: + print(colored("Logging in to Instagram", "magenta")) + uploader = InstagramUploader("dummy",metadata, headless= False) + uploader.login() + +if __name__ == "__main__": + print(colored("Logging in to Youtube and Instagram to save cookies for future use", "green")) + print(colored("Firefox browser required", "yellow")) + outputDirectory :str = os.path.join(os.getcwd(), "done_split") + metadata_path :str = os.path.join(outputDirectory, "upload.json") + login_youtube(metadata_path) + login_instagram(metadata_path) + print(colored("Cookies saved successfully. Now you can upload videos without logging in again", "cyan")) diff --git a/brainrotinator/uploaders/upload_tiktok.py b/brainrotinator/uploaders/upload_tiktok.py new file mode 100644 index 0000000..295d09b --- /dev/null +++ b/brainrotinator/uploaders/upload_tiktok.py @@ -0,0 +1,16 @@ + +from tiktok_uploader.upload import upload_video, upload_videos +from tiktok_uploader.auth import AuthBackend + + +#https://github.com/wkaisertexas/tiktok-uploader?tab=readme-ov-file +def upload_video_Tiktok(video_path :str, description:str, cookies:str) -> None: + + username = 'your username here' + password = 'password here' + + upload_video(filename=video_path, description=description, cookies= cookies, username=username, password=password) + + + + diff --git a/brainrotinator/uploaders/uploader_selenium.py b/brainrotinator/uploaders/uploader_selenium.py new file mode 100644 index 0000000..53758ea --- /dev/null +++ b/brainrotinator/uploaders/uploader_selenium.py @@ -0,0 +1,31 @@ +from .youtube_uploader_selenium import YouTubeUploader +#from https://github.com/linouk23/youtube_uploader_selenium +from .Instagram_Uploader.instagramUploader import InstagramUploader + +def upload_video_youtube(video_path: str, metadata_path: str, headless: bool, i : int = 0) -> None: + uploader = YouTubeUploader(video_path, metadata_path, headless=headless) + try: + uploader.upload() + except Exception as e: + print(f"Error uploading video: {e}") + print("Retrying upload...") + if i < 5: + print(f"Retry number {i}") + i += 1 + upload_video_youtube(video_path, metadata_path, headless, i) # retry the upload recursively + + +def upload_video_Instagram(video_path: str, metadata_path: str, headless: bool, i: int = 0) -> None: + uploader = InstagramUploader(video_path, metadata_path, headless=headless) + try: + uploader.upload() + except Exception as e: + print(f"Error uploading video: {e}") + print("Retrying upload...") + + if(i < 5): + print(f"Retry number {i}") + i += 1 + upload_video_Instagram(video_path, metadata_path,headless, i) # retry the upload recursively + + diff --git a/brainrotinator/uploaders/youtube_uploader_selenium/Constant.py b/brainrotinator/uploaders/youtube_uploader_selenium/Constant.py new file mode 100644 index 0000000..328207c --- /dev/null +++ b/brainrotinator/uploaders/youtube_uploader_selenium/Constant.py @@ -0,0 +1,50 @@ +class Constant: + """A class for storing constants for YoutubeUploader class""" + YOUTUBE_URL = 'https://www.youtube.com' + YOUTUBE_STUDIO_URL = 'https://studio.youtube.com' + YOUTUBE_UPLOAD_URL = 'https://www.youtube.com/upload' + USER_WAITING_TIME = 1 + VIDEO_TITLE = 'title' + VIDEO_DESCRIPTION = 'description' + VIDEO_EDIT = 'edit' + VIDEO_TAGS = 'tags' + TEXTBOX_ID = 'textbox' + TEXT_INPUT = 'text-input' + RADIO_LABEL = 'radioLabel' + UPLOADING_STATUS_CONTAINER = '/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[2]/div/div[1]/ytcp-video-upload-progress[@uploading=""]' + NOT_MADE_FOR_KIDS_LABEL = 'VIDEO_MADE_FOR_KIDS_NOT_MFK' + + UPLOAD_DIALOG = '//ytcp-uploads-dialog' + ADVANCED_BUTTON_ID = 'toggle-button' + TAGS_CONTAINER_ID = 'tags-container' + + TAGS_INPUT = 'text-input' + NEXT_BUTTON = 'next-button' + PUBLIC_BUTTON = 'PUBLIC' + VIDEO_URL_CONTAINER = "//span[@class='video-url-fadeable style-scope ytcp-video-info']" + VIDEO_URL_ELEMENT = "//a[@class='style-scope ytcp-video-info']" + HREF = 'href' + ERROR_CONTAINER = '//*[@id="error-message"]' + VIDEO_NOT_FOUND_ERROR = 'Could not find video_id' + DONE_BUTTON = 'done-button' + INPUT_FILE_VIDEO = "//input[@type='file']" + INPUT_FILE_THUMBNAIL = "//input[@id='file-loader']" + + # Playlist + VIDEO_PLAYLIST = 'playlist_title' + PL_DROPDOWN_CLASS = 'ytcp-video-metadata-playlists' + PL_SEARCH_INPUT_ID = 'search-input' + PL_ITEMS_CONTAINER_ID = 'items' + PL_ITEM_CONTAINER = '//span[text()="{}"]' + PL_NEW_BUTTON_CLASS = 'new-playlist-button' + PL_CREATE_PLAYLIST_CONTAINER_ID = 'create-playlist-form' + PL_CREATE_BUTTON_CLASS = 'create-playlist-button' + PL_DONE_BUTTON_CLASS = 'done-button' + + #Schedule + VIDEO_SCHEDULE = 'schedule' + SCHEDULE_CONTAINER_ID = 'second-container-expand-button' + SCHEDULE_DATE_ID = 'datepicker-trigger' + SCHEDULE_DATE_TEXTBOX = '/html/body/ytcp-date-picker/tp-yt-paper-dialog/div/form/tp-yt-paper-input/tp-yt-paper-input-container/div[2]/div/iron-input/input' + SCHEDULE_TIME = '//*[@id="input-1"]/input' + \ No newline at end of file diff --git a/brainrotinator/uploaders/youtube_uploader_selenium/__init__.py b/brainrotinator/uploaders/youtube_uploader_selenium/__init__.py new file mode 100644 index 0000000..30ee273 --- /dev/null +++ b/brainrotinator/uploaders/youtube_uploader_selenium/__init__.py @@ -0,0 +1,296 @@ +"""This module implements uploading videos on YouTube via Selenium using metadata JSON file + to extract its title, description etc.""" + +from typing import DefaultDict, Optional, Tuple +from selenium_firefox.firefox import Firefox +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from collections import defaultdict +from datetime import datetime +import json +import time +from .Constant import * +from pathlib import Path +import logging +import platform + +logging.basicConfig() + + + +import random + +#used to generate a random wait time to perform the actions on the browser +def random_time(): + rand : int = random.randint(1, 3) + print("Random time: ", rand) + return rand + +def load_metadata(metadata_json_path: Optional[str] = None) -> DefaultDict[str, str]: + if metadata_json_path is None: + return defaultdict(str) + with open(metadata_json_path, encoding='utf-8') as metadata_json_file: + return defaultdict(str, json.load(metadata_json_file)) + + +class YouTubeUploader: + """A class for uploading videos on YouTube via Selenium using metadata JSON file + to extract its title, description etc""" + + def __init__(self, video_path: str, metadata_json_path: Optional[str] = None, + thumbnail_path: Optional[str] = None, + profile_path: Optional[str] = str(Path.cwd()) + "/profile", + headless : bool = True) -> None: + self.video_path = video_path + self.thumbnail_path = thumbnail_path + self.metadata_dict = load_metadata(metadata_json_path) + self.browser = Firefox(profile_path=profile_path, pickle_cookies=True, full_screen=False, headless=headless) + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.DEBUG) + self.__validate_inputs() + print("headless for youtube =" + str(headless)) + + self.is_mac = False + if not any(os_name in platform.platform() for os_name in ["Windows", "Linux"]): + self.is_mac = True + + self.logger.debug("Use profile path: {}".format(self.browser.source_profile_path)) + + def __validate_inputs(self): + if not self.metadata_dict[Constant.VIDEO_TITLE]: + self.logger.warning( + "The video title was not found in a metadata file") + self.metadata_dict[Constant.VIDEO_TITLE] = Path( + self.video_path).stem + self.logger.warning("The video title was set to {}".format( + Path(self.video_path).stem)) + if not self.metadata_dict[Constant.VIDEO_DESCRIPTION]: + self.logger.warning( + "The video description was not found in a metadata file") + + def upload(self): + try: + self.login() + return self.__upload() + except Exception as e: + print(e) + self.__quit() + raise + + def login(self): + self.browser.get(Constant.YOUTUBE_URL) + time.sleep(random_time()) + + if self.browser.has_cookies_for_current_website(): + self.browser.load_cookies() + self.logger.debug("Loaded cookies from {}".format(self.browser.cookies_folder_path)) + time.sleep(random_time()) + self.browser.refresh() + else: + self.logger.info('Please sign in and then press enter') + input() + self.browser.get(Constant.YOUTUBE_URL) + time.sleep(random_time()) + self.browser.save_cookies() + self.logger.debug("Saved cookies to {}".format(self.browser.cookies_folder_path)) + + def __clear_field(self, field): + field.click() + time.sleep(random_time()) + if self.is_mac: + field.send_keys(Keys.COMMAND + 'a') + else: + field.send_keys(Keys.CONTROL + 'a') + time.sleep(random_time()) + field.send_keys(Keys.BACKSPACE) + + def __write_in_field(self, field, string, select_all=False): + if select_all: + self.__clear_field(field) + else: + field.click() + time.sleep(random_time()) + + field.send_keys(string) + + def __upload(self) -> Tuple[bool, Optional[str]]: + self.browser.get(Constant.YOUTUBE_URL) + time.sleep(random_time()) + self.browser.get(Constant.YOUTUBE_UPLOAD_URL) + time.sleep(random_time()) + absolute_video_path = str(Path.cwd() / self.video_path) + self.browser.find(By.XPATH, Constant.INPUT_FILE_VIDEO).send_keys( + absolute_video_path) + self.logger.debug('Attached video {}'.format(self.video_path)) + + # Find status container + uploading_status_container = None + while uploading_status_container is None: + time.sleep(0.5) #bug where slept too long, missed finding the element got soft locked + uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER) + + if self.thumbnail_path is not None: + absolute_thumbnail_path = str(Path.cwd() / self.thumbnail_path) + self.browser.find(By.XPATH, Constant.INPUT_FILE_THUMBNAIL).send_keys( + absolute_thumbnail_path) + change_display = "document.getElementById('file-loader').style = 'display: block! important'" + self.browser.driver.execute_script(change_display) + self.logger.debug( + 'Attached thumbnail {}'.format(self.thumbnail_path)) + + title_field, description_field = self.browser.find_all(By.ID, Constant.TEXTBOX_ID, timeout=15) + + self.__write_in_field( + title_field, self.metadata_dict[Constant.VIDEO_TITLE], select_all=True) + self.logger.debug('The video title was set to \"{}\"'.format( + self.metadata_dict[Constant.VIDEO_TITLE])) + + video_description = self.metadata_dict[Constant.VIDEO_DESCRIPTION] + video_description = video_description.replace("\n", Keys.ENTER) + if video_description: + self.__write_in_field(description_field, video_description, select_all=True) + self.logger.debug('Description filled.') + + kids_section = self.browser.find(By.NAME, Constant.NOT_MADE_FOR_KIDS_LABEL) + kids_section.location_once_scrolled_into_view + time.sleep(random_time()) + + self.browser.find(By.ID, Constant.RADIO_LABEL, kids_section).click() + self.logger.debug('Selected \"{}\"'.format(Constant.NOT_MADE_FOR_KIDS_LABEL)) + + # Playlist + playlist = self.metadata_dict[Constant.VIDEO_PLAYLIST] + if playlist: + self.browser.find(By.CLASS_NAME, Constant.PL_DROPDOWN_CLASS).click() + time.sleep(random_time()) + search_field = self.browser.find(By.ID, Constant.PL_SEARCH_INPUT_ID) + self.__write_in_field(search_field, playlist) + time.sleep(random_time() * 2) + playlist_items_container = self.browser.find(By.ID, Constant.PL_ITEMS_CONTAINER_ID) + # Try to find playlist + self.logger.debug('Playlist xpath: "{}".'.format(Constant.PL_ITEM_CONTAINER.format(playlist))) + playlist_item = self.browser.find(By.XPATH, Constant.PL_ITEM_CONTAINER.format(playlist), playlist_items_container) + if playlist_item: + self.logger.debug('Playlist found.') + playlist_item.click() + time.sleep(random_time()) + else: + self.logger.debug('Playlist not found. Creating') + self.__clear_field(search_field) + time.sleep(random_time()) + + new_playlist_button = self.browser.find(By.CLASS_NAME, Constant.PL_NEW_BUTTON_CLASS) + new_playlist_button.click() + + create_playlist_container = self.browser.find(By.ID, Constant.PL_CREATE_PLAYLIST_CONTAINER_ID) + playlist_title_textbox = self.browser.find(By.XPATH, "//textarea", create_playlist_container) + self.__write_in_field(playlist_title_textbox, playlist) + + time.sleep(random_time()) + create_playlist_button = self.browser.find(By.CLASS_NAME, Constant.PL_CREATE_BUTTON_CLASS) + create_playlist_button.click() + time.sleep(random_time()) + + done_button = self.browser.find(By.CLASS_NAME, Constant.PL_DONE_BUTTON_CLASS) + done_button.click() + + # Advanced options + self.browser.find(By.ID, Constant.ADVANCED_BUTTON_ID).click() + self.logger.debug('Clicked MORE OPTIONS') + time.sleep(random_time()) + + #click not ai (added myself by inspecting element and right clicking copy by xpath) + not_ai = self.browser.find(By.XPATH, "/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[1]/ytcp-ve/ytcp-video-metadata-editor/div/ytcp-video-metadata-editor-advanced/div[2]/ytkp-altered-content-select/div[2]/tp-yt-paper-radio-group/tp-yt-paper-radio-button[2]/div[1]/div[1]") + not_ai.click() + time.sleep(random_time()) + + + # Tags + tags = self.metadata_dict[Constant.VIDEO_TAGS] + if tags: + tags_container = self.browser.find(By.ID, Constant.TAGS_CONTAINER_ID) + tags_field = self.browser.find(By.ID, Constant.TAGS_INPUT, tags_container) + self.__write_in_field(tags_field, ','.join(tags)) + self.logger.debug('The tags were set to \"{}\"'.format(tags)) + + + self.browser.find(By.ID, Constant.NEXT_BUTTON).click() + self.logger.debug('Clicked {} one'.format(Constant.NEXT_BUTTON)) + + self.browser.find(By.ID, Constant.NEXT_BUTTON).click() + self.logger.debug('Clicked {} two'.format(Constant.NEXT_BUTTON)) + + self.browser.find(By.ID, Constant.NEXT_BUTTON).click() + self.logger.debug('Clicked {} three'.format(Constant.NEXT_BUTTON)) + + schedule = self.metadata_dict[Constant.VIDEO_SCHEDULE] + #Schedule + if schedule: + upload_time_object = datetime.strptime(schedule, "%m/%d/%Y, %H:%M") + self.browser.find(By.ID, Constant.SCHEDULE_CONTAINER_ID).click() #click the schedule dropdown + time.sleep(1) #make sure there is time for the date picker to load + schedule2 = self.browser.find(By.ID, Constant.SCHEDULE_DATE_ID) #find the date picker + time.sleep(1) #wait for schedule2 to load + schedule2.click() #click to open calendar + time.sleep(1) #wait for the date picker to load + self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).clear() #clear the date text box + self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).send_keys( + datetime.strftime(upload_time_object, "%b %e, %Y")) + self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).send_keys(Keys.ENTER) + time.sleep(1) #wait for the date picker to load + self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).click() + self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).clear() + time.sleep(1) #wait for the date picker to load + self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).send_keys( + datetime.strftime(upload_time_object, "%H:%M")) + self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).send_keys(Keys.ENTER) + self.logger.debug(f"Scheduled the video for {schedule}") + else: + public_main_button = self.browser.find(By.NAME, Constant.PUBLIC_BUTTON) + self.browser.find(By.ID, Constant.RADIO_LABEL, public_main_button).click() + self.logger.debug('Made the video {}'.format(Constant.PUBLIC_BUTTON)) + + video_id = self.__get_video_id() + + # Check status container and upload progress + uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER) + while uploading_status_container is not None: + uploading_progress = uploading_status_container.get_attribute('value') + self.logger.debug('Upload video progress: {}%'.format(uploading_progress)) + time.sleep(random_time() * 5) + uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER) + + self.logger.debug('Upload container gone.') + + done_button = self.browser.find(By.ID, Constant.DONE_BUTTON) + + # Catch such error as + # "File is a duplicate of a video you have already uploaded" + if done_button.get_attribute('aria-disabled') == 'true': + error_message = self.browser.find(By.XPATH, Constant.ERROR_CONTAINER).text + self.logger.error(error_message) + return False, None + + done_button.click() + self.logger.debug( + "Published the video with video_id = {}".format(video_id)) + time.sleep(random_time()) + self.browser.get(Constant.YOUTUBE_URL) + self.__quit() + return True, video_id + + def __get_video_id(self) -> Optional[str]: + video_id = None + try: + video_url_container = self.browser.find( + By.XPATH, Constant.VIDEO_URL_CONTAINER) + video_url_element = self.browser.find(By.XPATH, Constant.VIDEO_URL_ELEMENT, element=video_url_container) + video_id = video_url_element.get_attribute( + Constant.HREF).split('/')[-1] + except: + self.logger.warning(Constant.VIDEO_NOT_FOUND_ERROR) + pass + return video_id + + def __quit(self): + self.browser.driver.quit() diff --git a/openshorts b/openshorts deleted file mode 160000 index fe87af6..0000000 --- a/openshorts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fe87af6dd599b854e6eab2de0ca247ebafe13885 diff --git a/openshorts/.dockerignore b/openshorts/.dockerignore new file mode 100644 index 0000000..5fd8d63 --- /dev/null +++ b/openshorts/.dockerignore @@ -0,0 +1,55 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +.venv +venv/ +ENV/ +.eggs/ +*.egg-info/ +.pytest_cache/ + +# Node +node_modules/ +remotion/node_modules/ +render-service/node_modules/ +dashboard/node_modules/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Video files (will be mounted via volume) +*.mp4 +*.avi +*.mov +*.mkv +*.gif +videos/ + +# Model files (downloaded at build time) +*.pt + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Docs +README.md +*.md + + + + + + + diff --git a/openshorts/.env.example b/openshorts/.env.example new file mode 100644 index 0000000..7a2e21f --- /dev/null +++ b/openshorts/.env.example @@ -0,0 +1,9 @@ +# AWS S3 (optional — for clip backup/gallery) +AWS_ACCESS_KEY_ID=your_aws_access_key_here +AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here +AWS_REGION=eu-west-3 +AWS_S3_BUCKET=your-bucket-name +AWS_S3_PUBLIC_BUCKET=your-public-bucket-name + +# YouTube cookies (optional — paste Netscape-format cookies to bypass bot detection) +# YOUTUBE_COOKIES=... diff --git a/openshorts/.gitignore b/openshorts/.gitignore new file mode 100644 index 0000000..f2df51c --- /dev/null +++ b/openshorts/.gitignore @@ -0,0 +1,42 @@ +# Video files +*.mp4 +*.webm +*.mov +*.mkv + +# YOLO model +*.pt + +# Python virtual environment +.venv/ +__pycache__/ +*.pyc + +# Temporary files / runtime dirs +temp_* +uploads/ +downloads/ +videos/ +output/ + +# OS / IDE +.DS_Store +.idea/ +.vscode/ + +# Secrets +.env + +# Generated metadata +*_metadata.json + +# Cache dirs +.cache/ +.config/ +# Multi-agent Skills +.agents/ +.agent/ +.claude/ +skills/ +skills-lock.json + diff --git a/openshorts/CLAUDE.md b/openshorts/CLAUDE.md new file mode 100644 index 0000000..093db8a --- /dev/null +++ b/openshorts/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenShorts is an AI-powered vertical video generator that transforms long YouTube videos or local uploads into viral-ready short clips (9:16 format) for TikTok, Instagram Reels, and YouTube Shorts. Uses Google Gemini 2.0 Flash for viral moment detection and title generation. + +## Development Commands + +### Local Development (Docker) +```bash +docker compose up --build # Build and run full stack +``` +- Backend: http://localhost:8000 (FastAPI/Uvicorn) +- Frontend: http://localhost:5175 (Vite proxies API calls to backend) + +### Frontend Only (Dashboard) +```bash +cd dashboard +npm install +npm run dev # Dev server with HMR (port 5173) +npm run build # Production build +npm run lint # ESLint (strict, --max-warnings 0) +``` + +### Backend Only +```bash +pip install -r requirements.txt +uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +## Architecture + +### Core Processing Pipeline +1. **Ingest** - YouTube download (yt-dlp) or local upload +2. **Transcription** - faster-whisper with word-level timestamps +3. **Scene Detection** - PySceneDetect for segment boundaries +4. **AI Analysis** - Gemini identifies 3-15 viral moments (15-60 sec each) +5. **FFmpeg Extraction** - Precise clip cutting +6. **AI Cropping** - Vertical reframing with subject tracking +7. **Effects/Subtitles** - Optional AI-generated FFmpeg filters +8. **Hook Overlay** - Text overlays with styled fonts +9. **Voice Dubbing** - Optional ElevenLabs AI translation (30+ languages) +10. **S3 Backup** - Silent background upload +11. **Social Distribution** - Upload-Post API (async upload) + +### Key Files +| File | Purpose | +|------|---------| +| `main.py` | Core video processing: transcription, scene detection, clip extraction, vertical reframing | +| `app.py` | FastAPI server with async job queue and REST endpoints | +| `editor.py` | Gemini AI integration for dynamic video effects (FFmpeg filter generation) | +| `hooks.py` | Hook text overlay generation with font rendering | +| `s3_uploader.py` | AWS S3 upload with caching | +| `subtitles.py` | SRT generation, FFmpeg subtitle burning, and dubbed video transcription | +| `translate.py` | ElevenLabs dubbing API for AI voice translation | +| `dashboard/src/App.jsx` | Main React component with state management | +| `dashboard/src/components/TranslateModal.jsx` | Voice dubbing UI with language selection | + +### Dual-Mode Video Reframing +- **TRACK Mode** (single subject): MediaPipe face detection + YOLOv8 fallback with "Heavy Tripod" stabilization +- **GENERAL Mode** (groups/landscapes): Blurred background layout preserving full width + +### Key Classes +- `SmoothedCameraman` - Stabilized camera movement with safe zone logic (prevents jitter) +- `SpeakerTracker` - Prevents rapid speaker switching, handles temporary occlusions + +### API Endpoints +| Method | Route | Purpose | +|--------|-------|---------| +| POST | `/api/process` | Submit video for processing | +| GET | `/api/status/{job_id}` | Poll job status and logs | +| POST | `/api/edit` | Apply AI video effects | +| POST | `/api/subtitle` | Generate and apply subtitles (auto-transcribes dubbed videos) | +| POST | `/api/hook` | Add text hook overlays | +| POST | `/api/translate` | AI voice dubbing via ElevenLabs | +| GET | `/api/translate/languages` | List supported dubbing languages | +| POST | `/api/social/post` | Post to social media (async upload) | + +### Concurrency Model +Async job queue with semaphore-based concurrency control. Configure via `MAX_CONCURRENT_JOBS` env var (default: 5). Jobs auto-cleanup after 1 hour. + +## Environment Variables + +**Server-side (.env):** +- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_S3_BUCKET` - For S3 backup +- `MAX_CONCURRENT_JOBS` - Concurrent processing limit (default: 5) +- `VITE_API_URL` - Production API URL override + +**Client-side (localStorage, encrypted):** +- `GEMINI_API_KEY` - Google Gemini API key (required) +- `ELEVENLABS_API_KEY` - ElevenLabs API key for voice dubbing (optional) +- `UPLOAD_POST_API_KEY` - Upload-Post API key for social posting (optional) + +> API keys are stored encrypted in the browser and sent via headers only when needed. Never stored server-side. + +## Tech Stack +- **Backend:** Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx +- **Frontend:** React 18, Vite 4, Tailwind CSS 3.4 +- **External APIs:** Google Gemini, ElevenLabs Dubbing, Upload-Post +- **Infrastructure:** Docker + Docker Compose, AWS S3 diff --git a/openshorts/Dockerfile b/openshorts/Dockerfile new file mode 100644 index 0000000..95bfd1f --- /dev/null +++ b/openshorts/Dockerfile @@ -0,0 +1,64 @@ +# Multi-stage build for smaller final image +FROM python:3.11-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy and install Python dependencies +# Copy and install Python dependencies +COPY requirements.txt . +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +# Final stage +FROM python:3.11-slim + +WORKDIR /app + +# Install FFmpeg, OpenCV dependencies, and Node.js (for yt-dlp JS challenges) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Copy virtual env from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 + +# Always upgrade yt-dlp to latest (YouTube bot-detection changes frequently) +RUN pip install --upgrade --no-cache-dir yt-dlp + +# Copy application code +COPY . . + +# Create a non-root user (Moved up) +RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser + +# Create directories including Ultralytics cache config +RUN mkdir -p /app/uploads /app/output /tmp/Ultralytics +# Fix permissions: /app for code/uploads, /tmp/Ultralytics for AI cache +RUN chown -R appuser:appuser /app /tmp/Ultralytics + +# Switch to non-root user +USER appuser + +# Pre-download YOLO model on build (now running as appuser) +RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')" + +# Expose FastAPI port +EXPOSE 8000 + +# Run FastAPI app +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/openshorts/LICENSE b/openshorts/LICENSE new file mode 100644 index 0000000..a5ad963 --- /dev/null +++ b/openshorts/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 OpenShorts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openshorts/README.md b/openshorts/README.md new file mode 100644 index 0000000..6b14e2a --- /dev/null +++ b/openshorts/README.md @@ -0,0 +1,297 @@ +# OpenShorts.app + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Open Source](https://badges.frapsoft.com/os/v1/open-source.svg?v=103)](https://opensource.org/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com) +[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://docs.docker.com/compose/) +[![GitHub stars](https://img.shields.io/github/stars/mutonby/openshorts?style=social)](https://github.com/mutonby/openshorts) +[![Last Commit](https://img.shields.io/github/last-commit/mutonby/openshorts)](https://github.com/mutonby/openshorts/commits/main) + +**Free & open source AI video platform** with 3 tools in one: **Clip Generator**, **AI Shorts (UGC videos with AI actors)**, and **YouTube Studio**. Self-hosted with Docker. No watermarks, no limits. + +https://github.com/user-attachments/assets/b45fa983-16b4-48b5-ac5b-a267836b9ad9 + + + +### Video Tutorial: How it works +[![OpenShorts Tutorial](https://img.youtube.com/vi/xlyjD1qCaX0/maxresdefault.jpg)](https://www.youtube.com/watch?v=xlyjD1qCaX0 "Click to watch the video on YouTube") + +*Click the image above to watch the full walkthrough.* + +--- + +## 3 Tools in 1 Platform + +### 1. Clip Generator +Turn your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready 9:16 shorts for TikTok, Instagram Reels, and YouTube Shorts. + +![Clip Results](screenshots/clip-results.png) + +### 2. AI Shorts (UGC Video Creator) +Generate marketing videos with AI actors for **any product or business**. No camera, no studio, no influencer budget. Just describe your product or paste a URL. + +![AI Shorts Setup](screenshots/ai-shorts.png) + +- **Two cost modes**: Low Cost (~$0.65/video) and Premium (~$2/video) +- Works for any business: SaaS, restaurants, e-commerce, coaching, local businesses +- AI-generated actors with lip-sync, voiceover, b-roll, and TikTok-style subtitles +- Choose from a shared avatar gallery or upload your own photo +- Publish directly to TikTok, Instagram, and YouTube + +### 3. YouTube Studio +Complete free AI YouTube toolkit: thumbnails, titles, descriptions, and direct publishing. + +![YouTube Studio](screenshots/youtube-studio.png) + +- AI thumbnail generator with face overlay +- 10 viral title suggestions with refinement chat +- Auto-generated descriptions with chapter timestamps +- One-click publish to YouTube + +### UGC Video Gallery +All generated videos and avatars are saved to a public gallery with SEO pages for each video. + +![UGC Gallery](screenshots/ugc-gallery.png) + +- Public gallery page with hover-to-play (`/gallery`) +- Individual SEO video pages with og:video meta tags (`/video/{id}`) +- JSON-LD structured data for search engines +- Avatar gallery with prompt history + +--- + +## Key Features + +### Clip Generator +- **Viral Moment Detection**: Google Gemini 3.0 Flash analyzes transcripts and scene boundaries to detect 3-15 high-potential moments +- **Smart 9:16 Cropping**: Dual-mode AI reframing — TRACK mode (MediaPipe + YOLOv8 face tracking) and GENERAL mode (blurred background) +- **Auto Subtitles**: faster-whisper with word-level timestamps, styled and burned into clips +- **AI Voice Dubbing**: ElevenLabs integration for 30+ languages with voice cloning +- **Hook Text Overlays**: AI-generated attention-grabbing text overlays +- **AI Video Effects**: Gemini-generated FFmpeg filters for professional effects + +### AI Shorts Pipeline +1. **Analyze**: Scrape website URL + web research, or generate from manual description +2. **Script**: AI writes viral scripts (hook - problem - solution - CTA format) +3. **Actor**: Generate AI actors with Flux 2 Pro or select from shared gallery +4. **Voice**: ElevenLabs TTS voiceover (English/Spanish, male/female) +5. **Video**: Talking head generation (Hailuo 2.3 Fast img2video + VEED Lipsync) +6. **B-roll**: AI-generated visuals with Ken Burns effect +7. **Composite**: FFmpeg final assembly with subtitles and hook overlays +8. **Publish**: Direct posting to TikTok, Instagram Reels, YouTube Shorts via Upload-Post + +### YouTube Studio +- AI-powered title generation with 10 viral options +- Interactive refinement chat for titles +- AI thumbnail generation with custom face + background +- Auto descriptions with chapter timestamps from Whisper transcript +- Direct YouTube publishing via Upload-Post + +### Social Auto-Publishing +- **One-click posting** to TikTok, Instagram Reels, and YouTube Shorts simultaneously +- **Schedule uploads** for any date and time — plan your content calendar and let OpenShorts publish automatically +- **Multi-platform distribution** — publish to all your social networks at once from a single interface +- Upload-Post integration with async uploads + +### Infrastructure +- S3 cloud backup (private bucket for clips, public bucket for gallery/avatars) +- SEO gallery pages served by FastAPI with JSON-LD structured data +- Shared avatar gallery across all users +- Async job queue with configurable concurrency + +--- + +## Who Is This For? + +- **Content creators** — Turn long videos into shorts automatically, publish to all platforms at once +- **Marketing agencies** — Generate UGC videos for clients at scale, no actors or studios needed +- **SaaS founders** — Create product demos and marketing shorts from just a URL +- **E-commerce brands** — Product videos with AI actors for TikTok Shop, Instagram, YouTube +- **Local businesses** — Restaurants, gyms, real estate, coaching — affordable video marketing +- **Developers** — Self-host, customize the pipeline, integrate via API + +--- + +## AI Shorts Showcase + +Videos generated with OpenShorts AI Shorts — no camera, no studio, no actors: + +| | | | +|:---:|:---:|:---:| +| [![Biohacking for Investors](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/cdceec1b/actor.png)](https://openshorts.app/video/cdceec1b) | [![Secret Weapon for Devs](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/d3a80b6b/actor.png)](https://openshorts.app/video/d3a80b6b) | [![El Secreto de los Agentes de IA](https://test-videos-upload-post.s3.eu-west-3.amazonaws.com/videos/8ab7de92/actor.png)](https://openshorts.app/video/8ab7de92) | +| **Biohacking for Investors** · LOW COST | **Secret Weapon for Devs** · LOW COST | **El Secreto de los Agentes de IA** · PREMIUM | + +> Browse all videos at [openshorts.app/gallery](https://openshorts.app/gallery) + +--- + +## OpenShorts vs Competitors + +| Feature | OpenShorts | Opus Clip | CapCut | Vizard | Klap | Descript | +|---------|:---:|:---:|:---:|:---:|:---:|:---:| +| **Price** | **Free** | $15-29/mo | $8/mo | $15-20/mo | $23-63/mo | $24-65/mo | +| **Self-hosted** | **Yes** | No | No | No | No | No | +| **Open source** | **Yes** | No | No | No | No | No | +| **Watermark** | **Never** | Free tier | Some | Free tier | Free tier | Free tier | +| **Upload limits** | **None** | 10-30GB | Credit-based | 60min-10hr | 10-100 vids/mo | 60min-40hr | +| **AI clip detection** | Yes | Yes | Yes | Yes | Yes | Yes | +| **Smart 9:16 reframing** | Yes | Yes | Yes | Yes | Yes | No | +| **Auto subtitles** | Yes | Yes | Yes | Yes | Yes | Yes | +| **Voice dubbing (30+ langs)** | Yes | No | Pro only | No | Pro only | Business only | +| **AI UGC actors** | **Yes** | No | No | No | No | No | +| **AI video effects** | Yes | No | Yes | No | No | No | +| **Hook text overlays** | Yes | No | No | No | No | No | +| **YouTube Studio (titles, thumbnails)** | **Yes** | No | No | No | No | No | +| **Social auto-publishing** | Yes | Pro only | TikTok only | Paid only | Paid only | No | +| **Schedule uploads** | Yes | Pro only | No | Paid only | Paid only | No | +| **Data privacy** | **Your server** | Their cloud | Their cloud | Their cloud | Their cloud | Their cloud | + +--- + +## How Much Does It Cost? + +OpenShorts is free. You only pay for the AI APIs you use — and most have generous free tiers: + +| Service | Free Tier | Paid Cost | Used For | +|---------|-----------|-----------|----------| +| **Google Gemini** | Free trial with generous limits | < $0.01 per 10-min video | Viral moment detection, script generation, web research | +| **fal.ai** | Pay-per-use | ~$0.50-1.50 per AI Short | Actor generation, talking head video, lip-sync | +| **ElevenLabs** | Free tier available | Pay-per-use | Voiceover, voice dubbing | +| **Upload-Post** | **10 free uploads/month** to all networks (no credit card) | Pay-per-use | Auto-publishing to TikTok, Instagram, YouTube | +| **AWS S3** | Optional | ~$0.023/GB | Cloud backup for clips and gallery | + +**Bottom line:** You can clip videos for practically free with Gemini, and publish 10 videos/month to all social networks at zero cost with Upload-Post. + +--- + +## Requirements + +- **Docker & Docker Compose** +- **Google Gemini API Key** ([Free — get it here](https://aistudio.google.com/app/apikey)) — required for all AI features +- **fal.ai API Key** ([Pay-per-use](https://fal.ai)) — required for AI Shorts (actor generation, video, lip-sync) +- **ElevenLabs API Key** ([Free tier](https://elevenlabs.io)) — required for voiceover/dubbing +- **Upload-Post API Key** ([free tier](https://upload-post.com)) — required for direct social posting + +--- + +## Getting Started + +### 1. Clone +```bash +git clone https://github.com/your-username/OpenShorts.git +cd OpenShorts +``` + +### 2. Configure (optional) +```bash +cp .env.example .env +# Edit .env with your AWS keys for S3 backup +``` + +### 3. Launch +```bash +docker compose up --build +``` + +### 4. Open Dashboard +Navigate to **`http://localhost:5175`** + +1. Go to **Settings** and enter your API keys (Gemini, fal.ai, ElevenLabs, Upload-Post) +2. **Clip Generator**: Upload a long-form video to generate viral shorts +3. **AI Shorts**: Describe your product or paste a URL to generate UGC marketing videos +4. **YouTube Studio**: Generate thumbnails, titles, and descriptions for YouTube +5. **UGC Gallery**: Browse all generated videos and avatars + +--- + +## Technical Pipeline + +### Clip Generator +1. **Ingest** — Local video upload (or self-hosted URL ingest via yt-dlp) +2. **Transcribe** — faster-whisper with word-level timestamps +3. **Detect** — PySceneDetect for scene boundaries +4. **Analyze** — Gemini identifies 3-15 viral moments (15-60s each) +5. **Extract** — FFmpeg precise clip cutting +6. **Reframe** — AI vertical cropping with subject tracking +7. **Effects** — Subtitles, hooks, AI video effects +8. **Publish** — S3 backup + Upload-Post social distribution + +### AI Shorts +1. **Analyze** — Website scraping + Gemini web research (or manual description) +2. **Script** — Gemini generates viral scripts with segments +3. **Actor** — Flux 2 Pro portrait generation (or gallery/upload) +4. **Voice** — ElevenLabs TTS voiceover +5. **Video** — Hailuo 2.3 Fast img2video + VEED Lipsync (Low Cost) or Kling Avatar v2 (Premium) +6. **B-roll** — Flux 2 Pro image generation + Ken Burns effect +7. **Composite** — FFmpeg assembly with ASS subtitles and hook overlays +8. **Gallery** — Upload to public S3 with metadata for SEO pages +9. **Publish** — Upload-Post to TikTok, Instagram, YouTube + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Backend | Python 3.11, FastAPI, google-genai, faster-whisper, ultralytics (YOLOv8), mediapipe, opencv-python, yt-dlp, FFmpeg, httpx | +| Frontend | React 18, Vite 4, Tailwind CSS 3.4 | +| AI APIs | Google Gemini, fal.ai (Flux, Hailuo, VEED, Kling), ElevenLabs | +| Infrastructure | Docker + Docker Compose, AWS S3 | +| Publishing | Upload-Post API (TikTok, Instagram, YouTube) | + +--- + +## Environment Variables + +**Server-side (.env):** +| Variable | Description | +|----------|------------| +| `AWS_ACCESS_KEY_ID` | AWS access key for S3 | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key | +| `AWS_REGION` | AWS region (default: us-east-1) | +| `AWS_S3_BUCKET` | Private bucket for clip backup | +| `AWS_S3_PUBLIC_BUCKET` | Public bucket for gallery/avatars | +| `MAX_CONCURRENT_JOBS` | Concurrent processing limit (default: 5) | + +**Client-side (encrypted in localStorage):** +| Key | Description | +|-----|------------| +| `GEMINI_API_KEY` | Google Gemini — required | +| `FAL_KEY` | fal.ai — required for AI Shorts | +| `ELEVENLABS_API_KEY` | ElevenLabs — required for voiceover/dubbing | +| `UPLOAD_POST_API_KEY` | Upload-Post — required, for social posting | + +--- + +## Security & Performance + +- **Non-Root Execution**: Containers run as dedicated `appuser` +- **Concurrency Control**: Semaphore-based job queue (`MAX_CONCURRENT_JOBS`) +- **Auto-Cleanup**: Automatic purging of old jobs (1h retention) +- **Encrypted Keys**: API keys encrypted client-side, never stored server-side +- **Upload Validation**: Image uploads validated for format and minimum size +- **File Limits**: 2GB upload limit protection + +--- + +## Social Media Setup (Upload-Post) + +1. **Register**: [app.upload-post.com/login](https://app.upload-post.com/login) +2. **Create Profile**: Go to [Manage Users](https://app.upload-post.com/manage-users) +3. **Connect Accounts**: Link TikTok, Instagram, and/or YouTube +4. **Get API Key**: Navigate to [API Keys](https://app.upload-post.com/api-keys) +5. **Use in OpenShorts**: Paste the key in Settings + +--- + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=mutonby/openshorts&type=Date)](https://star-history.com/#mutonby/openshorts&Date) + +## Contributions + +Contributions are welcome! Whether it's adding new AI models, improving the lip-sync pipeline, or building new features — feel free to open a PR. + +## License + +MIT License. OpenShorts is yours to use, modify, and scale. diff --git a/openshorts/app.py b/openshorts/app.py new file mode 100644 index 0000000..0da7234 --- /dev/null +++ b/openshorts/app.py @@ -0,0 +1,2256 @@ +import os +import uuid +import subprocess +import threading +import json +import shutil +import glob +import time +import asyncio +from dotenv import load_dotenv +from typing import Dict, Optional, List +from contextlib import asynccontextmanager +from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, Header, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +from pydantic import BaseModel +from s3_uploader import upload_job_artifacts, list_all_clips, upload_actor_to_s3, list_actor_gallery, upload_video_to_gallery, list_video_gallery + +load_dotenv() + +# Constants +UPLOAD_DIR = "uploads" +OUTPUT_DIR = "output" +os.makedirs(UPLOAD_DIR, exist_ok=True) +os.makedirs(OUTPUT_DIR, exist_ok=True) + +# Configuration +# Default to 1 if not set, but user can set higher for powerful servers +MAX_CONCURRENT_JOBS = int(os.environ.get("MAX_CONCURRENT_JOBS", "5")) +MAX_FILE_SIZE_MB = 2048 # 2GB limit +JOB_RETENTION_SECONDS = 3600 # 1 hour retention +DISABLE_YOUTUBE_URL = os.environ.get("DISABLE_YOUTUBE_URL", "false").lower() in ("1", "true", "yes") + +# Application State +job_queue = asyncio.Queue() +jobs: Dict[str, Dict] = {} +thumbnail_sessions: Dict[str, Dict] = {} +publish_jobs: Dict[str, Dict] = {} # {publish_id: {status, result, error}} +# Semester to limit concurrency to MAX_CONCURRENT_JOBS +concurrency_semaphore = asyncio.Semaphore(MAX_CONCURRENT_JOBS) + +def _relocate_root_job_artifacts(job_id: str, job_output_dir: str) -> bool: + """ + Backward-compat rescue: + If main.py accidentally wrote metadata/clips into OUTPUT_DIR root (e.g. output/_...), + move them into output// so the API can find and serve them. + """ + try: + os.makedirs(job_output_dir, exist_ok=True) + root = OUTPUT_DIR + pattern = os.path.join(root, f"{job_id}_*_metadata.json") + meta_candidates = sorted(glob.glob(pattern), key=lambda p: os.path.getmtime(p), reverse=True) + if not meta_candidates: + return False + + # Move the newest metadata and its associated clips. + metadata_path = meta_candidates[0] + base_name = os.path.basename(metadata_path).replace("_metadata.json", "") + + # Move metadata + dest_metadata = os.path.join(job_output_dir, os.path.basename(metadata_path)) + if os.path.abspath(metadata_path) != os.path.abspath(dest_metadata): + shutil.move(metadata_path, dest_metadata) + + # Move any clips that match the same base_name into the job folder + clip_pattern = os.path.join(root, f"{base_name}_clip_*.mp4") + for clip_path in glob.glob(clip_pattern): + dest_clip = os.path.join(job_output_dir, os.path.basename(clip_path)) + if os.path.abspath(clip_path) != os.path.abspath(dest_clip): + shutil.move(clip_path, dest_clip) + + # Also move any temp_ clips that might remain + temp_clip_pattern = os.path.join(root, f"temp_{base_name}_clip_*.mp4") + for clip_path in glob.glob(temp_clip_pattern): + dest_clip = os.path.join(job_output_dir, os.path.basename(clip_path)) + if os.path.abspath(clip_path) != os.path.abspath(dest_clip): + shutil.move(clip_path, dest_clip) + + return True + except Exception: + return False + +async def cleanup_jobs(): + """Background task to remove old jobs and files.""" + import time + print("🧹 Cleanup task started.") + while True: + try: + await asyncio.sleep(300) # Check every 5 minutes + now = time.time() + + # Simple directory cleanup based on modification time + # Check OUTPUT_DIR + for job_id in os.listdir(OUTPUT_DIR): + job_path = os.path.join(OUTPUT_DIR, job_id) + if os.path.isdir(job_path): + if now - os.path.getmtime(job_path) > JOB_RETENTION_SECONDS: + print(f"🧹 Purging old job: {job_id}") + shutil.rmtree(job_path, ignore_errors=True) + if job_id in jobs: + del jobs[job_id] + + # Cleanup SaaSShorts jobs from memory + try: + saas_expired = [ + jid for jid, jdata in list(saas_jobs.items()) + if jdata.get("status") in ("completed", "failed") + and jdata.get("output_dir") + and os.path.isdir(jdata["output_dir"]) + and now - os.path.getmtime(jdata["output_dir"]) > JOB_RETENTION_SECONDS + ] + for jid in saas_expired: + del saas_jobs[jid] + except NameError: + pass + + # Cleanup Uploads + for filename in os.listdir(UPLOAD_DIR): + file_path = os.path.join(UPLOAD_DIR, filename) + try: + if now - os.path.getmtime(file_path) > JOB_RETENTION_SECONDS: + os.remove(file_path) + except Exception: pass + + except Exception as e: + print(f"⚠️ Cleanup error: {e}") + +async def process_queue(): + """Background worker to process jobs from the queue with concurrency limit.""" + print(f"🚀 Job Queue Worker started with {MAX_CONCURRENT_JOBS} concurrent slots.") + while True: + try: + # Wait for a job + job_id = await job_queue.get() + + # Acquire semaphore slot (waits if max jobs are running) + await concurrency_semaphore.acquire() + print(f"🔄 Acquired slot for job: {job_id}") + + # Process in background task to not block the loop (allowing other slots to fill) + asyncio.create_task(run_job_wrapper(job_id)) + + except Exception as e: + print(f"❌ Queue dispatch error: {e}") + await asyncio.sleep(1) + +async def run_job_wrapper(job_id): + """Wrapper to run job and release semaphore""" + try: + job = jobs.get(job_id) + if job: + await run_job(job_id, job) + except Exception as e: + print(f"❌ Job wrapper error {job_id}: {e}") + finally: + # Always release semaphore and mark queue task done + concurrency_semaphore.release() + job_queue.task_done() + print(f"✅ Released slot for job: {job_id}") + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Start worker and cleanup + worker_task = asyncio.create_task(process_queue()) + cleanup_task = asyncio.create_task(cleanup_jobs()) + yield + # Cleanup (optional: cancel worker) + +app = FastAPI(lifespan=lifespan) + +# Enable CORS for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount static files for serving videos +app.mount("/videos", StaticFiles(directory=OUTPUT_DIR), name="videos") + +# Mount static files for serving thumbnails +THUMBNAILS_DIR = os.path.join(OUTPUT_DIR, "thumbnails") +os.makedirs(THUMBNAILS_DIR, exist_ok=True) +app.mount("/thumbnails", StaticFiles(directory=THUMBNAILS_DIR), name="thumbnails") + +class ProcessRequest(BaseModel): + url: str + +def enqueue_output(out, job_id): + """Reads output from a subprocess and appends it to jobs logs.""" + try: + for line in iter(out.readline, b''): + decoded_line = line.decode('utf-8').strip() + if decoded_line: + print(f"📝 [Job Output] {decoded_line}") + if job_id in jobs: + jobs[job_id]['logs'].append(decoded_line) + except Exception as e: + print(f"Error reading output for job {job_id}: {e}") + finally: + out.close() + +async def run_job(job_id, job_data): + """Executes the subprocess for a specific job.""" + + cmd = job_data['cmd'] + env = job_data['env'] + output_dir = job_data['output_dir'] + + jobs[job_id]['status'] = 'processing' + jobs[job_id]['logs'].append("Job started by worker.") + print(f"🎬 [run_job] Executing command for {job_id}: {' '.join(cmd)}") + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr to stdout + env=env, + cwd=os.getcwd() + ) + + # We need to capture logs in a thread because Popen isn't async + t_log = threading.Thread(target=enqueue_output, args=(process.stdout, job_id)) + t_log.daemon = True + t_log.start() + + # Async wait for process with incremental updates + start_wait = time.time() + while process.poll() is None: + await asyncio.sleep(2) + + # Check for partial results every 2 seconds + # Look for metadata file + try: + json_files = glob.glob(os.path.join(output_dir, "*_metadata.json")) + if json_files: + target_json = json_files[0] + # Read metadata (it might be being written to, so simple try/except or just read) + # Use a lock or just robust read? json.load might fail if file is partial. + # Usually main.py writes it once at start (based on my review). + if os.path.getsize(target_json) > 0: + with open(target_json, 'r') as f: + data = json.load(f) + + base_name = os.path.basename(target_json).replace('_metadata.json', '') + clips = data.get('shorts', []) + cost_analysis = data.get('cost_analysis') + + # Check which clips actually exist on disk + ready_clips = [] + for i, clip in enumerate(clips): + clip_filename = f"{base_name}_clip_{i+1}.mp4" + clip_path = os.path.join(output_dir, clip_filename) + if os.path.exists(clip_path) and os.path.getsize(clip_path) > 0: + # Checking if file is growing? For now assume if it exists and main.py moves it there, it's done. + # main.py writes to temp_... then moves to final name. So presence means ready! + clip['video_url'] = f"/videos/{job_id}/{clip_filename}" + ready_clips.append(clip) + + if ready_clips: + jobs[job_id]['result'] = {'clips': ready_clips, 'cost_analysis': cost_analysis} + except Exception as e: + # Ignore read errors during processing + pass + + returncode = process.returncode + + if returncode == 0: + jobs[job_id]['status'] = 'completed' + jobs[job_id]['logs'].append("Process finished successfully.") + + # Start S3 upload in background (silent, non-blocking) + loop = asyncio.get_event_loop() + loop.run_in_executor(None, upload_job_artifacts, output_dir, job_id) + + # Find result JSON + json_files = glob.glob(os.path.join(output_dir, "*_metadata.json")) + if not json_files: + # Backward-compat rescue if outputs were written to OUTPUT_DIR root + if _relocate_root_job_artifacts(job_id, output_dir): + json_files = glob.glob(os.path.join(output_dir, "*_metadata.json")) + if json_files: + target_json = json_files[0] + with open(target_json, 'r') as f: + data = json.load(f) + + # Enhance result with video URLs + base_name = os.path.basename(target_json).replace('_metadata.json', '') + clips = data.get('shorts', []) + cost_analysis = data.get('cost_analysis') + + for i, clip in enumerate(clips): + clip_filename = f"{base_name}_clip_{i+1}.mp4" + clip['video_url'] = f"/videos/{job_id}/{clip_filename}" + + jobs[job_id]['result'] = {'clips': clips, 'cost_analysis': cost_analysis} + else: + jobs[job_id]['status'] = 'failed' + jobs[job_id]['logs'].append("No metadata file generated.") + else: + jobs[job_id]['status'] = 'failed' + jobs[job_id]['logs'].append(f"Process failed with exit code {returncode}") + + except Exception as e: + jobs[job_id]['status'] = 'failed' + jobs[job_id]['logs'].append(f"Execution error: {str(e)}") + +@app.get("/api/config") +async def get_config(): + return {"youtubeUrlEnabled": not DISABLE_YOUTUBE_URL} + +@app.post("/api/process") +async def process_endpoint( + request: Request, + file: Optional[UploadFile] = File(None), + url: Optional[str] = Form(None), + acknowledged: Optional[str] = Form(None) +): + api_key = request.headers.get("X-Gemini-Key") + if not api_key: + raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header") + + ack_flag = str(acknowledged).lower() in ("1", "true", "yes") + + # Handle JSON body manually for URL payload + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + body = await request.json() + url = body.get("url") + ack_flag = bool(body.get("acknowledged")) + + if not url and not file: + raise HTTPException(status_code=400, detail="Must provide URL or File") + + if not ack_flag: + raise HTTPException(status_code=400, detail="You must confirm you own the content or have rights to process it.") + + if url and DISABLE_YOUTUBE_URL: + raise HTTPException(status_code=403, detail="YouTube URL ingest is disabled on this deployment. Please upload a file you own.") + + # Capture attestation context for legal record (IP + timestamp + UA) + client_ip = request.client.host if request.client else "unknown" + fwd = request.headers.get("x-forwarded-for") + if fwd: + client_ip = fwd.split(",")[0].strip() + user_agent = request.headers.get("user-agent", "") + attestation = { + "acknowledged": True, + "ip": client_ip, + "user_agent": user_agent, + "timestamp": time.time(), + "source": "url" if url else "file", + } + + job_id = str(uuid.uuid4()) + job_output_dir = os.path.join(OUTPUT_DIR, job_id) + os.makedirs(job_output_dir, exist_ok=True) + + # Prepare Command + cmd = ["python", "-u", "main.py"] # -u for unbuffered + env = os.environ.copy() + env["GEMINI_API_KEY"] = api_key # Override with key from request + + if url: + cmd.extend(["-u", url]) + else: + # Save uploaded file with size limit check + input_path = os.path.join(UPLOAD_DIR, f"{job_id}_{file.filename}") + + # Read file in chunks to check size + size = 0 + limit_bytes = MAX_FILE_SIZE_MB * 1024 * 1024 + + with open(input_path, "wb") as buffer: + while content := await file.read(1024 * 1024): # Read 1MB chunks + size += len(content) + if size > limit_bytes: + os.remove(input_path) + shutil.rmtree(job_output_dir) + raise HTTPException(status_code=413, detail=f"File too large. Max size {MAX_FILE_SIZE_MB}MB") + buffer.write(content) + + cmd.extend(["-i", input_path]) + + cmd.extend(["-o", job_output_dir]) + + print(f"[attestation] job={job_id} ip={attestation['ip']} source={attestation['source']} ack=true") + + # Enqueue Job + jobs[job_id] = { + 'status': 'queued', + 'logs': [f"Job {job_id} queued."], + 'cmd': cmd, + 'env': env, + 'output_dir': job_output_dir, + 'attestation': attestation + } + + await job_queue.put(job_id) + + return {"job_id": job_id, "status": "queued"} + +@app.get("/api/status/{job_id}") +async def get_status(job_id: str): + if job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = jobs[job_id] + return { + "status": job['status'], + "logs": job['logs'], + "result": job.get('result') + } + +from editor import VideoEditor +from subtitles import generate_srt, burn_subtitles, generate_srt_from_video +from hooks import add_hook_to_video +from translate import translate_video, get_supported_languages +from thumbnail import analyze_video_for_titles, refine_titles, generate_thumbnail, generate_youtube_description + +class EditRequest(BaseModel): + job_id: str + clip_index: int + api_key: Optional[str] = None + input_filename: Optional[str] = None + +@app.post("/api/edit") +async def edit_clip( + req: EditRequest, + x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key") +): + # Determine API Key + final_api_key = req.api_key or x_gemini_key or os.environ.get("GEMINI_API_KEY") + + if not final_api_key: + raise HTTPException(status_code=400, detail="Missing Gemini API Key (Header or Body)") + + if req.job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = jobs[req.job_id] + if 'result' not in job or 'clips' not in job['result']: + raise HTTPException(status_code=400, detail="Job result not available") + + try: + # Resolve Input Path: Prefer explict input_filename from frontend (chaining edits) + if req.input_filename: + # Security: Ensure just a filename, no paths + safe_name = os.path.basename(req.input_filename) + input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_name) + filename = safe_name + else: + # Fallback to original clip + clip = job['result']['clips'][req.clip_index] + filename = clip['video_url'].split('/')[-1] + input_path = os.path.join(OUTPUT_DIR, req.job_id, filename) + + if not os.path.exists(input_path): + raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}") + + # Define output path for edited video + edited_filename = f"edited_{filename}" + output_path = os.path.join(OUTPUT_DIR, req.job_id, edited_filename) + + # Run editing in a thread to avoid blocking main loop + # Since VideoEditor uses blocking calls (subprocess, API wait) + def run_edit(): + editor = VideoEditor(api_key=final_api_key) + + # SAFE FILE RENAMING STRATEGY (Avoid UnicodeEncodeError in Docker) + # Create a safe ASCII filename in the same directory + safe_filename = f"temp_input_{req.job_id}.mp4" + safe_input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_filename) + + # Copy original file to safe path + # (Copy is safer than rename if something crashes, we keep original) + shutil.copy(input_path, safe_input_path) + + try: + # 1. Upload (using safe path) + vid_file = editor.upload_video(safe_input_path) + + # 2. Get duration + import cv2 + cap = cv2.VideoCapture(safe_input_path) + fps = cap.get(cv2.CAP_PROP_FPS) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + duration = frame_count / fps if fps else 0 + cap.release() + + # Load transcript from metadata + transcript = None + try: + meta_files = glob.glob(os.path.join(OUTPUT_DIR, req.job_id, "*_metadata.json")) + if meta_files: + with open(meta_files[0], 'r') as f: + data = json.load(f) + transcript = data.get('transcript') + except Exception as e: + print(f"⚠️ Could not load transcript for editing context: {e}") + + # 3. Get Plan (Filter String) + filter_data = editor.get_ffmpeg_filter(vid_file, duration, fps=fps, width=width, height=height, transcript=transcript) + + # 4. Apply + # Use safe output name first + safe_output_path = os.path.join(OUTPUT_DIR, req.job_id, f"temp_output_{req.job_id}.mp4") + editor.apply_edits(safe_input_path, safe_output_path, filter_data) + + # Move result to final destination (rename works even if dest name has unicode if filesystem supports it, + # but python might still struggle if locale is broken? No, os.rename usually handles it better than subprocess args) + # Actually, output_path is defined above: f"edited_{filename}" + # If filename has unicode, output_path has unicode. + # Let's hope shutil.move / os.rename works. + if os.path.exists(safe_output_path): + shutil.move(safe_output_path, output_path) + + return filter_data + finally: + # Cleanup temp safe input + if os.path.exists(safe_input_path): + os.remove(safe_input_path) + + # Run in thread pool + loop = asyncio.get_event_loop() + plan = await loop.run_in_executor(None, run_edit) + + # Update clip URL in the job result? + # Or return new URL and let frontend handle it? + # Updating job result allows persistence if page refreshes. + + new_video_url = f"/videos/{req.job_id}/{edited_filename}" + + # Start a new "edited" clip entry or just update the current one? + # Let's update the current one's video_url but keep backup? + # Or return the new URL to the frontend to display. + + return { + "success": True, + "new_video_url": new_video_url, + "edit_plan": plan + } + + except Exception as e: + print(f"❌ Edit Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +class SubtitleRequest(BaseModel): + job_id: str + clip_index: int + position: str = "bottom" # top, middle, bottom + font_size: int = 16 + font_name: str = "Verdana" + font_color: str = "#FFFFFF" + border_color: str = "#000000" + border_width: int = 2 + bg_color: str = "#000000" + bg_opacity: float = 0.0 + input_filename: Optional[str] = None + + +@app.get("/api/clip/{job_id}/{clip_index}/transcript") +async def get_clip_transcript(job_id: str, clip_index: int): + """Return word-level captions for a specific clip, formatted for Remotion.""" + if job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + output_dir = os.path.join(OUTPUT_DIR, job_id) + json_files = glob.glob(os.path.join(output_dir, "*_metadata.json")) + + if not json_files: + raise HTTPException(status_code=404, detail="Metadata not found") + + with open(json_files[0], 'r') as f: + data = json.load(f) + + transcript = data.get('transcript') + if not transcript: + raise HTTPException(status_code=400, detail="Transcript not found in metadata") + + clips = data.get('shorts', []) + if clip_index >= len(clips): + raise HTTPException(status_code=404, detail="Clip not found") + + clip_data = clips[clip_index] + clip_start = clip_data.get('start', 0) + clip_end = clip_data.get('end', 0) + + # Extract words within clip range and convert to CaptionWord format + captions = [] + for segment in transcript.get('segments', []): + for word_info in segment.get('words', []): + if word_info['end'] > clip_start and word_info['start'] < clip_end: + captions.append({ + "text": word_info.get('word', '').strip(), + "startMs": int((max(0, word_info['start'] - clip_start)) * 1000), + "endMs": int((max(0, word_info['end'] - clip_start)) * 1000), + }) + + duration_sec = clip_end - clip_start + + return { + "captions": captions, + "durationSec": duration_sec, + "language": transcript.get('language', 'en'), + } + + +# --- Remotion Render Proxy --- +RENDER_SERVICE_URL = os.getenv("RENDER_SERVICE_URL", "http://renderer:3100") + +@app.post("/api/render") +async def proxy_render(request: Request): + """Proxy render requests to the Node.js Remotion render service.""" + import httpx + body = await request.json() + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{RENDER_SERVICE_URL}/render", json=body) + return resp.json() + except Exception as e: + raise HTTPException(status_code=502, detail=f"Render service unavailable: {e}") + +@app.get("/api/render/{render_id}") +async def proxy_render_status(render_id: str): + """Proxy render status polling to the Node.js Remotion render service.""" + import httpx + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(f"{RENDER_SERVICE_URL}/render/{render_id}") + return resp.json() + except Exception as e: + raise HTTPException(status_code=502, detail=f"Render service unavailable: {e}") + + +class EffectsGenerateRequest(BaseModel): + job_id: str + clip_index: int + input_filename: Optional[str] = None + +@app.post("/api/effects/generate") +async def generate_effects_config( + req: EffectsGenerateRequest, + x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key") +): + """Generate structured EffectsConfig JSON for Remotion rendering via Gemini AI.""" + final_api_key = x_gemini_key or os.environ.get("GEMINI_API_KEY") + + if not final_api_key: + raise HTTPException(status_code=400, detail="Missing Gemini API Key (Header)") + + if req.job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = jobs[req.job_id] + if 'result' not in job or 'clips' not in job['result']: + raise HTTPException(status_code=400, detail="Job result not available") + + try: + # Resolve input path + if req.input_filename: + safe_name = os.path.basename(req.input_filename) + input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_name) + else: + clip = job['result']['clips'][req.clip_index] + filename = clip['video_url'].split('/')[-1] + input_path = os.path.join(OUTPUT_DIR, req.job_id, filename) + + if not os.path.exists(input_path): + raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}") + + def run_effects_generation(): + editor = VideoEditor(api_key=final_api_key) + + # Create safe ASCII filename to avoid encoding issues + safe_filename = f"temp_effects_{req.job_id}.mp4" + safe_input_path = os.path.join(OUTPUT_DIR, req.job_id, safe_filename) + shutil.copy(input_path, safe_input_path) + + try: + # Upload video to Gemini + vid_file = editor.upload_video(safe_input_path) + + # Get video metadata via ffprobe + probe_cmd = [ + 'ffprobe', '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height,r_frame_rate,duration', + '-show_entries', 'format=duration', + '-of', 'json', + safe_input_path + ] + probe_result = subprocess.check_output(probe_cmd).decode().strip() + probe_data = json.loads(probe_result) + + stream = probe_data.get('streams', [{}])[0] + width = int(stream.get('width', 1080)) + height = int(stream.get('height', 1920)) + + # Parse fps from r_frame_rate (e.g. "30/1") + r_frame_rate = stream.get('r_frame_rate', '30/1') + num, den = r_frame_rate.split('/') + fps = round(int(num) / int(den), 2) + + # Get duration from stream or format + duration = float(stream.get('duration', 0)) + if duration == 0: + duration = float(probe_data.get('format', {}).get('duration', 0)) + + # Load transcript from metadata + transcript = None + try: + meta_files = glob.glob(os.path.join(OUTPUT_DIR, req.job_id, "*_metadata.json")) + if meta_files: + with open(meta_files[0], 'r') as f: + data = json.load(f) + transcript = data.get('transcript') + except Exception as e: + print(f"⚠️ Could not load transcript for effects config: {e}") + + # Generate effects config + effects_config = editor.get_effects_config( + vid_file, duration, fps=fps, width=width, height=height, transcript=transcript + ) + + return effects_config + finally: + if os.path.exists(safe_input_path): + os.remove(safe_input_path) + + loop = asyncio.get_event_loop() + effects_config = await loop.run_in_executor(None, run_effects_generation) + + if effects_config is None: + raise HTTPException(status_code=500, detail="Failed to generate effects config from Gemini") + + return {"effects": effects_config} + + except HTTPException: + raise + except Exception as e: + print(f"❌ Effects Generation Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/subtitle") +async def add_subtitles(req: SubtitleRequest): + if req.job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + # Reload job data from disk just in case metadata was updated + job = jobs[req.job_id] + + # We need to access metadata.json to get the transcript + output_dir = os.path.join(OUTPUT_DIR, req.job_id) + json_files = glob.glob(os.path.join(output_dir, "*_metadata.json")) + + if not json_files: + raise HTTPException(status_code=404, detail="Metadata not found") + + with open(json_files[0], 'r') as f: + data = json.load(f) + + transcript = data.get('transcript') + if not transcript: + raise HTTPException(status_code=400, detail="Transcript not found in metadata. Please process a new video.") + + clips = data.get('shorts', []) + if req.clip_index >= len(clips): + raise HTTPException(status_code=404, detail="Clip not found") + + clip_data = clips[req.clip_index] + + # Video Path + if req.input_filename: + # Use chained file + filename = os.path.basename(req.input_filename) + else: + # Fallback to standard naming + filename = clip_data.get('video_url', '').split('/')[-1] + if not filename: + base_name = os.path.basename(json_files[0]).replace('_metadata.json', '') + filename = f"{base_name}_clip_{req.clip_index+1}.mp4" + + input_path = os.path.join(output_dir, filename) + if not os.path.exists(input_path): + # Try looking for edited version if url implied it? + # Just fail if not found. + raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}") + + # Define outputs + srt_filename = f"subs_{req.clip_index}_{int(time.time())}.srt" + srt_path = os.path.join(output_dir, srt_filename) + + # Output video + # We create a new file "subtitled_..." + output_filename = f"subtitled_{filename}" + output_path = os.path.join(output_dir, output_filename) + + try: + # 1. Generate SRT + # Check if this is a dubbed video - if so, transcribe it fresh + is_dubbed = filename.startswith("translated_") + + if is_dubbed: + print(f"🎙️ Dubbed video detected, transcribing audio for subtitles...") + def run_transcribe_srt(): + return generate_srt_from_video(input_path, srt_path) + + loop = asyncio.get_event_loop() + success = await loop.run_in_executor(None, run_transcribe_srt) + else: + success = generate_srt(transcript, clip_data['start'], clip_data['end'], srt_path) + + if not success: + raise HTTPException(status_code=400, detail="No words found for this clip range.") + + # 2. Burn Subtitles + # Run in thread pool + def run_burn(): + burn_subtitles(input_path, srt_path, output_path, + alignment=req.position, fontsize=req.font_size, + font_name=req.font_name, font_color=req.font_color, + border_color=req.border_color, border_width=req.border_width, + bg_color=req.bg_color, bg_opacity=req.bg_opacity) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, run_burn) + + except Exception as e: + print(f"❌ Subtitle Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # 3. Update Result and Metadata + # Update InMemory Jobs + if req.clip_index < len(job['result']['clips']): + job['result']['clips'][req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}" + + # Update Metadata on Disk (Persistence) + try: + if req.clip_index < len(clips): + clips[req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}" + # Update the main data structure + data['shorts'] = clips + + # Write back + with open(json_files[0], 'w') as f: + json.dump(data, f, indent=4) + print(f"✅ Metadata updated with subtitled video for clip {req.clip_index}") + except Exception as e: + print(f"⚠️ Failed to update metadata.json: {e}") + # Non-critical, but good for persistence + + return { + "success": True, + "new_video_url": f"/videos/{req.job_id}/{output_filename}" + } + +class HookRequest(BaseModel): + job_id: str + clip_index: int + text: str + input_filename: Optional[str] = None + position: Optional[str] = "top" # top, center, bottom + size: Optional[str] = "M" # S, M, L + +@app.post("/api/hook") +async def add_hook(req: HookRequest): + if req.job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = jobs[req.job_id] + output_dir = os.path.join(OUTPUT_DIR, req.job_id) + json_files = glob.glob(os.path.join(output_dir, "*_metadata.json")) + + if not json_files: + raise HTTPException(status_code=404, detail="Metadata not found") + + with open(json_files[0], 'r') as f: + data = json.load(f) + + clips = data.get('shorts', []) + if req.clip_index >= len(clips): + raise HTTPException(status_code=404, detail="Clip not found") + + clip_data = clips[req.clip_index] + + # Video Path + if req.input_filename: + filename = os.path.basename(req.input_filename) + else: + filename = clip_data.get('video_url', '').split('/')[-1] + if not filename: + base_name = os.path.basename(json_files[0]).replace('_metadata.json', '') + filename = f"{base_name}_clip_{req.clip_index+1}.mp4" + + input_path = os.path.join(output_dir, filename) + if not os.path.exists(input_path): + raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}") + + # Output video + output_filename = f"hook_{filename}" + output_path = os.path.join(output_dir, output_filename) + + # Map Size to Scale + size_map = {"S": 0.8, "M": 1.0, "L": 1.3} + font_scale = size_map.get(req.size, 1.0) + + try: + # Run in thread pool + def run_hook(): + add_hook_to_video(input_path, req.text, output_path, position=req.position, font_scale=font_scale) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, run_hook) + + except Exception as e: + print(f"❌ Hook Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # Update Persistence (Same logic as subtitles) + # Update InMemory Jobs + if req.clip_index < len(job['result']['clips']): + job['result']['clips'][req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}" + + # Update Metadata on Disk + try: + if req.clip_index < len(clips): + clips[req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}" + data['shorts'] = clips + with open(json_files[0], 'w') as f: + json.dump(data, f, indent=4) + print(f"✅ Metadata updated with hook video for clip {req.clip_index}") + except Exception as e: + print(f"⚠️ Failed to update metadata.json: {e}") + + return { + "success": True, + "new_video_url": f"/videos/{req.job_id}/{output_filename}" + } + +class TranslateRequest(BaseModel): + job_id: str + clip_index: int + target_language: str + source_language: Optional[str] = None + input_filename: Optional[str] = None + +@app.get("/api/translate/languages") +async def get_languages(): + """Return supported languages for translation.""" + return {"languages": get_supported_languages()} + +@app.post("/api/translate") +async def translate_clip( + req: TranslateRequest, + x_elevenlabs_key: Optional[str] = Header(None, alias="X-ElevenLabs-Key") +): + """Translate a video clip to a different language using ElevenLabs dubbing.""" + if not x_elevenlabs_key: + raise HTTPException(status_code=400, detail="Missing X-ElevenLabs-Key header") + + if req.job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = jobs[req.job_id] + output_dir = os.path.join(OUTPUT_DIR, req.job_id) + json_files = glob.glob(os.path.join(output_dir, "*_metadata.json")) + + if not json_files: + raise HTTPException(status_code=404, detail="Metadata not found") + + with open(json_files[0], 'r') as f: + data = json.load(f) + + clips = data.get('shorts', []) + if req.clip_index >= len(clips): + raise HTTPException(status_code=404, detail="Clip not found") + + clip_data = clips[req.clip_index] + + # Video Path + if req.input_filename: + filename = os.path.basename(req.input_filename) + else: + filename = clip_data.get('video_url', '').split('/')[-1] + if not filename: + base_name = os.path.basename(json_files[0]).replace('_metadata.json', '') + filename = f"{base_name}_clip_{req.clip_index+1}.mp4" + + input_path = os.path.join(output_dir, filename) + if not os.path.exists(input_path): + raise HTTPException(status_code=404, detail=f"Video file not found: {input_path}") + + # Output video with language suffix + base, ext = os.path.splitext(filename) + output_filename = f"translated_{req.target_language}_{base}{ext}" + output_path = os.path.join(output_dir, output_filename) + + try: + # Run translation in thread pool (blocking API calls) + def run_translate(): + return translate_video( + video_path=input_path, + output_path=output_path, + target_language=req.target_language, + api_key=x_elevenlabs_key, + source_language=req.source_language, + ) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, run_translate) + + except Exception as e: + print(f"❌ Translation Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # Update InMemory Jobs + if req.clip_index < len(job['result']['clips']): + job['result']['clips'][req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}" + + # Update Metadata on Disk + try: + if req.clip_index < len(clips): + clips[req.clip_index]['video_url'] = f"/videos/{req.job_id}/{output_filename}" + data['shorts'] = clips + with open(json_files[0], 'w') as f: + json.dump(data, f, indent=4) + print(f"✅ Metadata updated with translated video for clip {req.clip_index}") + except Exception as e: + print(f"⚠️ Failed to update metadata.json: {e}") + + return { + "success": True, + "new_video_url": f"/videos/{req.job_id}/{output_filename}" + } + +class SocialPostRequest(BaseModel): + job_id: str + clip_index: int + api_key: str + user_id: str + platforms: List[str] # ["tiktok", "instagram", "youtube"] + # Optional overrides if frontend wants to edit them + title: Optional[str] = None + description: Optional[str] = None + scheduled_date: Optional[str] = None # ISO-8601 string + timezone: Optional[str] = "UTC" + +import httpx + +@app.post("/api/social/post") +async def post_to_socials(req: SocialPostRequest): + if req.job_id not in jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = jobs[req.job_id] + if 'result' not in job or 'clips' not in job['result']: + raise HTTPException(status_code=400, detail="Job result not available") + + try: + clip = job['result']['clips'][req.clip_index] + # Video URL is relative /videos/..., we need absolute file path + # clip['video_url'] is like "/videos/{job_id}/{filename}" + # We constructed it as: f"/videos/{job_id}/{clip_filename}" + # And file is at f"{OUTPUT_DIR}/{job_id}/{clip_filename}" + + filename = clip['video_url'].split('/')[-1] + file_path = os.path.join(OUTPUT_DIR, req.job_id, filename) + + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail=f"Video file not found: {file_path}") + + # Construct parameters for Upload-Post API + # Fallbacks + final_title = req.title or clip.get('title', 'Viral Short') + final_description = req.description or clip.get('video_description_for_instagram') or clip.get('video_description_for_tiktok') or "Check this out!" + + # Prepare form data + url = "https://api.upload-post.com/api/upload" + headers = { + "Authorization": f"Apikey {req.api_key}" + } + + # Prepare data as dict (httpx handles lists for multiple values) + data_payload = { + "user": req.user_id, + "title": final_title, + "platform[]": req.platforms, # Pass list directly + "async_upload": "true" # Enable async upload + } + + # Add scheduling if present + if req.scheduled_date: + data_payload["scheduled_date"] = req.scheduled_date + if req.timezone: + data_payload["timezone"] = req.timezone + + # Add Platform specifics + if "tiktok" in req.platforms: + data_payload["tiktok_title"] = final_description + + if "instagram" in req.platforms: + data_payload["instagram_title"] = final_description + data_payload["media_type"] = "REELS" + + if "youtube" in req.platforms: + yt_title = req.title or clip.get('video_title_for_youtube_short', final_title) + data_payload["youtube_title"] = yt_title + data_payload["youtube_description"] = final_description + data_payload["privacyStatus"] = "public" + + # Send File + # httpx AsyncClient requires async file reading or bytes. + # Since we have MAX_FILE_SIZE_MB, reading into memory is safe-ish. + with open(file_path, "rb") as f: + file_content = f.read() + + files = { + "video": (filename, file_content, "video/mp4") + } + + # Switch to synchronous Client to avoid "sync request with AsyncClient" error with multipart/files + with httpx.Client(timeout=120.0) as client: + print(f"📡 Sending to Upload-Post for platforms: {req.platforms}") + response = client.post(url, headers=headers, data=data_payload, files=files) + + if response.status_code not in [200, 201, 202]: # Added 201 + print(f"❌ Upload-Post Error: {response.text}") + raise HTTPException(status_code=response.status_code, detail=f"Vendor API Error: {response.text}") + + return response.json() + + except Exception as e: + print(f"❌ Social Post Exception: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/social/user") +async def get_social_user(api_key: str = Header(..., alias="X-Upload-Post-Key")): + """Proxy to fetch user ID from Upload-Post""" + if not api_key: + raise HTTPException(status_code=400, detail="Missing X-Upload-Post-Key header") + + url = "https://api.upload-post.com/api/uploadposts/users" + print(f"🔍 Fetching User ID from: {url}") + headers = {"Authorization": f"Apikey {api_key}"} + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.get(url, headers=headers) + if resp.status_code != 200: + print(f"❌ Upload-Post User Fetch Error: {resp.text}") + raise HTTPException(status_code=resp.status_code, detail=f"Failed to fetch user: {resp.text}") + + data = resp.json() + print(f"🔍 Upload-Post User Response: {data}") + + user_id = None + # The structure is {'success': True, 'profiles': [{'username': '...'}, ...]} + profiles_list = [] + if isinstance(data, dict): + raw_profiles = data.get('profiles', []) + if isinstance(raw_profiles, list): + for p in raw_profiles: + username = p.get('username') + if username: + # Determine connected platforms + socials = p.get('social_accounts', {}) + connected = [] + # Check typical platforms + for platform in ['tiktok', 'instagram', 'youtube']: + account_info = socials.get(platform) + # If it's a dict and typically has data, or just not empty string + if isinstance(account_info, dict): + connected.append(platform) + + profiles_list.append({ + "username": username, + "connected": connected + }) + + if not profiles_list: + # Fallback if no profiles found + return {"profiles": [], "error": "No profiles found"} + + return {"profiles": profiles_list} + + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# --- Thumbnail Studio Endpoints --- + +@app.post("/api/thumbnail/upload") +async def thumbnail_upload( + file: Optional[UploadFile] = File(None), + url: Optional[str] = Form(None), +): + """Upload video and start background Whisper transcription immediately.""" + if not url and not file: + raise HTTPException(status_code=400, detail="Must provide URL or File") + + session_id = str(uuid.uuid4()) + transcript_event = asyncio.Event() + + # Save file if uploaded directly + video_path = None + if file: + video_path = os.path.join(UPLOAD_DIR, f"thumb_{session_id}_{file.filename}") + with open(video_path, "wb") as buffer: + content = await file.read() + buffer.write(content) + + # Initialize session + thumbnail_sessions[session_id] = { + "video_path": video_path, + "transcript_event": transcript_event, + "transcript_ready": False, + "transcript": None, + "transcript_segments": [], + "video_duration": 0, + "language": "en", + "context": "", + "titles": [], + "conversation": [], + "_url": url, # Store URL for deferred download + } + + async def run_background_whisper(): + try: + vpath = video_path + # Download YouTube video if URL was provided + if not vpath and url: + from main import download_youtube_video + loop = asyncio.get_event_loop() + vpath, _ = await loop.run_in_executor(None, download_youtube_video, url, UPLOAD_DIR) + thumbnail_sessions[session_id]["video_path"] = vpath + + from main import transcribe_video + loop = asyncio.get_event_loop() + transcript = await loop.run_in_executor(None, transcribe_video, vpath) + segments = transcript.get("segments", []) + duration = segments[-1]["end"] if segments else 0 + + thumbnail_sessions[session_id].update({ + "transcript_ready": True, + "transcript": transcript, + "transcript_segments": segments, + "video_duration": duration, + "language": transcript.get("language", "en"), + }) + print(f"✅ [Thumbnail] Background Whisper complete for session {session_id}") + except Exception as e: + print(f"❌ [Thumbnail] Background Whisper failed: {e}") + thumbnail_sessions[session_id]["transcript_error"] = str(e) + finally: + transcript_event.set() + + asyncio.create_task(run_background_whisper()) + + return {"session_id": session_id} + + +@app.post("/api/thumbnail/analyze") +async def thumbnail_analyze( + request: Request, + file: Optional[UploadFile] = File(None), + url: Optional[str] = Form(None), + session_id: Optional[str] = Form(None), + x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key") +): + """Analyze a video and suggest viral YouTube titles.""" + api_key = x_gemini_key + if not api_key: + raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header") + + pre_transcript = None + + # Check for pre-existing session with background Whisper + if session_id and session_id in thumbnail_sessions: + session = thumbnail_sessions[session_id] + + # Wait for background Whisper to complete + transcript_event = session.get("transcript_event") + if transcript_event: + print(f"⏳ [Thumbnail] Waiting for background Whisper to finish...") + await transcript_event.wait() + + if session.get("transcript_error"): + raise HTTPException(status_code=500, detail=f"Transcription failed: {session['transcript_error']}") + + video_path = session["video_path"] + if not video_path or not os.path.exists(video_path): + raise HTTPException(status_code=404, detail="Video file not found in session") + + if session.get("transcript_ready"): + pre_transcript = session["transcript"] + else: + # No pre-existing session — need file or URL + if not url and not file: + raise HTTPException(status_code=400, detail="Must provide URL, File, or session_id") + + session_id = str(uuid.uuid4()) + + if url: + from main import download_youtube_video + video_path, _ = download_youtube_video(url, UPLOAD_DIR) + else: + video_path = os.path.join(UPLOAD_DIR, f"thumb_{session_id}_{file.filename}") + with open(video_path, "wb") as buffer: + content = await file.read() + buffer.write(content) + + try: + # Run analysis in thread pool (skips Whisper if pre_transcript is available) + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, analyze_video_for_titles, api_key, video_path, pre_transcript) + + # Store/update session context + if session_id not in thumbnail_sessions: + thumbnail_sessions[session_id] = {} + + thumbnail_sessions[session_id].update({ + "context": result.get("transcript_summary", ""), + "titles": result.get("titles", []), + "language": result.get("language", "en"), + "conversation": thumbnail_sessions[session_id].get("conversation", []), + "video_path": video_path, + "transcript_segments": result.get("segments", []), + "video_duration": result.get("video_duration", 0) + }) + + return { + "session_id": session_id, + "titles": result.get("titles", []), + "context": result.get("transcript_summary", ""), + "language": result.get("language", "en"), + "recommended": result.get("recommended", []) + } + + except Exception as e: + print(f"❌ Thumbnail Analyze Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class ThumbnailTitlesRequest(BaseModel): + session_id: Optional[str] = None + message: Optional[str] = None + title: Optional[str] = None + +@app.post("/api/thumbnail/titles") +async def thumbnail_titles( + req: ThumbnailTitlesRequest, + x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key") +): + """Refine title suggestions or accept a manual title.""" + api_key = x_gemini_key + if not api_key: + raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header") + + # Manual title mode - just create a session with the user's title + if req.title: + session_id = req.session_id or str(uuid.uuid4()) + if session_id not in thumbnail_sessions: + thumbnail_sessions[session_id] = { + "context": "", + "titles": [req.title], + "language": "en", + "conversation": [] + } + return {"session_id": session_id, "titles": [req.title]} + + # Refinement mode + if not req.session_id or req.session_id not in thumbnail_sessions: + raise HTTPException(status_code=404, detail="Session not found") + + if not req.message: + raise HTTPException(status_code=400, detail="Must provide message or title") + + session = thumbnail_sessions[req.session_id] + + # Add user message to conversation history + session["conversation"].append({"role": "user", "content": req.message}) + + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + refine_titles, + api_key, + session["context"], + req.message, + session["conversation"] + ) + + new_titles = result.get("titles", []) + session["titles"] = new_titles + session["conversation"].append({"role": "assistant", "content": json.dumps(new_titles)}) + + return {"titles": new_titles} + + except Exception as e: + print(f"❌ Thumbnail Titles Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/thumbnail/generate") +async def thumbnail_generate( + request: Request, + session_id: str = Form(...), + title: str = Form(...), + extra_prompt: str = Form(""), + count: int = Form(3), + face: Optional[UploadFile] = File(None), + background: Optional[UploadFile] = File(None), + x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key") +): + """Generate YouTube thumbnails with Gemini image generation.""" + api_key = x_gemini_key + if not api_key: + raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header") + + # Clamp count + count = min(max(1, count), 6) + + # Save optional uploaded images + face_path = None + bg_path = None + thumb_upload_dir = os.path.join(UPLOAD_DIR, f"thumb_{session_id}") + os.makedirs(thumb_upload_dir, exist_ok=True) + + try: + if face and face.filename: + face_path = os.path.join(thumb_upload_dir, f"face_{face.filename}") + with open(face_path, "wb") as f: + f.write(await face.read()) + + if background and background.filename: + bg_path = os.path.join(thumb_upload_dir, f"bg_{background.filename}") + with open(bg_path, "wb") as f: + f.write(await background.read()) + + # Get video context from session (transcript summary from analysis step) + video_context = "" + if session_id in thumbnail_sessions: + video_context = thumbnail_sessions[session_id].get("context", "") + + # Run generation in thread pool + loop = asyncio.get_event_loop() + thumbnails = await loop.run_in_executor( + None, + generate_thumbnail, + api_key, + title, + session_id, + face_path, + bg_path, + extra_prompt, + count, + video_context + ) + + if not thumbnails: + raise HTTPException(status_code=500, detail="Thumbnail generation failed. Please check your Gemini API key has access to image generation (gemini-3.1-flash-image-preview model).") + + return {"thumbnails": thumbnails} + + except HTTPException: + raise + except Exception as e: + print(f"❌ Thumbnail Generate Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class ThumbnailDescribeRequest(BaseModel): + session_id: str + title: str + +@app.post("/api/thumbnail/describe") +async def thumbnail_describe( + req: ThumbnailDescribeRequest, + x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key") +): + """Generate a YouTube description with chapters from the transcript.""" + api_key = x_gemini_key + if not api_key: + raise HTTPException(status_code=400, detail="Missing X-Gemini-Key header") + + if req.session_id not in thumbnail_sessions: + raise HTTPException(status_code=404, detail="Session not found") + + session = thumbnail_sessions[req.session_id] + segments = session.get("transcript_segments", []) + if not segments: + raise HTTPException(status_code=400, detail="No transcript segments available. Please analyze a video first.") + + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + generate_youtube_description, + api_key, + req.title, + segments, + session.get("language", "en"), + session.get("video_duration", 0) + ) + return {"description": result.get("description", "")} + + except Exception as e: + print(f"❌ Thumbnail Describe Error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/thumbnail/publish") +async def thumbnail_publish( + background_tasks: BackgroundTasks, + session_id: str = Form(...), + title: str = Form(...), + description: str = Form(...), + thumbnail_url: str = Form(...), + api_key: str = Form(...), + user_id: str = Form(...), +): + """Kick off a background upload to YouTube via Upload-Post and return immediately.""" + if session_id not in thumbnail_sessions: + raise HTTPException(status_code=404, detail="Session not found") + + session = thumbnail_sessions[session_id] + video_path = session.get("video_path") + if not video_path or not os.path.exists(video_path): + raise HTTPException(status_code=404, detail="Original video file not found") + + # Resolve thumbnail path from URL + thumb_relative = thumbnail_url.lstrip("/") + if thumb_relative.startswith("thumbnails/"): + thumb_path = os.path.join(OUTPUT_DIR, thumb_relative) + else: + thumb_path = os.path.join(THUMBNAILS_DIR, thumb_relative) + + if not os.path.exists(thumb_path): + raise HTTPException(status_code=404, detail=f"Thumbnail file not found: {thumb_path}") + + # Generate a unique ID for this publish job so the frontend can poll + publish_id = str(uuid.uuid4()) + publish_jobs[publish_id] = {"status": "uploading", "result": None, "error": None} + + def do_upload(): + """Runs in a thread via BackgroundTasks — does the actual multipart upload.""" + try: + upload_url = "https://api.upload-post.com/api/upload" + headers = {"Authorization": f"Apikey {api_key}"} + data_payload = { + "user": user_id, + "platform[]": ["youtube"], + "title": title, # required base field (fallback) + "async_upload": "true", + "youtube_title": title, + "youtube_description": description, + "privacyStatus": "public", + } + video_filename = os.path.basename(video_path) + thumb_filename = os.path.basename(thumb_path) + + print(f"📡 [Thumbnail] Publishing to YouTube via Upload-Post... (publish_id={publish_id})") + with open(video_path, "rb") as vf, open(thumb_path, "rb") as tf: + files = { + "video": (video_filename, vf.read(), "video/mp4"), + "thumbnail": (thumb_filename, tf.read(), "image/jpeg"), + } + + # Use a long timeout — video uploads can take several minutes + with httpx.Client(timeout=600.0) as client: + response = client.post(upload_url, headers=headers, data=data_payload, files=files) + + if response.status_code not in [200, 201, 202]: + err = f"Upload-Post API Error ({response.status_code}): {response.text}" + print(f"❌ {err}") + publish_jobs[publish_id]["status"] = "failed" + publish_jobs[publish_id]["error"] = err + else: + print(f"✅ [Thumbnail] Published successfully (publish_id={publish_id})") + publish_jobs[publish_id]["status"] = "done" + publish_jobs[publish_id]["result"] = response.json() + + except Exception as e: + err = str(e) + print(f"❌ Thumbnail Publish Background Error: {err}") + publish_jobs[publish_id]["status"] = "failed" + publish_jobs[publish_id]["error"] = err + + background_tasks.add_task(do_upload) + return {"publish_id": publish_id, "status": "uploading"} + + +@app.get("/api/thumbnail/publish/status/{publish_id}") +async def thumbnail_publish_status(publish_id: str): + """Poll the status of a background publish job.""" + if publish_id not in publish_jobs: + raise HTTPException(status_code=404, detail="Publish job not found") + return publish_jobs[publish_id] + + +# @app.get("/api/gallery/clips") +# async def get_gallery_clips(limit: int = 20, offset: int = 0, refresh: bool = False): +# """ +# Fetch clips from S3 for the gallery with pagination. +# +# Args: +# limit: Number of clips to return (default 20, max 100) +# offset: Starting position for pagination +# refresh: Force refresh cache +# """ +# try: +# # Clamp limit to reasonable values +# limit = min(max(1, limit), 100) +# +# # Get clips (uses cache internally) +# all_clips = list_all_clips(limit=limit + offset, force_refresh=refresh) +# +# # Apply offset for pagination +# clips = all_clips[offset:offset + limit] +# +# return { +# "clips": clips, +# "total": len(all_clips), +# "limit": limit, +# "offset": offset, +# "has_more": len(all_clips) > offset + limit +# } +# except Exception as e: +# print(f"❌ Gallery Error: {e}") +# raise HTTPException(status_code=500, detail=str(e)) + + +# ═══════════════════════════════════════════════════════════════════════ +# SaaSShorts: AI UGC Video Generator for SaaS Products +# ═══════════════════════════════════════════════════════════════════════ + +from saasshorts import ( + scrape_website, + research_saas_online, + analyze_saas, + generate_scripts, + generate_full_video, + generate_actor_images, + get_elevenlabs_voices, + DEFAULT_VOICES, +) + +# State for SaaSShorts jobs (separate from video processing jobs) +saas_jobs: Dict[str, Dict] = {} + + +class SaaSAnalyzeRequest(BaseModel): + url: Optional[str] = None + description: Optional[str] = None # Manual product/business description + num_scripts: int = 3 + style: str = "ugc" + language: str = "en" + actor_gender: str = "female" + + +@app.post("/api/saasshorts/analyze") +async def saasshorts_analyze( + req: SaaSAnalyzeRequest, + x_gemini_key: Optional[str] = Header(None, alias="X-Gemini-Key"), +): + """Analyze a URL or manual description and generate video scripts.""" + gemini_key = x_gemini_key or os.environ.get("GEMINI_API_KEY") + if not gemini_key: + raise HTTPException(status_code=400, detail="Missing Gemini API Key") + + if not req.url and not req.description: + raise HTTPException(status_code=400, detail="Provide a URL or a product description") + + try: + loop = asyncio.get_event_loop() + + def run_analysis(): + web_research = None + + if req.url and req.url.strip(): + # URL provided: full scrape + research pipeline + scraped = scrape_website(req.url) + web_research = research_saas_online(req.url, gemini_key) + analysis = analyze_saas(scraped, gemini_key, web_research=web_research) + else: + # Manual description: build analysis from description + analysis = { + "product_name": req.description.split(",")[0].strip()[:60] if req.description else "Product", + "description": req.description, + "value_proposition": req.description, + "target_audience": "general audience", + "key_features": [req.description], + "pain_points": [], + "tone": "casual and authentic", + } + + scripts = generate_scripts(analysis, gemini_key, req.num_scripts, req.style, req.language, req.actor_gender) + return { + "analysis": analysis, + "scripts": scripts, + "web_research": web_research, + } + + result = await loop.run_in_executor(None, run_analysis) + return result + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class SaaSActorRequest(BaseModel): + actor_description: str + num_options: int = 3 + product_description: Optional[str] = None + + +@app.post("/api/saasshorts/actor-upload") +async def saasshorts_actor_upload(file: UploadFile = File(...)): + """Upload a custom actor image (stored locally only, not S3).""" + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + try: + content = await file.read() + + # Validate minimum size + if len(content) < 1000: + raise HTTPException(status_code=400, detail="File too small to be a valid image") + + upload_id = uuid.uuid4().hex[:8] + upload_dir = os.path.join(OUTPUT_DIR, "actor_uploads") + os.makedirs(upload_dir, exist_ok=True) + filename = f"custom_{upload_id}.png" + file_path = os.path.join(upload_dir, filename) + + with open(file_path, "wb") as f: + f.write(content) + + return {"url": f"/videos/actor_uploads/{filename}"} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/saasshorts/actor-options") +async def saasshorts_actor_options( + req: SaaSActorRequest, + x_fal_key: Optional[str] = Header(None, alias="X-Fal-Key"), +): + """Generate multiple actor image options for the user to choose from.""" + fal_key = x_fal_key + if not fal_key: + raise HTTPException(status_code=400, detail="Missing fal.ai API Key") + + try: + job_id = str(uuid.uuid4()) + out_dir = os.path.join(OUTPUT_DIR, f"saas_actors_{job_id}") + os.makedirs(out_dir, exist_ok=True) + + loop = asyncio.get_running_loop() + import functools + paths = await loop.run_in_executor( + None, + functools.partial( + generate_actor_images, + req.actor_description, fal_key, out_dir, "actor", req.num_options, + product_description=req.product_description, + ), + ) + + # Upload each actor image to public S3 with description + desc = req.actor_description + if req.product_description: + desc += f" (holding {req.product_description})" + urls = [] + for p in paths: + s3_url = upload_actor_to_s3(p, description=desc) + if s3_url: + urls.append(s3_url) + else: + # Fallback to local URL if S3 fails + urls.append(f"/videos/saas_actors_{job_id}/{os.path.basename(p)}") + + return {"images": urls} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/saasshorts/gallery") +async def saasshorts_video_gallery(limit: int = 50): + """List all UGC videos from the public gallery.""" + try: + loop = asyncio.get_running_loop() + videos = await loop.run_in_executor(None, list_video_gallery, limit) + return {"videos": videos, "total": len(videos)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class SaaSPostRequest(BaseModel): + job_id: str + api_key: str + user_id: str + platforms: List[str] + title: Optional[str] = None + description: Optional[str] = None + scheduled_date: Optional[str] = None + timezone: Optional[str] = "UTC" + + +@app.post("/api/saasshorts/post") +async def saasshorts_post_to_socials(req: SaaSPostRequest): + """Post an AI Shorts video to social media via Upload-Post.""" + if req.job_id not in saas_jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = saas_jobs[req.job_id] + result = job.get("result") + if not result or not result.get("video_url"): + raise HTTPException(status_code=400, detail="No video available for this job") + + try: + # Resolve video file path + video_url = result["video_url"] # e.g. /videos/saas_xxx/slug_final.mp4 + rel_path = video_url.replace("/videos/", "") + file_path = os.path.join(OUTPUT_DIR, rel_path) + + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail=f"Video file not found") + + script = result.get("script", {}) + final_title = req.title or script.get("title", "AI Short") + final_description = req.description or script.get("caption", "") + if not final_description: + final_description = script.get("full_narration", "Check this out!") + + url = "https://api.upload-post.com/api/upload" + headers = {"Authorization": f"Apikey {req.api_key}"} + + data_payload = { + "user": req.user_id, + "title": final_title, + "platform[]": req.platforms, + "async_upload": "true", + } + + if req.scheduled_date: + data_payload["scheduled_date"] = req.scheduled_date + if req.timezone: + data_payload["timezone"] = req.timezone + + if "tiktok" in req.platforms: + data_payload["tiktok_title"] = final_description + if "instagram" in req.platforms: + data_payload["instagram_title"] = final_description + data_payload["media_type"] = "REELS" + if "youtube" in req.platforms: + data_payload["youtube_title"] = final_title + data_payload["youtube_description"] = final_description + data_payload["privacyStatus"] = "public" + + filename = os.path.basename(file_path) + with open(file_path, "rb") as f: + file_content = f.read() + + files = {"video": (filename, file_content, "video/mp4")} + + with httpx.Client(timeout=120.0) as client: + print(f"📡 [AI Shorts] Sending to Upload-Post: {req.platforms}") + response = client.post(url, headers=headers, data=data_payload, files=files) + + if response.status_code not in [200, 201, 202]: + raise HTTPException(status_code=response.status_code, detail=f"Upload-Post Error: {response.text}") + + return response.json() + + except HTTPException: + raise + except Exception as e: + print(f"❌ [AI Shorts] Post Exception: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/gallery", response_class=HTMLResponse) +async def gallery_html_page(): + """SEO gallery page with all generated UGC videos.""" + import html as html_mod + loop = asyncio.get_running_loop() + videos = await loop.run_in_executor(None, list_video_gallery, 100) + + cards_html = "" + ld_items = [] + for i, v in enumerate(videos): + title = html_mod.escape(v.get("title", "Untitled")) + video_url = v.get("video_url", "") + actor_url = v.get("actor_url", "") + video_id = v.get("video_id", "") + duration = v.get("duration", 0) + mode = v.get("video_mode", "") + product = html_mod.escape(v.get("product_name", "")) + caption = html_mod.escape(v.get("caption", "")[:120]) + + mode_badge = 'LOW COST' if mode == "lowcost" else 'PREMIUM' + + cards_html += f''' + +
+
+ +
{mode_badge}
+
+
+

{title}

+

{duration:.0f}s · {product}

+
+
+
''' + + ld_items.append(f'{{"@type":"ListItem","position":{i+1},"url":"https://openshorts.app/video/{video_id}","name":"{title}"}}') + + ld_json = f'{{"@context":"https://schema.org","@type":"CollectionPage","name":"AI UGC Video Gallery","mainEntity":{{"@type":"ItemList","numberOfItems":{len(videos)},"itemListElement":[{",".join(ld_items)}]}}}}' + + return f''' + + + +AI UGC Video Gallery | OpenShorts + + + + + + + + + + +

AI-Generated UGC Videos

+

{len(videos)} videos generated · Low Cost & Premium modes

+
{cards_html}
+ +''' + + +@app.get("/video/{video_id}", response_class=HTMLResponse) +async def video_html_page(video_id: str): + """SEO individual video page with og:video meta tags.""" + import html as html_mod + loop = asyncio.get_running_loop() + videos = await loop.run_in_executor(None, list_video_gallery, 200) + meta = next((v for v in videos if v.get("video_id") == video_id), None) + if not meta: + raise HTTPException(status_code=404, detail="Video not found") + + title = html_mod.escape(meta.get("title", "Untitled")) + caption = html_mod.escape(meta.get("caption", "")) + narration = html_mod.escape(meta.get("full_narration", "")) + video_url = meta.get("video_url", "") + actor_url = meta.get("actor_url", "") + duration = meta.get("duration", 0) + mode = meta.get("video_mode", "") + product = html_mod.escape(meta.get("product_name", "")) + product_url = html_mod.escape(meta.get("product_url", "")) + language = meta.get("language", "en") + hashtags = " ".join(meta.get("hashtags", [])) + cost = meta.get("cost_estimate", {}).get("total", 0) + created = meta.get("created_at", "") + actor_desc = html_mod.escape(meta.get("actor_description", "")) + + ld_json = f'{{"@context":"https://schema.org","@type":"VideoObject","name":"{title}","description":"{caption}","thumbnailUrl":"{actor_url}","contentUrl":"{video_url}","uploadDate":"{created}","duration":"PT{int(duration)}S","width":1080,"height":1920,"inLanguage":"{language}"}}' + + mode_label = "Low Cost" if mode == "lowcost" else "Premium" + + return f''' + + + +{title} - AI UGC Video | OpenShorts + + + + + + + + + + + + + + + + + +
+
+
+

{title}

+

{duration:.0f}s · {mode_label} · ${cost:.2f} · {product}

+

Caption

{caption}

{hashtags}

+

Script

{narration}

+

Actor

{actor_desc}

+{f'

Product

{product}

' if product_url else ''} +← Back to Gallery +
Create Your Own +
+
+''' + + +@app.get("/api/saasshorts/actor-gallery") +async def saasshorts_actor_gallery(): + """List all previously generated actor images from public S3.""" + try: + loop = asyncio.get_running_loop() + images = await loop.run_in_executor(None, list_actor_gallery) + return {"images": images} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class SaaSGenerateRequest(BaseModel): + script: dict + voice_id: Optional[str] = None + actor_description: Optional[str] = None + selected_actor_url: Optional[str] = None # Pre-selected actor image URL + retry_job_id: Optional[str] = None + video_mode: str = "lowcost" # "lowcost" or "premium" + + +@app.post("/api/saasshorts/generate") +async def saasshorts_generate( + req: SaaSGenerateRequest, + x_fal_key: Optional[str] = Header(None, alias="X-Fal-Key"), + x_elevenlabs_key: Optional[str] = Header(None, alias="X-ElevenLabs-Key"), +): + """Generate a SaaS UGC video from a script. Returns a job_id for polling.""" + fal_key = x_fal_key + elevenlabs_key = x_elevenlabs_key + + if not fal_key: + raise HTTPException(status_code=400, detail="Missing fal.ai API Key (X-Fal-Key header)") + if not elevenlabs_key: + raise HTTPException(status_code=400, detail="Missing ElevenLabs API Key (X-ElevenLabs-Key header)") + + # Support retry: reuse output_dir so cached assets (image, voice, head, broll) are kept + reused = False + if req.retry_job_id: + # Check memory first, then disk + old_dir = os.path.join(OUTPUT_DIR, f"saas_{req.retry_job_id}") + if req.retry_job_id in saas_jobs: + old_dir = saas_jobs[req.retry_job_id]["output_dir"] + + if os.path.isdir(old_dir): + job_id = req.retry_job_id + job_output_dir = old_dir + reused = True + # Clear the 0-byte final video so pipeline re-generates it + for f in os.listdir(old_dir): + fp = os.path.join(old_dir, f) + if f.endswith("_final.mp4") and os.path.getsize(fp) == 0: + os.remove(fp) + saas_jobs[job_id] = { + "status": "processing", + "logs": [f"Retrying job {job_id[:8]}... reusing cached assets from disk."], + "result": None, + "output_dir": job_output_dir, + } + + if not reused: + job_id = str(uuid.uuid4()) + job_output_dir = os.path.join(OUTPUT_DIR, f"saas_{job_id}") + os.makedirs(job_output_dir, exist_ok=True) + saas_jobs[job_id] = { + "status": "processing", + "logs": ["SaaSShorts job started."], + "result": None, + "output_dir": job_output_dir, + } + + # If user selected a pre-generated actor, resolve it to a local path + selected_actor_path = None + if req.selected_actor_url: + if req.selected_actor_url.startswith("http"): + # Download from S3 public URL to job output dir + import httpx + try: + actor_local = os.path.join(job_output_dir, "selected_actor.png") + with httpx.Client(timeout=30.0) as client: + resp = client.get(req.selected_actor_url) + if resp.status_code == 200: + with open(actor_local, "wb") as f: + f.write(resp.content) + selected_actor_path = actor_local + except Exception: + pass + else: + src = os.path.join(OUTPUT_DIR, req.selected_actor_url.replace("/videos/", "")) + if os.path.exists(src): + selected_actor_path = src + + config = { + "fal_key": fal_key, + "elevenlabs_key": elevenlabs_key, + "voice_id": req.voice_id or "21m00Tcm4TlvDq8ikWAM", + "actor_description": req.actor_description, + "selected_actor_path": selected_actor_path, + "video_mode": req.video_mode, + } + + async def run_generation(): + await concurrency_semaphore.acquire() + try: + loop = asyncio.get_running_loop() + + def log_msg(msg): + print(f"[SaaSShorts Job {job_id[:8]}] {msg}") + if job_id in saas_jobs: + saas_jobs[job_id]["logs"].append(msg) + + def run(): + return generate_full_video(req.script, config, job_output_dir, log_msg) + + result = await loop.run_in_executor(None, run) + + if job_id in saas_jobs: + video_filename = result["video_filename"] + saas_jobs[job_id]["status"] = "completed" + saas_jobs[job_id]["result"] = { + "video_url": f"/videos/saas_{job_id}/{video_filename}", + "video_filename": video_filename, + "duration": result.get("duration", 0), + "cost_estimate": result.get("cost_estimate", {}), + "script": req.script, + } + saas_jobs[job_id]["logs"].append("Video generation completed!") + + # Upload to public gallery (non-blocking) + try: + gallery_meta = { + "title": req.script.get("title", "Untitled"), + "hook_text": req.script.get("hook_text", ""), + "caption": req.script.get("caption", ""), + "hashtags": req.script.get("hashtags", []), + "full_narration": req.script.get("full_narration", ""), + "actor_description": req.script.get("actor_description", ""), + "style": req.script.get("style", "ugc"), + "language": req.script.get("language", "en"), + "duration": result.get("duration", 0), + "video_mode": req.video_mode, + "product_name": req.script.get("_product_name", ""), + "product_url": req.script.get("_product_url", ""), + "segments": req.script.get("segments", []), + "cost_estimate": result.get("cost_estimate", {}), + } + gallery_result = upload_video_to_gallery( + video_path=result["video_path"], + actor_image_path=result.get("actor_image", ""), + metadata=gallery_meta, + video_id=job_id[:8], + ) + if gallery_result: + saas_jobs[job_id]["result"]["gallery_video_id"] = gallery_result["video_id"] + log_msg("📤 Uploaded to public gallery.") + except Exception as gallery_err: + log_msg(f"⚠️ Gallery upload skipped: {gallery_err}") + + except Exception as e: + print(f"[SaaSShorts] ❌ Job {job_id} failed: {e}") + if job_id in saas_jobs: + saas_jobs[job_id]["status"] = "failed" + saas_jobs[job_id]["logs"].append(f"Error: {str(e)}") + finally: + concurrency_semaphore.release() + + asyncio.create_task(run_generation()) + + return {"job_id": job_id, "status": "processing"} + + +@app.get("/api/saasshorts/status/{job_id}") +async def saasshorts_status(job_id: str): + """Poll SaaSShorts job status.""" + if job_id not in saas_jobs: + raise HTTPException(status_code=404, detail="SaaSShorts job not found") + + job = saas_jobs[job_id] + return { + "status": job["status"], + "logs": job["logs"], + "result": job.get("result"), + } + + +@app.get("/api/saasshorts/voices") +async def saasshorts_voices( + x_elevenlabs_key: Optional[str] = Header(None, alias="X-ElevenLabs-Key"), +): + """List available ElevenLabs voices.""" + if x_elevenlabs_key: + try: + loop = asyncio.get_event_loop() + voices = await loop.run_in_executor( + None, get_elevenlabs_voices, x_elevenlabs_key + ) + if voices: + return {"voices": voices, "source": "elevenlabs"} + except Exception: + pass + + # Fallback to default voices + return { + "voices": [ + {"voice_id": vid, "name": name, "category": "default"} + for name, vid in DEFAULT_VOICES.items() + ], + "source": "defaults", + } diff --git a/openshorts/churchil_queen_vertical.gif b/openshorts/churchil_queen_vertical.gif new file mode 100644 index 0000000..3ca18ce Binary files /dev/null and b/openshorts/churchil_queen_vertical.gif differ diff --git a/openshorts/churchil_queen_vertical_short.gif b/openshorts/churchil_queen_vertical_short.gif new file mode 100644 index 0000000..a686198 Binary files /dev/null and b/openshorts/churchil_queen_vertical_short.gif differ diff --git a/openshorts/dashboard/.gitignore b/openshorts/dashboard/.gitignore new file mode 100644 index 0000000..7af0368 --- /dev/null +++ b/openshorts/dashboard/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.config +.config/* \ No newline at end of file diff --git a/openshorts/dashboard/Dockerfile b/openshorts/dashboard/Dockerfile new file mode 100644 index 0000000..b807cb6 --- /dev/null +++ b/openshorts/dashboard/Dockerfile @@ -0,0 +1,13 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ + +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/openshorts/dashboard/README.md b/openshorts/dashboard/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/openshorts/dashboard/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/openshorts/dashboard/eslint.config.js b/openshorts/dashboard/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/openshorts/dashboard/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/openshorts/dashboard/index.html b/openshorts/dashboard/index.html new file mode 100644 index 0000000..91f7d7e --- /dev/null +++ b/openshorts/dashboard/index.html @@ -0,0 +1,194 @@ + + + + + + + + + + OpenShorts - Free Open Source Clip Generator & AI UGC Video Creator | TikTok, Reels & Shorts + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/openshorts/dashboard/package-lock.json b/openshorts/dashboard/package-lock.json new file mode 100644 index 0000000..a25c443 --- /dev/null +++ b/openshorts/dashboard/package-lock.json @@ -0,0 +1,8362 @@ +{ + "name": "openshorts-app", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "openshorts-app", + "version": "0.0.0", + "dependencies": { + "@remotion/media": "^4.0.447", + "@remotion/media-utils": "^4.0.447", + "@remotion/player": "^4.0.447", + "@remotion/web-renderer": "^4.0.447", + "lucide-react": "^0.344.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "remotion": "^4.0.447", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.23", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "vite": "^4.5.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.39.2.tgz", + "integrity": "sha512-KD6KADVzAnW7tqhRFGBOX4uaiHbd0Yxvg0lfthj3wJLAEEgEBAvi43w+ZXWeEn54X/jpabrLe4bW/eYFFvlbUA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.39.2.tgz", + "integrity": "sha512-VwBr3AzZTPEEPvt4aladZiXwOf3W293eq213zDupGQi/taS8WWNqDd3eBdf8FfvlbXATfbRiycXDKyQ0HlOZaQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.39.2.tgz", + "integrity": "sha512-3rrodrGnUpUP8F2d1aRUl8IvjqK3jegkupbOzvOokooSAO5rXk2Lr5jZe7TnPeiVGiXfmnoJ7s9uyUOHlCd8qw==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remotion/licensing": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.447.tgz", + "integrity": "sha512-/pqArZCErkX0jVroK6tvTLuWpgFGiu/z5nTyCkaQogg6xvQKi7QQBXBb8mUJ/BvUeiWOUX/eVJ2khDGhSzQpUQ==", + "license": "MIT" + }, + "node_modules/@remotion/media": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/media/-/media-4.0.447.tgz", + "integrity": "sha512-WWwOXAToVjwNxh9SfbBpdOweqbpOXLNoC7AaGKEv+AAH1RxVfNhc8ZzfFUgCtD7aBH4HHjva/Beca0C6LLVwKA==", + "dependencies": { + "mediabunny": "1.39.2", + "remotion": "4.0.447", + "zod": "4.3.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.447.tgz", + "integrity": "sha512-PltvSZsY+By8DlsZeAN63D1cMsgXewCqEq39nJrO0VZVc5nnWyd+nvMZz9yhGsoMo2Z5xNkfZhPkl+Gs25wn0Q==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.39.2", + "remotion": "4.0.447" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.447.tgz", + "integrity": "sha512-xKpdzlE8Gyceir/irJ2qsBK3hKJfILSTxQAQUtDWykehhC/OpizS9yl2//ZWghid9nNAL49QkbFcsqdcgcMocQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.447" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.447.tgz", + "integrity": "sha512-gzLLbglpu2DyH/WUywi599QICkcFjSf8VwANsLIUL/tUlr8RHjiJWr6XBgWYHHBtAXipRlmW7hRvP16j3wICiQ==", + "license": "UNLICENSED", + "dependencies": { + "@mediabunny/aac-encoder": "1.39.2", + "@mediabunny/flac-encoder": "1.39.2", + "@mediabunny/mp3-encoder": "1.39.2", + "@remotion/licensing": "4.0.447", + "mediabunny": "1.39.2", + "remotion": "4.0.447" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", + "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mediabunny": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz", + "integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==", + "license": "MPL-2.0", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remotion": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.447.tgz", + "integrity": "sha512-p2J+iGxmBL4TDUeAAlXrfxl9SfU/GRJEoVUpqJGYP8YJw/IKT2WbXngXHf4UWE0R2MHYAVtznuA640ktSSJwWw==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + }, + "dependencies": { + "@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true + }, + "@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true + }, + "@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "requires": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true + }, + "@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "requires": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + } + }, + "@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true + }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true + }, + "@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "requires": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + } + }, + "@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "requires": { + "@babel/types": "^7.28.5" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + } + }, + "@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + } + }, + "@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "dev": true, + "optional": true + }, + "@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + } + }, + "@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true + }, + "@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + } + }, + "@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true + }, + "@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@mediabunny/aac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.39.2.tgz", + "integrity": "sha512-KD6KADVzAnW7tqhRFGBOX4uaiHbd0Yxvg0lfthj3wJLAEEgEBAvi43w+ZXWeEn54X/jpabrLe4bW/eYFFvlbUA==", + "requires": {} + }, + "@mediabunny/flac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.39.2.tgz", + "integrity": "sha512-VwBr3AzZTPEEPvt4aladZiXwOf3W293eq213zDupGQi/taS8WWNqDd3eBdf8FfvlbXATfbRiycXDKyQ0HlOZaQ==", + "requires": {} + }, + "@mediabunny/mp3-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.39.2.tgz", + "integrity": "sha512-3rrodrGnUpUP8F2d1aRUl8IvjqK3jegkupbOzvOokooSAO5rXk2Lr5jZe7TnPeiVGiXfmnoJ7s9uyUOHlCd8qw==", + "requires": {} + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@remotion/licensing": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.447.tgz", + "integrity": "sha512-/pqArZCErkX0jVroK6tvTLuWpgFGiu/z5nTyCkaQogg6xvQKi7QQBXBb8mUJ/BvUeiWOUX/eVJ2khDGhSzQpUQ==" + }, + "@remotion/media": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/media/-/media-4.0.447.tgz", + "integrity": "sha512-WWwOXAToVjwNxh9SfbBpdOweqbpOXLNoC7AaGKEv+AAH1RxVfNhc8ZzfFUgCtD7aBH4HHjva/Beca0C6LLVwKA==", + "requires": { + "mediabunny": "1.39.2", + "remotion": "4.0.447", + "zod": "4.3.6" + } + }, + "@remotion/media-utils": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.447.tgz", + "integrity": "sha512-PltvSZsY+By8DlsZeAN63D1cMsgXewCqEq39nJrO0VZVc5nnWyd+nvMZz9yhGsoMo2Z5xNkfZhPkl+Gs25wn0Q==", + "requires": { + "mediabunny": "1.39.2", + "remotion": "4.0.447" + } + }, + "@remotion/player": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.447.tgz", + "integrity": "sha512-xKpdzlE8Gyceir/irJ2qsBK3hKJfILSTxQAQUtDWykehhC/OpizS9yl2//ZWghid9nNAL49QkbFcsqdcgcMocQ==", + "requires": { + "remotion": "4.0.447" + } + }, + "@remotion/web-renderer": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.447.tgz", + "integrity": "sha512-gzLLbglpu2DyH/WUywi599QICkcFjSf8VwANsLIUL/tUlr8RHjiJWr6XBgWYHHBtAXipRlmW7hRvP16j3wICiQ==", + "requires": { + "@mediabunny/aac-encoder": "1.39.2", + "@mediabunny/flac-encoder": "1.39.2", + "@mediabunny/mp3-encoder": "1.39.2", + "@remotion/licensing": "4.0.447", + "mediabunny": "1.39.2", + "remotion": "4.0.447" + } + }, + "@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "requires": { + "@babel/types": "^7.28.2" + } + }, + "@types/dom-mediacapture-transform": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", + "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", + "requires": { + "@types/dom-webcodecs": "*" + } + }, + "@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==" + }, + "@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "requires": {} + }, + "@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, + "@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "requires": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + } + }, + "acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + } + }, + "array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + } + }, + "array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + } + }, + "array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + } + }, + "array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + } + }, + "array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + } + }, + "arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + } + }, + "async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true + }, + "autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "requires": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + } + }, + "available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "requires": { + "possible-typed-array-names": "^1.0.0" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "requires": { + "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" + } + }, + "call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + } + }, + "data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + } + }, + "data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + } + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true + }, + "es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + } + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, + "es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + } + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "requires": { + "hasown": "^2.0.2" + } + }, + "es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "requires": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + } + }, + "esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "requires": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + } + }, + "eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "requires": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "dependencies": { + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + } + } + }, + "eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "requires": {} + }, + "eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "requires": {} + }, + "eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + }, + "espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "requires": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + } + }, + "esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "requires": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "requires": { + "is-callable": "^1.2.7" + } + }, + "fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + } + }, + "functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true + }, + "generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "requires": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + } + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true + }, + "graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0" + } + }, + "has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.0" + } + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, + "import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + } + }, + "is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + } + }, + "is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "requires": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + } + }, + "is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "requires": { + "has-bigints": "^1.0.2" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true + }, + "is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "requires": { + "hasown": "^2.0.2" + } + }, + "is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + } + }, + "is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "requires": { + "call-bound": "^1.0.3" + } + }, + "is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "requires": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + } + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + } + }, + "is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true + }, + "is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true + }, + "is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "requires": { + "call-bound": "^1.0.3" + } + }, + "is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + } + }, + "is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + } + }, + "is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "requires": { + "which-typed-array": "^1.1.16" + } + }, + "is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true + }, + "is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "requires": { + "call-bound": "^1.0.3" + } + }, + "is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + } + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + } + }, + "jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "requires": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + } + }, + "keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "requires": {} + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true + }, + "mediabunny": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz", + "integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==", + "requires": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "requires": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true + }, + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + } + }, + "object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + } + }, + "object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + } + }, + "object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + } + }, + "own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + }, + "pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true + }, + "possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true + }, + "postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "requires": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + } + } + }, + "postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "requires": { + "camelcase-css": "^2.0.1" + } + }, + "postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "requires": { + "lilconfig": "^3.1.1" + } + }, + "postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.1.1" + } + }, + "postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true + }, + "read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "requires": { + "pify": "^2.3.0" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + } + }, + "regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + } + }, + "remotion": { + "version": "4.0.447", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.447.tgz", + "integrity": "sha512-p2J+iGxmBL4TDUeAAlXrfxl9SfU/GRJEoVUpqJGYP8YJw/IKT2WbXngXHf4UWE0R2MHYAVtznuA640ktSSJwWw==", + "requires": {} + }, + "resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "requires": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rollup": { + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + } + }, + "safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + } + }, + "safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + } + }, + "scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + } + }, + "set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + } + }, + "set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + } + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + } + }, + "string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + } + }, + "string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + } + }, + "string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + } + }, + "string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "requires": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "dependencies": { + "resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "requires": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true + } + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, + "typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + } + }, + "typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + } + }, + "typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + } + }, + "typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + } + }, + "unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + } + }, + "update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "requires": { + "esbuild": "^0.18.10", + "fsevents": "~2.3.2", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "requires": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + } + }, + "which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + } + }, + "which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "requires": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + } + }, + "which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + } + }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + }, + "zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" + } + } +} diff --git a/openshorts/dashboard/package.json b/openshorts/dashboard/package.json new file mode 100644 index 0000000..0748bc8 --- /dev/null +++ b/openshorts/dashboard/package.json @@ -0,0 +1,36 @@ +{ + "name": "openshorts-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@remotion/media": "^4.0.447", + "@remotion/media-utils": "^4.0.447", + "@remotion/player": "^4.0.447", + "@remotion/web-renderer": "^4.0.447", + "lucide-react": "^0.344.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "remotion": "^4.0.447", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.23", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "vite": "^4.5.3" + } +} diff --git a/openshorts/dashboard/postcss.config.js b/openshorts/dashboard/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/openshorts/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/openshorts/dashboard/public/logo-openshorts.png b/openshorts/dashboard/public/logo-openshorts.png new file mode 100644 index 0000000..53df264 Binary files /dev/null and b/openshorts/dashboard/public/logo-openshorts.png differ diff --git a/openshorts/dashboard/public/og-image.png b/openshorts/dashboard/public/og-image.png new file mode 100644 index 0000000..40397b8 Binary files /dev/null and b/openshorts/dashboard/public/og-image.png differ diff --git a/openshorts/dashboard/public/robots.txt b/openshorts/dashboard/public/robots.txt new file mode 100644 index 0000000..2cd07bf --- /dev/null +++ b/openshorts/dashboard/public/robots.txt @@ -0,0 +1,33 @@ +# robots.txt for OpenShorts +# Allow all search engines and AI bots to crawl the site + +User-agent: * +Allow: / + +# Explicitly allow AI search engine bots +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: GPTBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: anthropic-ai +Allow: / + +User-agent: Google-Extended +Allow: / + +# Sitemap +Sitemap: https://openshorts.app/sitemap.xml diff --git a/openshorts/dashboard/public/sitemap.xml b/openshorts/dashboard/public/sitemap.xml new file mode 100644 index 0000000..038ca07 --- /dev/null +++ b/openshorts/dashboard/public/sitemap.xml @@ -0,0 +1,9 @@ + + + + https://openshorts.app/ + 2026-03-07 + weekly + 1.0 + + diff --git a/openshorts/dashboard/public/vite.svg b/openshorts/dashboard/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/openshorts/dashboard/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/openshorts/dashboard/src/App.css b/openshorts/dashboard/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/openshorts/dashboard/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/openshorts/dashboard/src/App.jsx b/openshorts/dashboard/src/App.jsx new file mode 100644 index 0000000..fb0e912 --- /dev/null +++ b/openshorts/dashboard/src/App.jsx @@ -0,0 +1,1113 @@ +import React, { useState, useEffect } from 'react'; +import { Upload, FileVideo, Sparkles, Youtube, Instagram, Share2, LogOut, ChevronDown, Check, Activity, LayoutDashboard, Settings, PlusCircle, History, Menu, X, Terminal, Shield, LayoutGrid, Image, Globe, RotateCcw, Calendar, AlertTriangle, KeyRound, Bot, Users, Smartphone, ExternalLink, Copy, CheckCircle2 } from 'lucide-react'; +import KeyInput from './components/KeyInput'; +import MediaInput from './components/MediaInput'; +import ResultCard from './components/ResultCard'; +import ProcessingAnimation from './components/ProcessingAnimation'; +// import Gallery from './components/Gallery'; +import ThumbnailStudio from './components/ThumbnailStudio'; +import SaaShortsTab from './components/SaaShortsTab'; +import UGCGallery from './components/UGCGallery'; +import ScheduleWeekModal from './components/ScheduleWeekModal'; +import { getApiUrl } from './config'; + +// Enhanced "Encryption" using XOR + Base64 with a Salt +// This is better than plain Base64 but still client-side. +const SECRET_KEY = import.meta.env.VITE_ENCRYPTION_KEY || "OpenShorts-Static-Salt-Change-Me"; +const ENCRYPTION_PREFIX = "ENC:"; + +const encrypt = (text) => { + if (!text) return ''; + try { + const xor = text.split('').map((c, i) => + String.fromCharCode(c.charCodeAt(0) ^ SECRET_KEY.charCodeAt(i % SECRET_KEY.length)) + ).join(''); + return ENCRYPTION_PREFIX + btoa(xor); + } catch (e) { + console.error("Encryption failed", e); + return text; + } +}; + +const decrypt = (text) => { + if (!text) return ''; + if (text.startsWith(ENCRYPTION_PREFIX)) { + try { + const raw = text.slice(ENCRYPTION_PREFIX.length); + // Check if it's plain base64 or our custom XOR (simple try) + const xor = atob(raw); + const result = xor.split('').map((c, i) => + String.fromCharCode(c.charCodeAt(0) ^ SECRET_KEY.charCodeAt(i % SECRET_KEY.length)) + ).join(''); + return result; + } catch (e) { + // Fallback if decryption fails (might be old plain text) + return ''; + } + } + // Backward compatibility: If no prefix, assume old plain text (or return empty if you want to force re-login) + // For migration: Return text as is, so it populates the field, and next save will encrypt it. + return text; +}; + +// Simple TikTok icon sine Lucide might not have it or it varies +const TikTokIcon = ({ size = 16, className = "" }) => ( + + + +); + +const UserProfileSelector = ({ profiles, selectedUserId, onSelect }) => { + const [isOpen, setIsOpen] = useState(false); + + if (!profiles || profiles.length === 0) return null; + + const selectedProfile = profiles.find(p => p.username === selectedUserId) || profiles[0]; + + return ( +
+ + + {isOpen && ( +
+
+ {profiles.map((profile) => ( + + ))} +
+
+ )} +
+ ); +}; + +const SESSION_KEY = 'openshorts_session'; +const SESSION_MAX_AGE = 3600000; // 1 hour (matches server job retention) + +// Mock polling function +const pollJob = async (jobId) => { + const res = await fetch(getApiUrl(`/api/status/${jobId}`)); + if (!res.ok) throw new Error('Status check failed'); + return res.json(); +}; + +function App() { + const [apiKey, setApiKey] = useState(localStorage.getItem('gemini_key') || ''); + // Social API State - Load encrypted or plain + const [uploadPostKey, setUploadPostKey] = useState(() => { + const stored = localStorage.getItem('uploadPostKey_v3'); + if (stored) return decrypt(stored); + return ''; + }); + // ElevenLabs API State - Load encrypted + const [elevenLabsKey, setElevenLabsKey] = useState(() => { + const stored = localStorage.getItem('elevenLabsKey_v1'); + if (stored) return decrypt(stored); + return ''; + }); + + // fal.ai API State - Load encrypted + const [falKey, setFalKey] = useState(() => { + const stored = localStorage.getItem('falKey_v1'); + if (stored) return decrypt(stored); + return ''; + }); + + const [uploadUserId, setUploadUserId] = useState(() => localStorage.getItem('uploadUserId') || ''); + const [userProfiles, setUserProfiles] = useState([]); // List of {username, connected: []} + const [showKeyModal, setShowKeyModal] = useState(false); + const [jobId, setJobId] = useState(null); + const [status, setStatus] = useState('idle'); // idle, processing, complete, error + const [results, setResults] = useState(null); + const [logs, setLogs] = useState([]); + const [logsVisible, setLogsVisible] = useState(true); + const [processingMedia, setProcessingMedia] = useState(null); + const [activeTab, setActiveTab] = useState('dashboard'); // dashboard, settings + + const [sessionRecovered, setSessionRecovered] = useState(false); + const [showScheduleWeek, setShowScheduleWeek] = useState(false); + + // Sync state for original video playback + const [syncedTime, setSyncedTime] = useState(0); + const [isSyncedPlaying, setIsSyncedPlaying] = useState(false); + const [syncTrigger, setSyncTrigger] = useState(0); + + const handleClipPlay = (startTime) => { + setSyncedTime(startTime); + setIsSyncedPlaying(true); + setSyncTrigger(prev => prev + 1); + }; + + const handleClipPause = () => { + setIsSyncedPlaying(false); + }; + + // Session Recovery: Restore on mount + useEffect(() => { + try { + const saved = localStorage.getItem(SESSION_KEY); + if (!saved) return; + const session = JSON.parse(saved); + if (Date.now() - session.timestamp > SESSION_MAX_AGE) { + localStorage.removeItem(SESSION_KEY); + return; + } + if (session.jobId && session.status && session.status !== 'idle') { + setJobId(session.jobId); + setResults(session.results || null); + if (session.processingMedia) setProcessingMedia(session.processingMedia); + if (session.activeTab) setActiveTab(session.activeTab); + // If was processing, resume polling; if complete/error, just show results + setStatus(session.status === 'processing' ? 'processing' : session.status); + setSessionRecovered(true); + setTimeout(() => setSessionRecovered(false), 5000); + } + } catch (e) { + localStorage.removeItem(SESSION_KEY); + } + }, []); + + // Session Recovery: Save state changes + useEffect(() => { + if (status === 'idle') { + localStorage.removeItem(SESSION_KEY); + return; + } + try { + const sessionData = { + jobId, + status, + results, + processingMedia: processingMedia?.type === 'url' ? processingMedia : null, + activeTab, + timestamp: Date.now() + }; + localStorage.setItem(SESSION_KEY, JSON.stringify(sessionData)); + } catch (e) { + // localStorage full or serialization error - ignore + } + }, [jobId, status, results, activeTab]); + + useEffect(() => { + // Encrypt Gemini Key too for consistency if desired, but user asked specifically about Social integration not saving well. + // For now keeping gemini plain for compatibility unless requested. + if (apiKey) localStorage.setItem('gemini_key', apiKey); + }, [apiKey]); + + useEffect(() => { + if (uploadPostKey) { + localStorage.setItem('uploadPostKey_v3', encrypt(uploadPostKey)); + } + if (uploadUserId) { + localStorage.setItem('uploadUserId', uploadUserId); + } + }, [uploadPostKey, uploadUserId]); + + useEffect(() => { + if (elevenLabsKey) { + localStorage.setItem('elevenLabsKey_v1', encrypt(elevenLabsKey)); + } + }, [elevenLabsKey]); + + useEffect(() => { + if (falKey) { + localStorage.setItem('falKey_v1', encrypt(falKey)); + } + }, [falKey]); + + useEffect(() => { + if (uploadPostKey && userProfiles.length === 0) { + fetchUserProfiles(); + } + }, [uploadPostKey]); + + useEffect(() => { + let interval; + if ((status === 'processing' || status === 'completed') && jobId) { + interval = setInterval(async () => { + try { + const data = await pollJob(jobId); + console.log("Job status:", data); + + // Update results if available (real-time) + if (data.result) { + setResults(data.result); + } + + if (data.status === 'completed') { + setStatus('complete'); + clearInterval(interval); + } else if (data.status === 'failed') { + setStatus('error'); + const errorMsg = data.error || (data.logs && data.logs.length > 0 ? data.logs[data.logs.length - 1] : "Process failed"); + setLogs(prev => [...prev, "Error: " + errorMsg]); + clearInterval(interval); + } else { + // Update logs if available + if (data.logs) setLogs(data.logs); + } + } catch (e) { + console.error("Polling error", e); + } + }, 2000); + } + return () => clearInterval(interval); + }, [status, jobId]); + + + const fetchUserProfiles = async () => { + if (!uploadPostKey) return; + try { + const res = await fetch(getApiUrl('/api/social/user'), { + headers: { 'X-Upload-Post-Key': uploadPostKey } + }); + if (!res.ok) throw new Error("Failed to fetch"); + const data = await res.json(); + if (data.profiles && data.profiles.length > 0) { + setUserProfiles(data.profiles); + // Auto select first if none selected + if (!uploadUserId) { + setUploadUserId(data.profiles[0].username); + } + } else { + alert("No profiles found for this API Key."); + } + } catch (e) { + alert("Error fetching User Profiles. Please check key."); + console.error(e); + } + }; + + const handleProcess = async (data) => { + if (!apiKey || !uploadPostKey) { + setShowKeyModal(true); + return; + } + setStatus('processing'); + setLogs(["Starting process..."]); + setResults(null); + setProcessingMedia(data); + + try { + let body; + const headers = { 'X-Gemini-Key': apiKey }; + + if (data.type === 'url') { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify({ url: data.payload, acknowledged: !!data.acknowledged }); + } else { + const formData = new FormData(); + formData.append('file', data.payload); + formData.append('acknowledged', data.acknowledged ? 'true' : 'false'); + body = formData; + } + + const res = await fetch(getApiUrl('/api/process'), { + method: 'POST', + headers: data.type === 'url' ? headers : { 'X-Gemini-Key': apiKey }, + body + }); + + if (!res.ok) throw new Error(await res.text()); + const resData = await res.json(); + setJobId(resData.job_id); + + } catch (e) { + setStatus('error'); + setLogs(l => [...l, `Error starting job: ${e.message}`]); + } + }; + + const handleReset = () => { + setStatus('idle'); + setJobId(null); + setResults(null); + setLogs([]); + setProcessingMedia(null); + localStorage.removeItem(SESSION_KEY); + }; + + // --- UI Components --- + + const Sidebar = () => ( +
+
+
+ Logo +
+ OpenShorts +
+ + + + +
+ ); + + return ( +
+ + +
+ {/* Background Gradients */} +
+
+
+ + {/* Top Header */} +
+
+ {status !== 'idle' && ( + + )} +
+ +
+ {userProfiles.length > 0 && ( + + )} + + {(!apiKey || !uploadPostKey) && ( + + )} +
+
+ + {/* Persistent Missing Keys Banner — visible on every screen */} + {(!apiKey || !uploadPostKey) && activeTab !== 'settings' && ( +
+
+ +
+ Required API keys missing.{' '} + + {!apiKey && !uploadPostKey + ? 'Set your Gemini and Upload-Post API keys to use OpenShorts.' + : !apiKey + ? 'Set your Gemini API key to use OpenShorts.' + : 'Set your Upload-Post API key to use OpenShorts.'} + +
+
+ +
+ )} + + {/* Session Recovery Banner */} + {sessionRecovered && ( +
+
+ + Session recovered + Your previous work has been restored. +
+ +
+ )} + + {/* Main Workspace */} +
+ + {/* View: Settings */} + {activeTab === 'settings' && ( +
+
+

Settings

+
+ Privacy: keys only live in your browser (sent to backend just to process) +
+
+ + +
+
+

Social Integration

+ Required +
+

+ Required to publish your clips to TikTok, Instagram Reels, and YouTube Shorts via Upload-Post. + Includes a free tier (no credit card required). +

+
+ +
+ setUploadPostKey(e.target.value)} + className="input-field" + placeholder="ey..." + /> + +
+

+ Connect your Upload-Post account to enable one-click publishing. +

+
+ + Keys are only stored in your browser. They are sent to the backend only to process your request, never stored server-side. + +

+
+
+ +
+
+

Video Translation

+ Optional +
+

+ Translate your clips to different languages using ElevenLabs AI dubbing. + Automatically translates speech while preserving the original voice characteristics. +

+
+ +
+ setElevenLabsKey(e.target.value)} + className="input-field" + placeholder="sk_..." + /> + +
+

+ Get your API key from ElevenLabs to enable video translation. +

+
+ + Keys are only stored in your browser. They are sent to the backend only to process your request, never stored server-side. + +

+
+
+ +
+
+

AI Shorts (UGC Videos)

+ New +
+

+ Generate UGC-style videos with AI actors for any product or business using fal.ai. + Just describe your product or paste a URL. Requires fal.ai + ElevenLabs API keys. +

+
+ +
+ setFalKey(e.target.value)} + className="input-field" + placeholder="fal_..." + /> + +
+

+ Get your API key from fal.ai to enable AI actor video generation. +

+
+ + Keys are only stored in your browser. Sent to backend only to process requests. + +

+
+
+
+ )} + + {/* View: SaaS Shorts */} + {activeTab === 'saasshorts' && ( + + )} + + {/* View: AI Agent */} + {activeTab === 'ai-agent' && ( +
+
+ + {/* Header */} +
+
+ Autonomous Skill +
+

+ Your Personal Clipping Team +

+

+ Drop your videos in a folder and a team of AI clippers picks the viral moments, edits them, and queues them for your approval — like having a 24/7 short-form editing crew on autopilot. +

+
+ + {/* Mobile-format warning */} +
+ +
+

Upload videos already in vertical (9:16) mobile format.

+

+ The agent does not reframe horizontal footage. Make sure every source video is shot or pre-cropped to mobile/portrait format before dropping it into the input folder. +

+
+
+ + {/* Workflow */} +
+
+
+ +
+

1. Drop your videos

+

+ Put your long-form vertical footage in the watched folder. The skill picks one video per run. +

+
+ +
+
+ +
+

2. AI clippers work

+

+ Whisper transcribes, Gemini 3 Flash spots viral beats, FFmpeg cuts each clip and adds a hook overlay. +

+
+ +
+
+ +
+

3. You validate, it ships

+

+ Approve the candidates you like and the skill auto-publishes them to TikTok, Reels and YouTube Shorts via Upload-Post. +

+
+
+ + {/* Repo CTA */} +
+
+
+

skill-autoshorts

+

+ The Claude Code skill that powers this workflow. Install it once and trigger it whenever you want a fresh batch of clips. +

+
+ + View on GitHub + +
+ +
+ git clone https://github.com/mutonby/skill-autoshorts + +
+ +
+
+ + Daily batch — picks one long video per run +
+
+ + Whisper transcription with word-level timing +
+
+ + Gemini 3 Flash multimodal moment detection +
+
+ + Auto-publish to TikTok, Reels & YouTube Shorts +
+
+
+ +
+
+ )} + + {/* View: UGC Gallery */} + {activeTab === 'ugc-gallery' && ( + + )} + + {/* View: Thumbnails */} + {activeTab === 'thumbnails' && ( + + )} + + {/* View: Gallery */} + {/* {activeTab === 'gallery' && ( + + )} */} + + {/* View: Dashboard (Idle) */} + {activeTab === 'dashboard' && status === 'idle' && ( +
+
+
+

+ Create Viral Shorts +

+

+ Drop your long-form video below to instantly generate viral clips with AI. +

+
+ + + +
+ YouTube + Instagram + TikTok +
+
+
+ )} + + {/* View: Processing / Results (Split View) */} + {activeTab === 'dashboard' && (status === 'processing' || status === 'complete' || status === 'error') && ( +
+ + {/* Left Panel: Preview & Status */} +
+
+

+ + Live Analysis +

+ + {status.toUpperCase()} + +
+ + {/* Video Preview */} + {processingMedia && ( + + )} + + {/* Logs Terminal */} +
+
+ + System Logs + + +
+ {logsVisible && ( +
+ {logs.map((log, i) => ( +
+ {new Date().toLocaleTimeString()} + {log} +
+ ))} + {status === 'processing' && ( +
_
+ )} +
+ )} +
+
+ + {/* Right Panel: Results Grid */} +
+

+ + Generated Shorts + {results?.clips?.length > 0 && ( + + {results.clips.length} Clips + + )} + {results?.cost_analysis && ( + + ${results.cost_analysis.total_cost.toFixed(5)} + + )} + {results?.clips?.length > 1 && status === 'complete' && ( + + )} +

+ +
+ {results && results.clips && results.clips.length > 0 ? ( +
+ {results.clips.map((clip, i) => ( + handleClipPlay(time)} + onPause={handleClipPause} + /> + ))} +
+ ) : ( + status === 'processing' ? ( +
+
+

Waiting for clips...

+
+ ) : status === 'error' ? ( +
+

Generation failed.

+
+ ) : null + )} +
+
+ +
+ )} + +
+ +
+ + {/* Missing API Key Modal */} + {showKeyModal && ( +
setShowKeyModal(false)}> +
e.stopPropagation()}> +

+ {!apiKey && !uploadPostKey + ? 'Required API Keys Missing' + : !apiKey + ? 'Gemini API Key Required' + : 'Upload-Post API Key Required'} +

+

+ OpenShorts needs both a Gemini API key and an Upload-Post API key. Both have free tiers. +

+ + {/* Gemini block */} +
+

+ {apiKey ? : } + Gemini API Key {apiKey && — set} +

+ {!apiKey && ( + <> +
    +
  1. Go to aistudio.google.com/app/apikey
  2. +
  3. Sign in with your Google account
  4. +
  5. Click "Create API Key"
  6. +
  7. Copy the key and paste it below
  8. +
+ { + if (e.key === 'Enter' && e.target.value.trim()) { + setApiKey(e.target.value.trim()); + } + }} + /> + + )} +
+ + {/* Upload-Post block */} +
+

+ {uploadPostKey ? : } + Upload-Post API Key {uploadPostKey && — set} +

+ {!uploadPostKey && ( + <> +

+ Required to publish your clips to TikTok, Instagram Reels, and YouTube Shorts. Free tier available, no credit card needed. +

+
    +
  1. Register at app.upload-post.com
  2. +
  3. Connect your TikTok, Instagram, or YouTube accounts
  4. +
  5. Go to API Keys and generate one
  6. +
  7. Paste it below
  8. +
+ { + if (e.key === 'Enter' && e.target.value.trim()) { + setUploadPostKey(e.target.value.trim()); + } + }} + /> + + )} +
+ +
+ + +
+
+
+ )} + + setShowScheduleWeek(false)} + clips={results?.clips || []} + jobId={jobId} + uploadPostKey={uploadPostKey} + uploadUserId={uploadUserId} + /> +
+ ); +} + +export default App; diff --git a/openshorts/dashboard/src/Landing.jsx b/openshorts/dashboard/src/Landing.jsx new file mode 100644 index 0000000..5f3e124 --- /dev/null +++ b/openshorts/dashboard/src/Landing.jsx @@ -0,0 +1,612 @@ +import React from 'react'; +import { Sparkles, Zap, Globe, FileVideo, Subtitles, Youtube, Instagram, Shield, Github, ArrowRight, Play, Check, ChevronDown, Monitor, Cpu, Languages, Type, Upload, Scissors } from 'lucide-react'; + +const TikTokIcon = ({ size = 16, className = "" }) => ( + + + +); + +const FeatureCard = ({ icon: Icon, title, description }) => ( +
+
+ +
+

{title}

+

{description}

+
+); + +const StepCard = ({ number, title, description }) => ( +
+
+ {number} +
+
+

{title}

+

{description}

+
+
+); + +const ComparisonRow = ({ feature, openshorts, opusclip, kapwing }) => ( + + {feature} + {openshorts} + {opusclip} + {kapwing} + +); + +const FAQItem = ({ question, answer, isOpen, onClick }) => ( +
+ + {isOpen && ( +
+

{answer}

+
+ )} +
+); + +export default function Landing({ onLaunchApp }) { + const [openFaq, setOpenFaq] = React.useState(null); + + const features = [ + { + icon: Sparkles, + title: "AI Viral Moment Detection", + description: "Google Gemini 3.0 Flash analyzes your video transcript and scene boundaries to detect the 3-15 most engaging moments. Each clip is scored for viral potential based on emotional impact, hook strength, and shareability — similar to how TikTok's algorithm ranks content for the For You page." + }, + { + icon: Scissors, + title: "Smart 9:16 Vertical Cropping", + description: "Dual-mode AI reframing: TRACK mode follows subjects with MediaPipe face detection + YOLOv8 fallback. GENERAL mode creates blurred backgrounds for group shots and landscapes." + }, + { + icon: Subtitles, + title: "Automatic Subtitle Generation", + description: "Powered by faster-whisper with word-level timestamps. According to Verizon Media research, 80% of viewers are more likely to watch a video to completion when captions are available. Subtitles are auto-generated, styled, and burned into your clips." + }, + { + icon: Languages, + title: "AI Voice Dubbing in 30+ Languages", + description: "ElevenLabs AI integration translates and dubs your video audio while preserving the original speaker's voice characteristics. According to CSA Research, 76% of consumers prefer content in their native language — dubbing unlocks global audiences." + }, + { + icon: Type, + title: "Hook Text Overlays", + description: "Add attention-grabbing text overlays with styled fonts. AI-generated hook titles capture viewers in the first 3 seconds — critical for TikTok and Reels engagement." + }, + { + icon: Zap, + title: "AI Video Effects", + description: "Google Gemini generates dynamic FFmpeg filters for professional video effects — color grading, transitions, and visual enhancements applied automatically." + }, + { + icon: Upload, + title: "Local Video Upload", + description: "Upload your long-form videos — podcasts, webinars, livestreams, vlogs — at full original resolution and audio quality. Process content you own or have rights to." + }, + { + icon: Shield, + title: "100% Self-Hosted & Private", + description: "Deploy with Docker on your own machine. Your videos never leave your infrastructure. API keys are encrypted client-side and never stored on the server." + }, + { + icon: Monitor, + title: "Free AI YouTube Studio", + description: "Free AI YouTube thumbnail generator, AI title suggestions (10 viral options with refinement chat), and auto-generated descriptions with chapter timestamps — all free. Upload a face photo for personalized thumbnails. Publish directly to YouTube from one workflow." + }, + { + icon: Globe, + title: "Direct Social Publishing", + description: "Post directly to TikTok, Instagram Reels, and YouTube Shorts from the dashboard. Async uploads with progress tracking and S3 cloud backup." + }, + { + icon: Sparkles, + title: "AI UGC Video Generator", + description: "Generate marketing videos with AI actors for any product or business. Paste a URL or describe your product — AI writes the script, generates a realistic avatar with lip-sync, adds b-roll, subtitles, and hook overlays. From $0.65/video." + }, + { + icon: FileVideo, + title: "AI Actors & Lip-Sync", + description: "Choose from a gallery of AI-generated actors or upload your own photo. The pipeline generates a talking head video with natural movement and lip-synced voiceover in English or Spanish. Two modes: Low Cost ($0.65) and Premium ($2.00)." + } + ]; + + const steps = [ + { title: "Upload a Long-Form Video", description: "Drop any video file you own — podcasts, webinars, livestreams, interviews. OpenShorts supports all common formats and resolutions." }, + { title: "AI Detects the Best Viral Moments", description: "Google Gemini 3.0 Flash transcribes, analyzes scene boundaries, and identifies 3-15 high-potential clips of 15-60 seconds each." }, + { title: "Smart Cropping to Vertical 9:16", description: "AI reframes each clip to vertical format with face tracking. Subjects stay centered with stabilized camera movement — no manual positioning." }, + { title: "Add Subtitles, Hooks & Effects", description: "Auto-generate styled subtitles, add hook text overlays, and apply AI video effects. Optionally dub into 30+ languages." }, + { title: "Download or Post to Social Media", description: "Export your viral-ready clips or post directly to TikTok, Instagram Reels, and YouTube Shorts from the dashboard." } + ]; + + const faqs = [ + { + question: "What is OpenShorts and how does it work?", + answer: "OpenShorts is a free, open source AI clip generator that transforms your long-form videos — podcasts, webinars, livestreams, vlogs, interviews — into viral-ready short clips in 9:16 vertical format. It uses a multi-step AI pipeline: faster-whisper for transcription with word-level timestamps, PySceneDetect for scene boundary detection, and Google Gemini 3.0 Flash AI for identifying the most engaging viral moments. According to HubSpot's 2025 State of Marketing report, short-form video delivers the highest ROI of any content format, and repurposing long-form content into shorts increases total reach by up to 300%." + }, + { + question: "Is OpenShorts really free? What's the catch?", + answer: "OpenShorts is 100% free and open source. You self-host it using Docker on your own machine or server. It uses three external APIs — all with free tiers. Google Gemini API (required) powers the AI analysis, viral moment detection, and thumbnail generation — its free tier includes 1,500 requests per day. ElevenLabs API (optional) enables AI voice dubbing in 30+ languages — free tier included. Upload-Post API (optional) is a social media API that allows direct publishing to YouTube, TikTok, and Instagram — 10 free uploads/month, no credit card required. There are no watermarks, no usage limits, no monthly subscriptions, and no per-video fees — unlike Opus Clip ($15-228/month) or Kapwing ($24-79/month)." + }, + { + question: "How does OpenShorts compare to Opus Clip?", + answer: "OpenShorts is a free, self-hosted alternative to Opus Clip. Both offer AI viral moment detection and smart vertical cropping. Key differences: OpenShorts is completely free vs Opus Clip's $15-228/month pricing. OpenShorts runs on your infrastructure (full data privacy) vs cloud-only. OpenShorts uses Google Gemini 3.0 Flash for AI analysis vs Opus Clip's proprietary model. OpenShorts adds AI voice dubbing in 30+ languages, AI-generated video effects, and hook text overlays. The trade-off is that OpenShorts requires Docker self-hosting, while Opus Clip is a ready-to-use cloud service." + }, + { + question: "How do I turn a long-form video into TikTok or Reels clips?", + answer: "Upload your long-form video into OpenShorts, enter your free Gemini API key, and click Process. The AI transcribes it with faster-whisper, detects the best viral moments using Google Gemini 3.0 Flash, and crops them to 9:16 vertical format with MediaPipe face tracking. According to Wyzowl's 2025 Video Marketing Statistics report, 91% of businesses use video as a marketing tool, and repurposed short-form clips drive 2.5x more engagement than original content." + }, + { + question: "What AI does OpenShorts use for viral moment detection?", + answer: "OpenShorts uses Google Gemini 3.0 Flash, Google's latest multimodal AI model, for viral moment detection and title generation. The AI receives the full video transcript with timestamps, scene boundary data from PySceneDetect, and analyzes engagement patterns to identify the 3-15 most shareable moments. Each clip is scored based on emotional impact, hook strength, and viral potential — similar to how platforms like TikTok and YouTube rank content." + }, + { + question: "Can OpenShorts translate and dub videos into other languages?", + answer: "Yes. OpenShorts integrates with ElevenLabs AI dubbing to translate your video audio into over 30 languages while preserving the original speaker's voice characteristics. After dubbing, the system automatically re-transcribes the new audio and generates subtitles in the target language. This makes it easy to repurpose content for global audiences — studies show that dubbed content receives 2-3x more engagement in non-English markets." + }, + { + question: "How does the smart vertical cropping work?", + answer: "OpenShorts offers two intelligent cropping modes for converting 16:9 horizontal video to 9:16 vertical format. TRACK mode uses MediaPipe face detection with YOLOv8 as fallback to follow a single subject with 'Heavy Tripod' stabilization — the camera moves smoothly like a professional cameraman. GENERAL mode handles group shots and landscapes by creating a blurred background layout. A SpeakerTracker prevents rapid switching between subjects and handles temporary occlusions for smooth results." + }, + { + question: "Can OpenShorts generate YouTube thumbnails and titles for free?", + answer: "Yes. OpenShorts includes a free AI YouTube thumbnail generator, a free AI YouTube title generator, and a free AI YouTube description generator — all powered by Google Gemini 3.0 Flash. Upload your video and the AI suggests 10 viral title options with an interactive refinement chat. Then it generates multiple thumbnail designs using AI image generation — upload a face photo and background image for personalized results. The studio also auto-generates YouTube descriptions with chapter timestamps and lets you publish directly to YouTube. Everything is 100% free with the Gemini free tier." + }, + { + question: "What are the system requirements to run OpenShorts?", + answer: "OpenShorts runs on any system with Docker installed. The recommended setup is 8GB+ RAM and a modern multi-core CPU. GPU acceleration (NVIDIA CUDA) is optional but speeds up video processing significantly. The Docker Compose setup handles all dependencies automatically — Python 3.11, FFmpeg, YOLOv8, MediaPipe, faster-whisper, and the React dashboard. It works on Linux, macOS, and Windows (via WSL2/Docker Desktop)." + }, + { + question: "Is there a free open source clip generator?", + answer: "Yes — OpenShorts is a 100% free, open source clip generator. Unlike paid clip generators like Opus Clip ($15-228/month) or Kapwing ($24-79/month), OpenShorts lets you generate unlimited clips with no watermarks, no usage limits, and no subscription fees. It also includes a free AI YouTube thumbnail generator, free AI YouTube title generator, and free AI YouTube description generator — features that other clip generators charge extra for. You self-host it with Docker on your own machine for full privacy and control." + }, + { + question: "What is the AI UGC Video Generator?", + answer: "OpenShorts includes an AI UGC (User Generated Content) video creator that generates marketing videos with AI actors for any product or business. You describe your product or paste a website URL — the AI writes a viral script, generates a realistic AI actor with lip-synced voiceover, adds b-roll visuals, TikTok-style subtitles, and hook text overlays. The result is a ready-to-post vertical video for TikTok, Instagram Reels, or YouTube Shorts. Two cost modes: Low Cost (~$0.65/video using Hailuo + VEED Lipsync) and Premium (~$2/video using Kling Avatar v2)." + }, + { + question: "How much does it cost to generate an AI UGC video?", + answer: "OpenShorts itself is free, but the AI Shorts feature uses external APIs (fal.ai for video generation, ElevenLabs for voiceover) that charge per use. Low Cost mode costs approximately $0.65 per video (Flux image $0.05 + ElevenLabs voice $0.10 + Hailuo img2video $0.19 + VEED Lipsync $0.20 + b-roll $0.10). Premium mode costs approximately $2.00 per video using Kling Avatar v2 for higher quality. Both modes are significantly cheaper than hiring UGC creators ($50-500 per video) or using platforms like HeyGen ($24-180/month)." + }, + { + question: "Can I use the AI UGC Video Generator for any type of business?", + answer: "Yes. The AI Shorts generator works for any product, service, or business — not just SaaS. You can use it for restaurants, e-commerce stores, coaching services, local businesses, personal brands, apps, and more. Just describe your business in the text field (e.g. 'Artisan pizza restaurant in Madrid, wood-fired oven, home delivery') or paste your website URL, and the AI generates viral marketing scripts tailored to your business." + } + ]; + + const checkIcon = ; + const xIcon = Paid; + + return ( +
+ {/* Navigation */} + + + {/* Hero Section */} +
+
+
+ + Free & Open Source AI Clip Generator + UGC Video Creator +
+ +

+ Free Open Source + Clip Generator + & AI UGC Video Creator +

+ +

+ Three tools in one. Clip Generator: turn your long-form videos into viral shorts with AI moment detection, smart 9:16 crop, and auto subtitles. AI Shorts: generate UGC marketing videos with AI actors and lip-sync for any business. YouTube Studio: free AI thumbnail generator, 10 viral title suggestions with refinement chat, and auto descriptions with chapters. Self-hosted, open source, no limits. +

+ +
+ + + + View on GitHub + +
+ + {/* Platform Icons */} +
+ Export to: +
+
+ + TikTok +
+
+ + Reels +
+
+ + Shorts +
+
+
+
+
+ + {/* Stats Bar */} +
+
+
+
100%
+
Free & Open Source
+
+
+
3
+
Tools in One
+
+
+
30+
+
Dubbing Languages
+
+
+
$0
+
No Watermarks
+
+
+
+ + {/* 3 Tools in 1 Section */} +
+
+
+

3 Free Tools in 1 Platform

+

Everything you need to create, optimize, and publish short-form video content — all free and open source.

+
+
+
+
+ +

Clip Generator

+

Turn your long-form videos into viral-ready 9:16 shorts. AI detects the best moments, crops to vertical with face tracking, and adds subtitles automatically.

+
    + {['AI viral moment detection', 'Smart face-tracking crop', 'Auto subtitles + hook overlays', 'AI dubbing in 30+ languages'].map((f, i) => ( +
  • {f}
  • + ))} +
+
+
+
+ +

AI Shorts

+

Generate UGC marketing videos with AI actors for any product or business. No camera, no studio. Just describe your product and get a viral-ready video.

+
    + {['AI actor generation + lip-sync', 'Script writing from URL or description', 'B-roll + TikTok-style subtitles', 'From $0.65 per video'].map((f, i) => ( +
  • {f}
  • + ))} +
+
+
+
+ +

YouTube Studio

+

Complete free AI YouTube toolkit. Generate thumbnails with your face, get 10 viral title suggestions with refinement chat, and auto-generate descriptions with timestamps.

+
    + {['AI thumbnail generator (with face upload)', '10 viral title suggestions + chat', 'Auto descriptions with chapters', 'Direct publish to YouTube'].map((f, i) => ( +
  • {f}
  • + ))} +
+
+
+
+
+ + {/* Features Section */} +
+
+
+

Free AI Clip Generator + UGC Video Creator

+

Three tools in one: clip long videos into viral shorts, generate UGC marketing videos with AI actors, and a complete YouTube Studio for thumbnails, titles, and descriptions.

+
+
+ {features.map((feature, i) => ( + + ))} +
+
+
+ + {/* API Keys Section */} +
+
+
+

All APIs Have Free Tiers

+

OpenShorts uses three external APIs — all with generous free tiers. Only Gemini is required. Your API keys are encrypted client-side and never stored on the server.

+
+
+
+
REQUIRED
+
+ +
+

Google Gemini API

+ Free tier: 1,500 req/day +

Powers all AI features: viral moment detection, title generation, video effects, YouTube thumbnail creation, and description writing. The core engine of OpenShorts.

+
+
+
OPTIONAL
+
+ +
+

ElevenLabs API

+ Free tier included +

Enables AI voice dubbing and translation in 30+ languages. Preserves the original speaker's voice while translating audio. Dubbed clips are auto-subtitled.

+
+
+
OPTIONAL
+
+ +
+

Upload-Post API

+ Free tier included +

Enables direct publishing to YouTube, TikTok, and Instagram Reels from the dashboard. Social media API that lets you post your clips and thumbnails without leaving OpenShorts.

+
+
+
+
+
AI SHORTS
+
+ +
+

fal.ai API

+ Pay-per-use from $0.04 +

Powers AI Shorts: generates AI actor images (Flux), talking head videos (Hailuo/Kling), and lip-sync (VEED). Required only for the AI UGC video generator.

+
+
+
AI SHORTS
+
+ +
+

ElevenLabs TTS

+ Free tier included +

Generates natural voiceovers for AI Shorts from the script. Multiple voice options for male and female actors in English and Spanish.

+
+
+
+
+ + {/* How It Works Section */} +
+
+
+

How It Works

+

From a long-form video to viral-ready clips in 5 automated steps. The entire pipeline runs on your machine with AI doing the heavy lifting.

+
+
+ {steps.map((step, i) => ( + + ))} +
+
+
+ + {/* Tech Stack */} +
+
+
+

Built with Proven Technology

+

OpenShorts combines industry-leading AI models and open source tools into a production-ready video processing pipeline.

+
+
+ {[ + { name: "Google Gemini 3.0", desc: "AI Analysis" }, + { name: "faster-whisper", desc: "Transcription" }, + { name: "YOLOv8", desc: "Object Detection" }, + { name: "MediaPipe", desc: "Face Tracking" }, + { name: "FFmpeg", desc: "Video Processing" }, + { name: "ElevenLabs", desc: "Voice & TTS" }, + { name: "fal.ai", desc: "AI Video Gen" }, + { name: "React + Vite", desc: "Dashboard" }, + { name: "Docker", desc: "Deployment" } + ].map((tech, i) => ( +
+
{tech.name}
+
{tech.desc}
+
+ ))} +
+
+
+ + {/* Comparison Table */} +
+
+
+

Free Clip Generator vs Paid Alternatives

+

Why pay $15-228/month for an AI clip generator when you can self-host the same capabilities for free? OpenShorts includes a free YouTube thumbnail generator, AI title suggestions, and auto descriptions — features that paid tools charge extra for.

+
+
+ + + + + + + + + + + $0 Free} opusclip={xIcon} kapwing={xIcon} /> + + + + Limited} kapwing={No} /> + No} kapwing={checkIcon} /> + + Cloud only} kapwing={Cloud only} /> + Free tier only} kapwing={Paid} /> + No} kapwing={No} /> + No} kapwing={Paid} /> + Limited} kapwing={Paid} /> + No} kapwing={No} /> + No} kapwing={No} /> + Unlimited} opusclip={Per plan} kapwing={Per plan} /> + +
Feature + OpenShorts + Opus ClipKapwing
+
+
+
+ + {/* Use Cases */} +
+
+
+

Who Uses OpenShorts?

+

Content creators, marketers, and agencies use OpenShorts to scale their short-form video production. According to HubSpot's 2025 report, short-form video is the #1 content format with the highest ROI.

+
+
+ {[ + { + title: "Content Creators", + description: "Repurpose your long-form videos into TikTok and Reels clips automatically. According to YouTube's Creator Insider data, channels that post Shorts alongside long-form videos see 20-30% more subscriber growth.", + icon: Youtube + }, + { + title: "Social Media Managers", + description: "Scale short-form content production for multiple clients. According to Sprout Social's 2025 Index, 66% of consumers find short-form video the most engaging content type. Process videos in batch and publish directly from one dashboard.", + icon: Instagram + }, + { + title: "Podcasters & Educators", + description: "Extract the most engaging moments from podcast episodes and educational content. Research by Headliner shows that podcast clips on social media increase episode downloads by 72% on average.", + icon: FileVideo + }, + { + title: "Businesses & Brands", + description: "Generate UGC-style marketing videos for any product or business with AI actors. No camera, no studio, no influencer budget. Just describe your product and get a viral-ready video with lip-synced AI avatar, voiceover, b-roll, and subtitles — from $0.65 per video.", + icon: Sparkles + } + ].map((useCase, i) => ( +
+ +

{useCase.title}

+

{useCase.description}

+
+ ))} +
+
+
+ + {/* FAQ Section */} +
+
+
+

Frequently Asked Questions

+

Everything you need to know about OpenShorts, from setup to features.

+
+
+ {faqs.map((faq, i) => ( + setOpenFaq(openFaq === i ? null : i)} + /> + ))} +
+
+
+ + {/* CTA Section */} +
+
+

Start Creating Viral Videos for Free

+

No sign-up, no credit card, no watermarks. Generate viral clips from long videos or create AI UGC marketing videos with AI actors for any business. Self-host with Docker.

+
+ + + + Star on GitHub + +
+
+
+ + {/* Footer */} + +
+ ); +} diff --git a/openshorts/dashboard/src/Legal.jsx b/openshorts/dashboard/src/Legal.jsx new file mode 100644 index 0000000..bc515a1 --- /dev/null +++ b/openshorts/dashboard/src/Legal.jsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { ArrowLeft } from 'lucide-react'; + +const LAST_UPDATED = '2026-05-06'; +const ISSUES_URL = 'https://github.com/mutonby/openshorts/issues'; + +function Section({ title, children }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +export default function Legal() { + const handleBack = () => { + window.location.hash = ''; + }; + + return ( +
+
+
+ +
+
+ +
+

Terms & Privacy

+

Last updated: {LAST_UPDATED}

+ +
+

+ OpenShorts is a free, open-source AI clip generator. There are no accounts, no payments, and we + do not persistently store the videos you upload or the clips we generate. By using the Service + you agree to the points below. +

+
+ +
+

+ The Service is offered for free, on a best-effort basis, with no warranties of any kind and no + guarantee of uptime, accuracy, or fitness for any particular purpose. To the maximum extent + permitted by law, we are not liable for any damages arising from your use of the Service. +

+
+ +
+

+ Before processing a video, you must affirmatively confirm — via the checkbox in the upload + interface — that you own the content or have the rights to process it. By doing so you + represent and warrant that: +

+
    +
  • You own all rights to the content, or have a valid license or permission to process it;
  • +
  • The content does not infringe any third-party copyright, trademark, privacy, or other right;
  • +
  • The content is not unlawful, defamatory, or otherwise prohibited.
  • +
+

+ If you submit content you do not have rights to, that is your responsibility, not ours. You + agree to indemnify OpenShorts and its contributors against any third-party claim arising from + content you submitted. +

+
+ +
+
    +
  • + Uploaded videos and generated clips: deleted with + their job, typically within 1 hour. Not backed up off-server in our hosted deployment. +
  • +
  • + Attestation record (IP, user-agent, timestamp, source):{' '} + kept in memory with the job and discarded when the job is purged (≈1 hour). Used only to + evidence the ownership confirmation in case of a takedown or dispute. +
  • +
  • + Standard server access logs: retained up to 30 days + for debugging and abuse prevention. +
  • +
  • + API keys (Gemini, ElevenLabs, Upload-Post): stored + encrypted in your browser's localStorage. They are + sent as request headers when a feature needs them, used to call the relevant third party, + and never written to our database or disk. +
  • +
+

We do not sell, rent, or share your data with third parties for advertising or any unrelated purpose.

+
+ +
+

+ When you use a feature that requires it, OpenShorts forwards relevant data to the third-party + API for which you provided a key — Google Gemini (AI analysis), ElevenLabs (optional dubbing), + Upload-Post (optional social posting). Those services have their own terms and privacy policies + which apply in addition to this notice. +

+
+ +
+

+ Under the GDPR / UK GDPR you have the right to access, rectify, erase, restrict, object to, or + port your personal data. Because we do not hold accounts and job data is purged within an hour, + most requests are auto-satisfied by the retention schedule. For anything else, file a request + via{' '} + + GitHub Issues + + . You may also lodge a complaint with your local supervisory authority (in Spain: AEPD,{' '} + + aepd.es + + ). +

+
+ +
+

+ If you believe content processed through the Service infringes your copyright, open an issue at{' '} + + {ISSUES_URL} + {' '} + with: identification of the work, identification of the allegedly infringing material (job ID, + URL, or sufficient detail to locate it), your contact information, and a statement that you are + authorized to act on behalf of the rights holder. Note that uploaded content is typically + deleted within 1 hour, so most takedowns are auto-resolved by retention. +

+
+ +
+

+ OpenShorts is open source and may be self-hosted. This notice applies only to the hosted + version we operate. Self-hosted instances are operated by their respective administrators, and + their data handling, retention, and policies are their responsibility, not ours. +

+
+ +
+

+ We may update this notice from time to time; the "Last updated" date above reflects the most + recent revision. Continued use after a change constitutes acceptance. For any other question, + please use{' '} + + GitHub Issues + + . +

+

This notice is governed by the laws of Spain.

+
+
+
+ ); +} diff --git a/openshorts/dashboard/src/assets/react.svg b/openshorts/dashboard/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/openshorts/dashboard/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/openshorts/dashboard/src/components/Gallery.jsx b/openshorts/dashboard/src/components/Gallery.jsx new file mode 100644 index 0000000..eb41e3f --- /dev/null +++ b/openshorts/dashboard/src/components/Gallery.jsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { LayoutGrid, AlertCircle, Loader2 } from 'lucide-react'; +import { getApiUrl } from '../config'; +import GalleryCard from './GalleryCard'; + +const CLIPS_PER_PAGE = 20; + +export default function Gallery() { + const [clips, setClips] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [offset, setOffset] = useState(0); + + const loaderRef = useRef(null); + + const fetchClips = useCallback(async (currentOffset = 0, append = false) => { + try { + if (currentOffset === 0) setLoading(true); + else setLoadingMore(true); + + const res = await fetch( + getApiUrl(`/api/gallery/clips?limit=${CLIPS_PER_PAGE}&offset=${currentOffset}`) + ); + if (!res.ok) throw new Error('Failed to fetch clips'); + const data = await res.json(); + + const newClips = data.clips || []; + + if (append) { + setClips(prev => [...prev, ...newClips]); + } else { + setClips(newClips); + } + + setHasMore(data.has_more ?? newClips.length === CLIPS_PER_PAGE); + setOffset(currentOffset + newClips.length); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, []); + + // Initial load + useEffect(() => { + fetchClips(0, false); + }, [fetchClips]); + + // Infinite scroll observer + useEffect(() => { + if (!hasMore || loadingMore || loading) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loadingMore) { + fetchClips(offset, true); + } + }, + { rootMargin: '200px', threshold: 0.1 } + ); + + if (loaderRef.current) { + observer.observe(loaderRef.current); + } + + return () => { + if (loaderRef.current) { + observer.unobserve(loaderRef.current); + } + }; + }, [hasMore, loadingMore, loading, offset, fetchClips]); + + if (loading) { + return ( +
+ +

Loading your viral history...

+
+ ); + } + + if (error) { + return ( +
+ +

Error loading gallery: {error}

+ +
+ ); + } + + return ( +
+
+

+ Clip Gallery +

+ + {clips.length} {clips.length === 1 ? 'Clip' : 'Clips'}{hasMore ? '+' : ''} + +
+ + {clips.length === 0 ? ( +
+

No clips found yet.

+

Process some videos to populate your gallery!

+
+ ) : ( + <> +
+ {clips.map((clip, i) => ( + + ))} +
+ + {/* Infinite scroll loader trigger */} + {hasMore && ( +
+ {loadingMore && ( +
+ + Loading more clips... +
+ )} +
+ )} + + )} +
+ ); +} + diff --git a/openshorts/dashboard/src/components/GalleryCard.jsx b/openshorts/dashboard/src/components/GalleryCard.jsx new file mode 100644 index 0000000..eafdd19 --- /dev/null +++ b/openshorts/dashboard/src/components/GalleryCard.jsx @@ -0,0 +1,156 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { Download, Youtube, Instagram, Video, Copy, Check, Play } from 'lucide-react'; + +export default function GalleryCard({ clip }) { + const [copied, setCopied] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const cardRef = useRef(null); + const videoRef = useRef(null); + + // Lazy loading with IntersectionObserver + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + // Once loaded, we don't need to observe anymore + observer.unobserve(entry.target); + } + }); + }, + { + rootMargin: '200px', // Start loading 200px before entering viewport + threshold: 0.1 + } + ); + + if (cardRef.current) { + observer.observe(cardRef.current); + } + + return () => { + if (cardRef.current) { + observer.unobserve(cardRef.current); + } + }; + }, []); + + const handleCopy = (text, field) => { + navigator.clipboard.writeText(text); + setCopied(field); + setTimeout(() => setCopied(null), 2000); + }; + + const handleDownload = async (e) => { + e.preventDefault(); + try { + const response = await fetch(clip.url); + if (!response.ok) throw new Error('Download failed'); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `clip_${clip.job_id}_${clip.index + 1}.mp4`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (err) { + console.error('Download error:', err); + window.open(clip.url, '_blank'); + } + }; + + return ( +
+ {/* Video Player - Lazy loaded */} +
+ {isVisible ? ( +
+ + {/* Content & Details */} +
+
+

+ {clip.title} +

+
+ {clip.duration.toFixed(1)}s + ID: {clip.job_id.substring(0, 8)} +
+
+ +
+ {/* YouTube Title */} +
+
+ YouTube Title +
+

{clip.title}

+ +
+ + {/* TikTok / IG Caption */} +
+
+
+

+ {clip.tiktok_desc || clip.insta_desc} +

+ +
+
+ + {/* Footer Action */} + +
+
+ ); +} diff --git a/openshorts/dashboard/src/components/HookModal.jsx b/openshorts/dashboard/src/components/HookModal.jsx new file mode 100644 index 0000000..db50e25 --- /dev/null +++ b/openshorts/dashboard/src/components/HookModal.jsx @@ -0,0 +1,212 @@ +import React, { useState } from 'react'; +import { X, Sparkles, Loader2, Maximize, MoveVertical, Zap } from 'lucide-react'; +import RemotionPreview from './RemotionPreview'; + +const ENTRANCE_OPTIONS = [ + { value: 'spring', label: 'Bounce' }, + { value: 'fade', label: 'Fade' }, + { value: 'slide-up', label: 'Slide Up' }, + { value: 'none', label: 'None' }, +]; + +export default function HookModal({ isOpen, onClose, onGenerate, isProcessing, videoUrl, initialText, durationInSeconds, existingSubtitles }) { + const [text, setText] = useState(initialText || 'POV: You are using the viral hook feature'); + const [position, setPosition] = useState('top'); + const [size, setSize] = useState('M'); + const [entranceAnimation, setEntranceAnimation] = useState('spring'); + const [displayDuration, setDisplayDuration] = useState(5); + + if (!isOpen) return null; + + // Build hook config for Remotion preview + const hookConfig = { + text: text || 'Enter your text...', + position, + size, + entranceAnimation, + displayDurationSec: displayDuration, + }; + + const useRemotionPreview = !!videoUrl; + + // Fallback preview logic (same as original) + const getPositionClass = () => { + switch (position) { + case 'center': return 'items-center justify-center'; + case 'bottom': return 'items-center justify-end pb-[20%]'; + case 'top': default: return 'items-center justify-start pt-[20%]'; + } + }; + + const getSizeStyle = () => { + switch (size) { + case 'S': return { fontSize: '14px', maxWidth: '80%' }; + case 'L': return { fontSize: '24px', maxWidth: '95%' }; + case 'M': default: return { fontSize: '18px', maxWidth: '90%' }; + } + }; + + return ( +
+
+ + + {/* Left: Preview */} +
+ {useRemotionPreview ? ( + + ) : ( + <> +
+ + {/* Right: Controls */} +
+

+ Viral Hook +

+ +
+ {/* Text Input */} +
+ +