diff --git a/.dockerignore b/.dockerignore index 5bd3f39..c3ba700 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,13 @@ .git +.gitignore .env +.env.* +!.env.example __pycache__ *.pyc .claude/ outputs/ +data/ node_modules/ remotion/public/music/*.mp3 remotion/out/ @@ -12,5 +16,15 @@ remotion/out/ tests/ *.md .pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +venv/ +.venv/ +# Secrets and runtime state — never bake into the image channel_insights.json lessons_learned.json +client_secrets.json +youtube_token.json +*.json.bak +*.json.tmp diff --git a/.env.example b/.env.example index be274a5..acef301 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,30 @@ # OPTIONAL keys enable additional features. # ═══════════════════════════════════════════════════════════════════════════════ +# ── REQUIRED: Server security ───────────────────────────────────────────────── + +# Shared secret for the control API (/trigger, /kill, setup wizard, ...). +# REQUIRED — while blank, every protected endpoint returns 503. On a local +# install the setup wizard (opened from http://127.0.0.1:8080) generates one. +# Generate: python -c "import secrets;print(secrets.token_urlsafe(32))" +TRIGGER_KEY= + +# Dashboard login password. Required to use the dashboard from any machine +# other than localhost (remote browsers never receive TRIGGER_KEY without it). +DASHBOARD_PASSWORD= + +# Flask session signing key. Set it so dashboard logins survive restarts. +# Generate: python -c "import secrets;print(secrets.token_hex(32))" +FLASK_SECRET_KEY= + +# Address the dashboard/API binds to. Unset = 127.0.0.1 (local only). +# Docker sets HOST=0.0.0.0 itself — left commented out so a blank value here +# can't override it via --env-file. Only expose after setting the keys above. +# HOST= + +# Set to true when the dashboard is served over HTTPS (marks cookies Secure). +COOKIE_SECURE= + # ── REQUIRED ────────────────────────────────────────────────────────────────── # Anthropic (Claude) — LLM for all text generation @@ -33,6 +57,16 @@ FAL_API_KEY= # Get yours: https://www.pexels.com/api/new/ PEXELS_API_KEY= +# ── OPTIONAL: Alternative providers ─────────────────────────────────────────── + +# Epidemic Sound — music, SFX, stems, voiceover (key expires every 30 days) +# Get yours: https://www.epidemicsound.com/account/api-keys +EPIDEMIC_SOUND_API_KEY= + +# OpenAI — alternative LLM / TTS provider (providers.llm.name: openai) +# Get yours: https://platform.openai.com/api-keys +OPENAI_API_KEY= + # ── OPTIONAL: Database ──────────────────────────────────────────────────────── # Supabase — Video metadata storage & analytics diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b6aa13..6634ec9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,9 +111,41 @@ jobs: working-directory: remotion run: npm test + dashboard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Install dependencies + working-directory: dashboard + run: npm ci + + - name: Typecheck and build + working-directory: dashboard + run: npm run build + + - name: Committed dist matches source + run: | + if [ -n "$(git status --porcelain dashboard/dist)" ]; then + echo "::error::dashboard/dist is stale. Run 'cd dashboard && npm run build' and commit dashboard/dist." + git status --porcelain dashboard/dist + exit 1 + fi + + - name: Run tests + working-directory: dashboard + run: npm test + docker: runs-on: ubuntu-latest - needs: [py-test, ts-test] + needs: [py-test, ts-test, dashboard] if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index f87f24f..be8011d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,6 @@ htmlcov/ .ruff_cache/ # Dashboard build -dashboard/dist/ dashboard/node_modules/ # Remotion build @@ -72,3 +71,6 @@ state.json docs/*.pdf docs/*.tex CLAUDE.md + +# Runtime data mounted into Docker (may contain OAuth secrets) +data/ diff --git a/CLAUDE.md b/CLAUDE.md index 16481ae..3c4f7e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Topic → Research(1) → Originality(2) → Narrative(3) → Script(4) → Scri → Footage(9) → Images(10) → Video(11) → QA(12) → Upload(13) ``` -**Core call chain:** `agents/*.py` → `core/agent_wrapper.py:call_agent()` → `clients/claude_client.py:call_claude()` → Anthropic API +**Core call chain:** `agents/*.py` → `core/agent_wrapper.py:call_agent()` → `clients/claude_client.py:call_claude()` → configured LLM provider (Anthropic API by default) ### Key Files @@ -73,31 +73,40 @@ Key functions: `core/profile.py` → `get_profile()`, `get_style_directive()`, ` ### Provider System -Swap any external service in `obsidian.yaml`: +Every external-service call goes through the registry. Swap any service in `obsidian.yaml`, using a built-in name or a custom `module.path.ClassName`. `options:` are passed to the constructor as keyword arguments: ```yaml providers: llm: - name: anthropic # or openai, or custom.module.ClassName + name: anthropic # or openai, or my_pkg.module.ClassName tts: - name: elevenlabs # or epidemic_sound, openai (PR pending) + name: elevenlabs # or openai, epidemic_sound images: name: fal footage: name: pexels upload: - name: local + name: local # or youtube (stage 13 honours this; default saves to outputs/final/) music: name: auto # epidemic_sound, local, or auto sfx: name: auto ``` -Key functions: `providers/registry.py` → `get_provider("llm")`, `list_providers()`, `clear_cache()` +Key functions: `providers/registry.py` → `get_provider("llm")`, `get_provider_name()` (resolves `auto`), `is_builtin()`, `list_providers()`, `clear_cache()`. Self-check: `python -m providers`. Guide for custom providers: `docs/PROVIDERS.md`; templates in `examples/providers/`. -Built-in providers: `anthropic`, `openai`, `elevenlabs`, `epidemic_sound`, `fal`, `pexels`, `local` +Built-in providers: `anthropic`, `openai`, `elevenlabs`, `epidemic_sound`, `fal`, `pexels`, `local`, `youtube` Provider types (7): `llm`, `tts`, `images`, `footage`, `upload`, `music`, `sfx` +Where each provider is called: +- **LLM:** `clients/claude_client.call_claude()`. When the provider is `anthropic` it calls `_anthropic_call` directly. Otherwise it calls `provider.generate(model=resolve_model(tier))`, where the tier comes from the model (OPUS→premium, SONNET→full, HAIKU→light). +- **TTS:** `pipeline/audio.synthesize_chunk()`, used by long-form and Shorts audio. If the provider returns empty timestamps, words are aligned with forced alignment. +- **Images:** `pipeline/images.py`, `pipeline/shorts.py` and `agents/thumbnail_agent.py`. +- **Footage:** `agents/09_footage_hunter.search_stock_video()`. +- **Upload:** `agents/11_youtube_uploader.run()` and the Shorts upload. Non-`youtube` providers skip the YouTube-only follow-ups. +- **Music and SFX:** `pipeline/audio_assets.py`, called from `pipeline/convert.py`. +- **Claude vision** (image and thumbnail scoring) only runs with the `anthropic` LLM. Otherwise images are accepted without scoring. + ### Epidemic Sound Integration **API**: MCP server at `https://www.epidemicsound.com/a/mcp-service/mcp` — JSON-RPC protocol, NOT REST. @@ -229,7 +238,7 @@ Note: CI uses bare `python` (GitHub Actions), locally use `.venv/bin/python`. ~1 - **Adding an API endpoint**: In `webhook_server.py`, use `@require_key` decorator, return `jsonify()` - **Adding structured output to an agent**: Add schema to `core/structured_schemas.py`, add to `SCHEMA_REGISTRY` (unless agent has multiple call patterns) - **Adding a content profile**: Copy `profiles/_template.yaml`, fill all sections, set `profile:` in `obsidian.yaml`. Tests auto-validate. -- **Adding a provider**: Extend ABC from `providers/base.py`, register in `providers/registry.py` `_BUILTIN_PROVIDERS`, add tests in `tests/test_providers.py` +- **Adding a provider**: Custom providers need no registration: extend the ABC in `providers/base.py` and set `providers..name: module.ClassName` (see `docs/PROVIDERS.md`). For a new *built-in*, also register it in `providers/registry.py` `_BUILTIN_PROVIDERS` and add tests in `tests/test_providers.py` - **Adding a music/SFX feature**: Use `clients/epidemic_client.py` (`_call_tool`) for API. Music: `music_manager.py` → `epidemic_music_manager.py`. SFX: `epidemic_sfx_manager.py`. Fallback chain: API → cached → local. - **Adding a music provider**: Extend `MusicProvider` from `providers/base.py`, register in `providers/registry.py`. Implement `search()`, `download()`, `select_for_video()`. - **Changing config defaults**: Edit `obsidian.yaml`, constants in `core/pipeline_config.py` will pick it up automatically diff --git a/DEPLOY.md b/DEPLOY.md index eecc766..16cc1e3 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,9 +1,44 @@ # The Obsidian Archive — Railway Deployment Checklist ## Overview -This project runs as a **headless worker** on Railway. -It has no HTTP server — the container runs `scheduler.py --daemon` continuously, -producing videos on schedule and uploading them to YouTube. +The container runs `scheduler.py --daemon` continuously, producing videos on +schedule and handing them to the configured upload provider. **Publishing to +YouTube is opt-in:** the default `providers.upload.name` in `obsidian.yaml` is +`local`, which saves finished videos to `outputs/final/`. To publish, set: + +```yaml +providers: + upload: + name: youtube +``` + +and complete Step 1a below. The scheduler logs a warning at startup if it finds +YouTube credentials while the upload provider isn't `youtube`. The scheduler also starts the Flask +dashboard/control API (`server/webhook_server.py`) on port **8080** (`PORT`), +which serves the dashboard at `/`, the health check at `/health`, and the +key-protected control endpoints (`/trigger`, `/kill`, `/api/setup/*`, ...). + +--- + +## Security (read before exposing the port) + +- **Set `TRIGGER_KEY`** — every control endpoint returns `503` until it is set. + Generate one with `python -c "import secrets;print(secrets.token_urlsafe(32))"`. + Callers send it in the `X-Trigger-Key` header (`?key=` is accepted only on + the `/stream` SSE endpoint). +- **Set `DASHBOARD_PASSWORD`** — without it the dashboard only works from + `localhost`; remote browsers never receive the trigger key. +- **Set `FLASK_SECRET_KEY`** so login sessions survive restarts, and + `COOKIE_SECURE=true` when served over HTTPS. +- The server binds to `127.0.0.1` unless `HOST` is set. The Docker image sets + `HOST=0.0.0.0` because a container must listen on all interfaces — so set + the keys above before publishing port 8080. +- If you put a reverse proxy **on the same host** in front of the server, + requests appear to come from `127.0.0.1`; always set `DASHBOARD_PASSWORD` + in that setup. +- On a fresh local (non-Docker) install with no `TRIGGER_KEY`, the setup + wizard is reachable from `127.0.0.1` only; saving it generates a + `TRIGGER_KEY` and writes it to `.env`. --- @@ -14,8 +49,8 @@ The YouTube upload API requires an interactive OAuth flow that **cannot run insi You must generate `youtube_token.json` on your local machine first: ```bash -cd ~/Desktop/Obsidian -python3 11_youtube_uploader.py +cd obsidian-engine +python3 agents/11_youtube_uploader.py # Browser will open — log in with your YouTube channel account # Token is saved to youtube_token.json ``` @@ -28,8 +63,8 @@ In Railway dashboard → your service → **Variables** → add: ``` YOUTUBE_TOKEN_JSON = ``` -Then in `11_youtube_uploader.py`, the startup can write this back to disk. -*(Or mount it as a volume — see Step 4.)* +`scheduler.py` writes this back to `youtube_token.json` at startup +(and `agents/11_youtube_uploader.py` also restores it from the env var). --- @@ -58,26 +93,28 @@ Go to: Railway dashboard → your project → your service → **Variables** |---------------------------|---------------------------------------------------| | `YOUTUBE_TOKEN_JSON` | Full JSON contents of `youtube_token.json` | +### Required — dashboard / API security + +| Variable | Description | +|----------------------|----------------------------------------------------------| +| `TRIGGER_KEY` | Shared secret for the control API (see **Security**) | +| `DASHBOARD_PASSWORD` | Dashboard login password | +| `FLASK_SECRET_KEY` | Session signing key (keeps logins valid across restarts) | + ### Optional | Variable | Description | |------------------------|------------------------------------------------------| +| `COOKIE_SECURE` | `true` when the dashboard is served over HTTPS (Railway domains are) | +| `HOST` | Bind address — the Dockerfile already sets `0.0.0.0` | | `PYTHONUNBUFFERED` | Set to `1` (already in Dockerfile, but safe to repeat) | --- -## Step 3 — Write youtube_token.json at container startup +## Step 3 — YouTube token restore (already built in) -Add this to the **top of `scheduler.py`** (after `load_dotenv`) so the token file -is restored from the env var each time the container starts: - -```python -# Restore YouTube token from env var (Railway secret) -_token_json = os.getenv("YOUTUBE_TOKEN_JSON", "") -if _token_json: - token_path = Path(__file__).parent / "youtube_token.json" - token_path.write_text(_token_json) -``` +No code change is needed: `scheduler.py` already restores `youtube_token.json` +from `YOUTUBE_TOKEN_JSON` at startup. --- @@ -92,6 +129,11 @@ In Railway dashboard → your service → **Volumes**: > Without this volume, every restart loses all rendered content. +> The image runs as the unprivileged `app` user (uid 1000). Railway mounts +> volumes as root, so if logs show `Permission denied: '/app/outputs/...'`, +> add the variable `RAILWAY_RUN_UID=0` (Railway's documented fix for +> non-root images with volumes). + --- ## Step 5 — Deploy @@ -148,3 +190,21 @@ If you see import errors, check that all env vars in Step 2 are set. | `FATAL: Fact verification requires full rewrite` | Twist reveal unverifiable — topic needs to be re-queued with a different angle | | `Pipeline halted: script too short` | Claude returned < 1000 words — retry the topic | | Remotion render crash | Chrome deps missing — check Dockerfile build logs | + +--- + +## Docker Compose (self-hosted) + +```bash +cp .env.example .env # fill in API keys, TRIGGER_KEY, DASHBOARD_PASSWORD +mkdir -p data outputs remotion/public/music +docker compose up -d --build +``` + +- Runtime state (`channel_insights.json`, `lessons_learned.json`) is stored in + `./data/`. Put `client_secrets.json` and `youtube_token.json` in `./data/` + too (or set `YOUTUBE_TOKEN_JSON` in `.env`). +- The container runs as an unprivileged user (uid 1000). On Linux, if your + uid differs, build with `APP_UID=$(id -u) APP_GID=$(id -g) docker compose up -d --build`, + or `chown` the `data/` and `outputs/` directories to uid 1000. +- Browse to `http://:8080` and log in with `DASHBOARD_PASSWORD`. diff --git a/Dockerfile b/Dockerfile index 7c79d38..06ca3af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# Base image is pinned by tag only. For reproducible/supply-chain-hardened +# builds, pin by digest (python:3.11-slim@sha256:) after verifying it. FROM python:3.11-slim # System dependencies for Remotion (Chromium), ffmpeg, ffprobe @@ -30,24 +32,42 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* +# Unprivileged runtime user. APP_UID should match the host user that owns the +# bind-mounted ./data and ./outputs directories (default 1000). +ARG APP_UID=1000 +ARG APP_GID=1000 +RUN groupadd --gid "${APP_GID}" app \ + && useradd --uid "${APP_UID}" --gid app --create-home --shell /usr/sbin/nologin app + WORKDIR /app +RUN chown app:app /app -# Python dependencies +# Python dependencies (installed system-wide as root) COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Everything under /app is owned by `app`: the pipeline writes outputs/, +# outputs/media/, remotion/public/, remotion/src/video-data.json, and Remotion +# downloads its headless browser into remotion/node_modules at render time. +USER app + # Node dependencies for Remotion -COPY remotion/package*.json remotion/ +COPY --chown=app:app remotion/package*.json remotion/ RUN cd remotion && npm ci --omit=dev # Copy application code -COPY . . +COPY --chown=app:app . . -# Create output directories and seed intelligence files (so bind mounts have valid defaults) -RUN mkdir -p outputs/logs outputs/images remotion/public/music \ +# Create output/data directories and seed intelligence files +RUN mkdir -p data outputs/logs outputs/images outputs/media remotion/public/music \ && echo '{}' > channel_insights.json \ && echo '{}' > lessons_learned.json +# The container must listen on all interfaces (the app defaults to 127.0.0.1). +ENV HOST=0.0.0.0 \ + PORT=8080 \ + PYTHONUNBUFFERED=1 + # Expose dashboard port EXPOSE 8080 @@ -55,12 +75,25 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 -# Ensure intelligence files are valid JSON on startup -# Handles: first deploy (no file), Docker directory-mount (not a file), empty/corrupt file -# Then run scheduler (which starts the web server + scheduled jobs) +# Startup: runtime JSON state lives in /app/data (bind-mount ./data there). +# For each state file, /app/ becomes a symlink to /app/data/; +# insights/lessons default to '{}', token/secrets are only linked. A regular +# file left at /app/ by an atomic write in a previous run is copied into +# data/ first. Nothing here can abort startup — python always starts. CMD ["sh", "-c", "\ - for f in channel_insights.json lessons_learned.json; do \ - if [ -d \"$f\" ]; then rm -rf \"$f\"; fi; \ - if [ ! -f \"$f\" ] || [ ! -s \"$f\" ]; then echo '{}' > \"$f\"; fi; \ - done && \ - exec python3 scheduler.py --daemon"] +mkdir -p data 2>/dev/null; \ +if [ -w data ]; then \ + for f in channel_insights.json lessons_learned.json youtube_token.json client_secrets.json; do \ + if [ -d \"$f\" ] && [ ! -L \"$f\" ]; then echo \"[start] WARNING: /app/$f is a directory (old single-file mount?) - skipping\"; continue; fi; \ + if [ -d \"data/$f\" ]; then echo \"[start] WARNING: /app/data/$f is a directory - skipping\"; continue; fi; \ + if [ -f \"$f\" ] && [ ! -L \"$f\" ] && [ -s \"$f\" ] && [ \"$(cat \"$f\")\" != '{}' ]; then \ + if [ ! -s \"data/$f\" ] || [ \"$f\" -nt \"data/$f\" ]; then cp -f \"$f\" \"data/$f\"; fi; \ + fi; \ + case \"$f\" in channel_insights.json|lessons_learned.json) [ -s \"data/$f\" ] || echo '{}' > \"data/$f\";; esac; \ + rm -f \"$f\"; ln -s \"data/$f\" \"$f\"; \ + done; \ +else \ + echo \"[start] WARNING: /app/data is not writable by uid $(id -u) - runtime state will not persist. On the host run: mkdir -p data outputs && chown -R $(id -u) data outputs\"; \ +fi; \ +for d in outputs remotion/public/music; do [ -w \"$d\" ] || echo \"[start] WARNING: /app/$d is not writable by uid $(id -u)\"; done; \ +exec python3 scheduler.py --daemon"] diff --git a/README.md b/README.md index a1aaed7..72ebae7 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ You need **4 API keys** to get started. Total free credits cover your first 3-5 | Service | What For | Link | |---------|----------|------| -| YouTube API | Auto-upload to YouTube | [Google Cloud Console](https://console.cloud.google.com) | +| YouTube API | Auto-upload to YouTube (set `providers.upload.name: youtube`) | [Google Cloud Console](https://console.cloud.google.com) | | Supabase | Analytics database | [supabase.com](https://supabase.com) | | Epidemic Sound | Premium music + SFX | [epidemicsound.com](https://www.epidemicsound.com) | | Telegram | Pipeline notifications | [@BotFather](https://t.me/BotFather) | @@ -162,22 +162,23 @@ profile: documentary # Netflix/HBO style ## Pluggable Providers -Swap any external service without touching code: +Every external service (LLM, voice, images, footage, upload, music, SFX) goes through a provider, so you can swap any of them without touching code: ```yaml # obsidian.yaml providers: llm: { name: openai } # GPT instead of Claude - tts: { name: elevenlabs } # Default narrator + tts: { name: elevenlabs } # or: openai, epidemic_sound images: { name: fal } # AI image generation footage: { name: pexels } # Stock B-roll - upload: { name: local } # Save to disk (or: youtube) + upload: { name: local } # Save to outputs/final/ (or: youtube to publish) music: { name: auto } # Epidemic Sound → local fallback + sfx: { name: auto } ``` -Built-in: `anthropic`, `openai`, `elevenlabs`, `fal`, `pexels`, `local`, `epidemic_sound` +Built-in: `anthropic`, `openai`, `elevenlabs`, `epidemic_sound`, `fal`, `pexels`, `local`, `youtube` -Custom provider? Point to any class: `name: my_package.MyProvider` +**Bring your own:** set `name` to the dotted path of your own class (e.g. `my_providers.tts.MyTTS`). Anything under `options:` is passed to its constructor. Run `python -m providers` to check that everything loads. [docs/PROVIDERS.md](docs/PROVIDERS.md) documents the interface for each type, and [examples/providers/](examples/providers/) has working templates. ## Dashboard diff --git a/agents/09_footage_hunter.py b/agents/09_footage_hunter.py index e718cb0..17eb810 100644 --- a/agents/09_footage_hunter.py +++ b/agents/09_footage_hunter.py @@ -1,10 +1,10 @@ """ Agent 09 - Footage Hunter v2 -Wikimedia-first for historical content. Pexels only for pure atmosphere. +Wikimedia-first for historical content. Stock video (the configured footage +provider — providers.footage, Pexels by default) only for pure atmosphere. """ import sys -import os import json import requests import time @@ -14,8 +14,6 @@ from dotenv import load_dotenv load_dotenv() -PEXELS_API_KEY = os.getenv("PEXELS_API_KEY") - _CACHE_FILE = _BASE / "outputs" / "footage_cache.json" _CACHE_TTL = 7 * 24 * 3600 # 7 days @@ -178,44 +176,53 @@ def search_wikimedia(query, prefer_painting=True): _session_cache[cache_key] = None return None -def search_pexels_video(query): - """Only used for pure atmospheric scenes.""" - cache_key = f"pexels:{query}" +def search_stock_video(query): + """Search the configured footage provider. Only used for pure atmospheric scenes. + + Returns {"source", "url", "width", "height", "duration", "credit"} or None. + """ + try: + from providers.registry import get_provider, get_provider_name + provider_name = get_provider_name("footage") + provider = get_provider("footage") + except Exception as e: + print(f" [Footage] Provider unavailable: {e}") + return None + + cache_key = f"{provider_name}:{query}" if cache_key in _session_cache: return _session_cache[cache_key] if cache_key in _footage_cache: cached = dict(_footage_cache[cache_key]) cached.pop("_ts", None) return cached - url = "https://api.pexels.com/videos/search" - headers = {"Authorization": PEXELS_API_KEY} - params = {"query": query, "per_page": 5, "orientation": "landscape"} try: - r = requests.get(url, headers=headers, params=params, timeout=10) - if r.status_code == 200: - videos = json.loads(r.text, strict=False).get("videos", []) - for video in videos: - files = video.get("video_files", []) - hd = [f for f in files if f.get("quality") == "hd" and f.get("width", 0) >= 1280] - if hd: - result = { - "source": "pexels", - "url": hd[0]["link"], - "width": hd[0].get("width", 1920), - "height": hd[0].get("height", 1080), - "duration": video.get("duration", 10), - "credit": f"Video by {video.get('user', {}).get('name', 'Pexels')} on Pexels" - } - _footage_cache[cache_key] = {**result, "_ts": time.time()} - _session_cache[cache_key] = result - _save_cache(_footage_cache) - return result + results = provider.search(query, orientation="landscape", min_duration=0, max_results=5) + for item in results or []: + if not item.get("url"): + continue + result = { + "source": provider_name, + "url": item["url"], + "width": item.get("width", 1920), + "height": item.get("height", 1080), + "duration": item.get("duration", 10), + "credit": item.get("credit") or f"Footage via {provider.name}", + } + _footage_cache[cache_key] = {**result, "_ts": time.time()} + _session_cache[cache_key] = result + _save_cache(_footage_cache) + return result except Exception as e: - print(f" [Pexels] Error for '{query}': {e}") + print(f" [{provider.name}] Error for '{query}': {e}") # Cache negative results in session to avoid re-querying within same run _session_cache[cache_key] = None return None + +# Backward-compatible alias (pre-provider name) +search_pexels_video = search_stock_video + # Scene type routing ATMOSPHERIC_QUERIES = { "dark": "dark stone texture ancient", @@ -252,9 +259,9 @@ def run(scenes_data): visual = search_wikimedia(pexels_query, prefer_painting=False) elif visual_type in ["broll_atmospheric", "broll_nature"]: - # Atmospheric: try Pexels video first, Wikimedia as fallback + # Atmospheric: try stock video first, Wikimedia as fallback atm_query = ATMOSPHERIC_QUERIES.get(mood, pexels_query) or pexels_query - visual = search_pexels_video(atm_query) + visual = search_stock_video(atm_query) if not visual and wikimedia_query: visual = search_wikimedia(wikimedia_query) @@ -285,7 +292,7 @@ def run(scenes_data): with open(manifest_path, "w") as f: json.dump(manifest, f, indent=2) - wikimedia_count = sum(1 for s in results if s.get("visual", {}).get("source") == "wikimedia") - pexels_count = sum(1 for s in results if s.get("visual", {}).get("source") == "pexels") - print(f"[Footage Hunter v2] Wikimedia: {wikimedia_count} | Pexels: {pexels_count}") + wikimedia_count = sum(1 for s in results if (s.get("visual") or {}).get("source") == "wikimedia") + stock_count = sum(1 for s in results if (s.get("visual") or {}).get("source") not in (None, "wikimedia")) + print(f"[Footage Hunter v2] Wikimedia: {wikimedia_count} | Stock video: {stock_count}") return manifest diff --git a/agents/11_youtube_uploader.py b/agents/11_youtube_uploader.py index 0b25a15..7f77802 100644 --- a/agents/11_youtube_uploader.py +++ b/agents/11_youtube_uploader.py @@ -1,8 +1,14 @@ #!/usr/bin/env python3 """ -Agent 11 — YouTube Uploader -Uploads rendered video to YouTube with SEO metadata. -First run opens browser for OAuth. Subsequent runs use saved token. +Agent 11 — Uploader (stage 13) +Builds title/description/tags/thumbnail from the SEO data, then publishes via +the configured upload provider (providers.upload in obsidian.yaml): + + - "youtube": uploads to YouTube with SEO metadata and runs the YouTube-only + follow-ups (sources comment, era playlist, endscreen/cards). First run + opens a browser for OAuth; subsequent runs use the saved token. + - anything else ("local" by default, or your own UploadProvider): + provider.upload(video, title, description, tags, thumbnail). """ from __future__ import annotations @@ -922,6 +928,38 @@ def batch_set_public(video_ids, stagger=True): return results +def upload_with_provider(video_path, title, description, tags, thumbnail_path=None) -> dict: + """Publish through a non-YouTube upload provider (providers.upload). + + Returns the provider's result dict plus {"provider": }. Raises if the + provider returns no video_id (stage validation treats that as a failure). + """ + from providers.registry import get_provider, get_provider_name + provider = get_provider("upload") + name = get_provider_name("upload") + print(f"[Upload] Publishing via upload provider '{name}' ({provider.name})") + result = provider.upload( + Path(video_path), title, description, list(tags or []), + Path(thumbnail_path) if thumbnail_path else None, + ) + result = dict(result or {}) + if not result.get("video_id"): + raise RuntimeError(f"Upload provider '{name}' returned no video_id: {result}") + result.setdefault("url", "") + result["provider"] = name + print(f"[Upload] ✓ {result.get('status', 'done')}: {result.get('url', '')}") + return result + + +def _youtube_privacy(default: str) -> str: + """providers.upload.options.privacy (youtube provider) overrides the call default.""" + try: + from providers.registry import get_provider + return getattr(get_provider("upload"), "privacy", None) or default + except Exception: + return default + + def run(seo_data, manifest, verification_data=None, research_data=None, privacy="public", thumbnail_path=None): """Main entry point called from run_pipeline.py""" @@ -953,7 +991,13 @@ def run(seo_data, manifest, verification_data=None, research_data=None, privacy= seo_data["chapter_markers"] = real_chapters description = build_description(seo_data, verification_data) - result = upload_video(video_path, title, description, tags, thumbnail, privacy) + from providers.registry import get_provider_name + if get_provider_name("upload") != "youtube": + # Non-YouTube provider: publish and skip YouTube-only follow-ups + return upload_with_provider(video_path, title, description, tags, thumbnail) + + result = upload_video(video_path, title, description, tags, thumbnail, _youtube_privacy(privacy)) + result["provider"] = "youtube" video_id = result.get("video_id", "") # Post sources comment diff --git a/agents/thumbnail_agent.py b/agents/thumbnail_agent.py index 6c86b44..33a096d 100644 --- a/agents/thumbnail_agent.py +++ b/agents/thumbnail_agent.py @@ -1,9 +1,11 @@ """ Thumbnail Agent — Generate and score high-quality YouTube thumbnails. -Generates 3 distinct thumbnail concepts via Claude Sonnet, renders them with -fal.ai Flux Pro, adds bold text overlay with Pillow, scores each with Claude -Haiku vision, and returns the best one. +Generates distinct thumbnail concepts via the LLM, renders them with the +configured image provider (providers.images — fal.ai Flux Pro by default), +adds bold text overlay with Pillow, scores each with Claude Haiku vision (when +the LLM provider is Anthropic; otherwise variants get a neutral score), and +returns the best one. """ from __future__ import annotations @@ -19,18 +21,6 @@ from core.agent_wrapper import call_agent from clients.claude_client import HAIKU -# ── fal.ai setup ────────────────────────────────────────────────────────────── -try: - import fal_client -except ImportError: - import subprocess - subprocess.run([sys.executable, "-m", "pip", "install", "fal-client"], check=True) - import fal_client - -_fal_key = os.getenv("FAL_API_KEY", "") -if _fal_key: - os.environ["FAL_KEY"] = _fal_key - # ── Pillow setup ────────────────────────────────────────────────────────────── try: from PIL import Image, ImageDraw, ImageFont @@ -114,10 +104,10 @@ def _generate_concepts(title: str, angle: str, hook: str, topic: str = "") -> li return concepts[:5] -# ── Step 2: Generate thumbnail images via fal.ai ──────────────────────────── +# ── Step 2: Generate thumbnail images via the image provider ───────────────── def _generate_image(prompt: str, index: int) -> str | None: - """Generate a single thumbnail image with fal.ai Flux Pro. Returns path or None.""" + """Generate a single thumbnail image with the configured image provider. Returns path or None.""" THUMB_DIR.mkdir(parents=True, exist_ok=True) img_path = THUMB_DIR / f"thumb_variant_{index:02d}.jpg" @@ -132,15 +122,10 @@ def _generate_image(prompt: str, index: int) -> str | None: full_prompt = f"{prompt}, {thumb_style}" try: - result = fal_client.subscribe("fal-ai/flux-pro/v1.1-ultra", arguments={ - "prompt": full_prompt, - "image_size": {"width": 1280, "height": 720}, - "num_images": 1, - "safety_tolerance": "2", - }) - url = result["images"][0]["url"] - from pipeline.helpers import download_file - download_file(url, img_path) + from providers.registry import get_provider + from pipeline.images import _place_image + out = get_provider("images").generate(full_prompt, style="flux", width=1280, height=720) + _place_image(out, img_path) print(f" [Thumbnail] Variant {index + 1} generated ({img_path.stat().st_size // 1024}KB)") return str(img_path) except Exception as e: @@ -275,6 +260,15 @@ def _add_text_overlay(image_path: str, text: str, color_scheme: str) -> str: def _score_thumbnail(image_path: str, concept: dict) -> dict: """Score a thumbnail on clickability, readability, and emotional impact.""" + neutral = {"clickability": 5, "readability": 5, "emotional_impact": 5, "total": 15, + "brief_note": "scoring unavailable"} + try: + from clients.claude_client import anthropic_vision_available + if not anthropic_vision_available(): + return neutral + except Exception: + return neutral + with open(image_path, "rb") as f: img_b64 = base64.b64encode(f.read()).decode() @@ -379,12 +373,14 @@ def run(seo_data: dict, script_data: dict, angle_data: dict) -> dict | None: return None # ── Step 2: Generate images ────────────────────────────────────────────── - fal_key = os.getenv("FAL_API_KEY", "") - if not fal_key: - print("[Thumbnail] WARNING: FAL_API_KEY not set — cannot generate images") - return None - - print("[Thumbnail] Generating images with fal.ai Flux Pro...") + from providers.registry import get_provider_name + if get_provider_name("images") == "fal": + from providers.images.fal import FalProvider + if not FalProvider.has_credentials(): + print("[Thumbnail] WARNING: FAL_API_KEY not set — cannot generate images") + return None + + print(f"[Thumbnail] Generating images with image provider '{get_provider_name('images')}'...") variants = _generate_all_images(concepts) if not variants: diff --git a/clients/claude_client.py b/clients/claude_client.py index 3628b06..ca13962 100644 --- a/clients/claude_client.py +++ b/clients/claude_client.py @@ -1,10 +1,18 @@ """ -Shared Claude API client with model routing. -- Sonnet 4.6: creative, analytical, judgment-based agents -- Haiku 4.5: mechanical, formatting, classification agents +Shared LLM entry point with model routing. +- Opus / "premium": creative writing +- Sonnet / "full": analytical, judgment-based agents +- Haiku / "light": mechanical, formatting, classification agents + +call_claude() / call_claude_with_search() are the single entry point every +agent uses. With the default ``providers.llm.name: anthropic`` they call the +Anthropic API directly (see _anthropic_call). With any other LLM provider +(built-in "openai" or a custom ``module.ClassName``) they dispatch to +``provider.generate()`` / ``provider.generate_with_search()``, mapping the +Claude model id to a tier ("premium" / "full" / "light") that the provider +resolves via ``resolve_model()``. """ -import anthropic import copy import random import threading @@ -37,7 +45,34 @@ SONNET = "claude-sonnet-4-6" HAIKU = "claude-haiku-4-5-20251001" -client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + +class _LazyAnthropicClient: + """Module-level ``client`` that only builds anthropic.Anthropic on first use. + + Users who bring their own LLM provider don't need ANTHROPIC_API_KEY (or + even the anthropic package) just to import this module. Existing code + that does ``from clients.claude_client import client`` keeps working, and + tests can still patch ``clients.claude_client.client``. + """ + + _lock = threading.Lock() + + def __init__(self): + self._real = None + + def _get(self): + if self._real is None: + with self._lock: + if self._real is None: + import anthropic + self._real = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + return self._real + + def __getattr__(self, name): + return getattr(self._get(), name) + + +client = _LazyAnthropicClient() # ── Cost tracking (thread-safe) ─────────────────────────────────────────────── _PRICES = { # USD per 1M tokens @@ -82,6 +117,8 @@ def get_session_costs() -> dict: def reset_session_costs(): """Reset the session cost accumulator (call at start of each pipeline run).""" + global _untracked_notice_logged + _untracked_notice_logged = False with _cost_lock: _session_costs["tokens"] = {} _session_costs["usd_total"] = 0.0 @@ -243,6 +280,75 @@ def _repair_truncated_json(text: str): return result +# ── Provider dispatch ───────────────────────────────────────────────────────── + +_untracked_notice_logged = False + + +def model_tier(model: str | None) -> str: + """Map a Claude model id to a provider-neutral tier. + + OPUS → "premium", SONNET → "full", HAIKU → "light", anything else → "full". + """ + if not model: + return "full" + if model == OPUS: + return "premium" + if model == SONNET: + return "full" + if model == HAIKU: + return "light" + low = str(model).lower() + if "opus" in low: + return "premium" + if "haiku" in low: + return "light" + return "full" + + +def _custom_llm_provider(): + """Return the configured LLM provider, or None for the built-in Anthropic path. + + If the provider config can't be read at all we assume the default + (Anthropic). A custom provider that fails to load raises — the user asked + for it, so a silent fallback to Anthropic would be wrong. + """ + try: + from providers.registry import get_provider_name + name = get_provider_name("llm") + except Exception: + return None + if name == "anthropic": + return None + from providers.registry import get_provider + provider = get_provider("llm") + global _untracked_notice_logged + if not _untracked_notice_logged: + _untracked_notice_logged = True + print(f"[llm] Using LLM provider '{name}' ({getattr(provider, 'name', '?')}) — " + f"token usage/cost is not tracked for non-Anthropic providers, " + f"so the per-run budget cap does not apply to LLM spend.") + return provider + + +def llm_is_anthropic() -> bool: + """True when LLM calls go to the built-in Anthropic provider.""" + try: + from providers.registry import get_provider_name + return get_provider_name("llm") == "anthropic" + except Exception: + return True + + +def anthropic_vision_available() -> bool: + """True if Claude vision calls (image scoring) can be made. + + Vision scoring is an Anthropic-specific feature: it runs only when the LLM + provider is the built-in "anthropic" and ANTHROPIC_API_KEY is set. + """ + return llm_is_anthropic() and bool(os.environ.get("ANTHROPIC_API_KEY")) + + def call_claude( system_prompt: str, user_prompt: str, @@ -250,6 +356,72 @@ def call_claude( max_tokens: int = 4000, expect_json: bool = True, output_schema: dict | None = None, +) -> object: + """ + Call the configured LLM and return parsed JSON or raw text. + + Default (providers.llm.name: anthropic): Claude via the Anthropic API. + Otherwise: ``provider.generate()`` with model=provider.resolve_model(tier). + """ + provider = _custom_llm_provider() + if provider is None: + return _anthropic_call(system_prompt, user_prompt, model, max_tokens, + expect_json, output_schema) + result = provider.generate( + system_prompt=system_prompt, + user_prompt=user_prompt, + model=provider.resolve_model(model_tier(model)), + max_tokens=max_tokens, + expect_json=expect_json, + output_schema=output_schema, + ) + if expect_json and isinstance(result, str): + parsed = _parse_json_robust(result) + if parsed is None: + raise json.JSONDecodeError( + f"Could not parse JSON from LLM provider response ({len(result)} chars)", + result[:200], 0, + ) + return parsed + return result + + +def call_claude_with_search( + system_prompt: str, + user_prompt: str, + model: str = SONNET, + max_tokens: int = 4000, + output_schema: dict | None = None, +) -> str: + """ + Call the configured LLM with web search enabled. Returns raw text. + + Default: Claude with the web_search tool. Otherwise: + ``provider.generate_with_search()`` (providers without search may fall + back to plain generation). + """ + provider = _custom_llm_provider() + if provider is None: + return _anthropic_call_with_search(system_prompt, user_prompt, model, + max_tokens, output_schema) + return provider.generate_with_search( + system_prompt=system_prompt, + user_prompt=user_prompt, + model=provider.resolve_model(model_tier(model)), + max_tokens=max_tokens, + output_schema=output_schema, + ) + + +# ── Anthropic implementation (built-in "anthropic" provider) ───────────────── + +def _anthropic_call( + system_prompt: str, + user_prompt: str, + model: str = SONNET, + max_tokens: int = 4000, + expect_json: bool = True, + output_schema: dict | None = None, ) -> object: """ Call Claude and return parsed JSON or raw text. @@ -320,7 +492,7 @@ def call_claude( if getattr(response, "stop_reason", None) == "max_tokens" and max_tokens < 16000: bumped = min(max_tokens * 2, 16000) print(f"[claude_client] Response truncated at {max_tokens} tokens — retrying with {bumped}...") - return call_claude(system_prompt, user_prompt, model, max_tokens=bumped, expect_json=True, output_schema=output_schema) + return _anthropic_call(system_prompt, user_prompt, model, max_tokens=bumped, expect_json=True, output_schema=output_schema) # When schema is provided, API guarantees valid JSON — skip _parse_json_robust if output_schema is not None: @@ -336,7 +508,7 @@ def call_claude( ) -def call_claude_with_search( +def _anthropic_call_with_search( system_prompt: str, user_prompt: str, model: str = SONNET, diff --git a/core/pipeline_optimizer.py b/core/pipeline_optimizer.py index 427bae4..b90c4f5 100644 --- a/core/pipeline_optimizer.py +++ b/core/pipeline_optimizer.py @@ -39,7 +39,7 @@ 10: {"name": "Images", "agent": None, "critical": False}, 11: {"name": "Remotion Conversion", "agent": None, "critical": True}, 12: {"name": "Video Render", "agent": None, "critical": True}, - 13: {"name": "YouTube Upload", "agent": None, "critical": True}, + 13: {"name": "Upload", "agent": None, "critical": True}, } STAGE_NAMES = {k: v["name"] for k, v in STAGE_META.items()} diff --git a/core/profile.py b/core/profile.py index 80e4b8a..4e895e0 100644 --- a/core/profile.py +++ b/core/profile.py @@ -17,6 +17,7 @@ from __future__ import annotations import os +import re import yaml from pathlib import Path @@ -52,8 +53,15 @@ def _resolve_profile_name() -> str: return "documentary" +_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + def _load_profile(name: str) -> dict: """Load a profile YAML file by name.""" + # Reject path separators / traversal ("../x", "/etc/x") — names only. + if not isinstance(name, str) or not _PROFILE_NAME_RE.match(name): + logger.warning(f"[Profile] Invalid profile name {name!r}, falling back to documentary") + name = "documentary" profile_path = PROFILES_DIR / f"{name}.yaml" if not profile_path.exists(): logger.warning(f"[Profile] '{name}' not found at {profile_path}, falling back to documentary") diff --git a/core/utils.py b/core/utils.py index c6b4b6f..1b58c4c 100644 --- a/core/utils.py +++ b/core/utils.py @@ -9,7 +9,9 @@ def atomic_write_json(path: Path, data: Union[dict, list]): """Write JSON atomically via temp file + rename to prevent corruption.""" - path = Path(path) + # resolve() follows symlinks, so a file symlinked into a mounted data dir + # (Docker ./data) is replaced in place instead of the link being clobbered. + path = Path(path).resolve() path.parent.mkdir(parents=True, exist_ok=True) tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") try: diff --git a/dashboard/dist/assets/index-5UBuKdRI.css b/dashboard/dist/assets/index-5UBuKdRI.css deleted file mode 100644 index ab6f21f..0000000 --- a/dashboard/dist/assets/index-5UBuKdRI.css +++ /dev/null @@ -1 +0,0 @@ -/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"Courier New", Courier, "Liberation Mono", monospace;--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-blue-500:oklch(62.3% .214 259.815);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-bold:700;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg:#020408;--color-bg-1:#0a1220;--color-bg-2:#0e1a30;--color-border:#1e3a5a;--color-text:#b0cce0;--color-dim:#4a7090;--color-bright:#e0f0ff;--color-success:#22c55e;--color-error:#ef4444;--color-warning:#f59e0b;--color-running:#3b82f6}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-1\/2{top:50%}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:calc(var(--spacing) * 0)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.row-span-2{grid-row:span 2/span 2}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-\[3px\]{height:3px}.h-full{height:100%}.h-px{height:1px}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-\[calc\(100vh-200px\)\]{max-height:calc(100vh - 200px)}.min-h-\[20px\]{min-height:20px}.min-h-screen{min-height:100vh}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-80{width:calc(var(--spacing) * 80)}.w-\[285px\]{width:285px}.w-\[340px\]{width:340px}.w-\[400px\]{width:400px}.w-\[420px\]{width:420px}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-\[90vw\]{max-width:90vw}.max-w-\[120px\]{max-width:120px}.max-w-\[200px\]{max-width:200px}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[32px\]{min-width:32px}.min-w-\[70px\]{min-width:70px}.min-w-\[100px\]{min-width:100px}.min-w-\[140px\]{min-width:140px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-bg-1{border-color:var(--color-bg-1)}.border-border{border-color:var(--color-border)}.border-border\/30{border-color:#1e3a5a4d}@supports (color:color-mix(in lab,red,red)){.border-border\/30{border-color:color-mix(in oklab,var(--color-border) 30%,transparent)}}.border-border\/50{border-color:#1e3a5a80}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--color-border) 50%,transparent)}}.border-cyan-500\/30{border-color:#00b7d74d}@supports (color:color-mix(in lab,red,red)){.border-cyan-500\/30{border-color:color-mix(in oklab,var(--color-cyan-500) 30%,transparent)}}.border-dim{border-color:var(--color-dim)}.border-error\/30{border-color:#ef44444d}@supports (color:color-mix(in lab,red,red)){.border-error\/30{border-color:color-mix(in oklab,var(--color-error) 30%,transparent)}}.border-error\/50{border-color:#ef444480}@supports (color:color-mix(in lab,red,red)){.border-error\/50{border-color:color-mix(in oklab,var(--color-error) 50%,transparent)}}.border-green-500\/30{border-color:#00c7584d}@supports (color:color-mix(in lab,red,red)){.border-green-500\/30{border-color:color-mix(in oklab,var(--color-green-500) 30%,transparent)}}.border-purple-500\/30{border-color:#ac4bff4d}@supports (color:color-mix(in lab,red,red)){.border-purple-500\/30{border-color:color-mix(in oklab,var(--color-purple-500) 30%,transparent)}}.border-running\/20{border-color:#3b82f633}@supports (color:color-mix(in lab,red,red)){.border-running\/20{border-color:color-mix(in oklab,var(--color-running) 20%,transparent)}}.border-running\/30{border-color:#3b82f64d}@supports (color:color-mix(in lab,red,red)){.border-running\/30{border-color:color-mix(in oklab,var(--color-running) 30%,transparent)}}.border-running\/40{border-color:#3b82f666}@supports (color:color-mix(in lab,red,red)){.border-running\/40{border-color:color-mix(in oklab,var(--color-running) 40%,transparent)}}.border-running\/60{border-color:#3b82f699}@supports (color:color-mix(in lab,red,red)){.border-running\/60{border-color:color-mix(in oklab,var(--color-running) 60%,transparent)}}.border-success{border-color:var(--color-success)}.border-success\/30{border-color:#22c55e4d}@supports (color:color-mix(in lab,red,red)){.border-success\/30{border-color:color-mix(in oklab,var(--color-success) 30%,transparent)}}.border-success\/40{border-color:#22c55e66}@supports (color:color-mix(in lab,red,red)){.border-success\/40{border-color:color-mix(in oklab,var(--color-success) 40%,transparent)}}.border-success\/50{border-color:#22c55e80}@supports (color:color-mix(in lab,red,red)){.border-success\/50{border-color:color-mix(in oklab,var(--color-success) 50%,transparent)}}.border-warning\/30{border-color:#f59e0b4d}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--color-warning) 30%,transparent)}}.border-warning\/50{border-color:#f59e0b80}@supports (color:color-mix(in lab,red,red)){.border-warning\/50{border-color:color-mix(in oklab,var(--color-warning) 50%,transparent)}}.bg-bg-1{background-color:var(--color-bg-1)}.bg-bg-1\/80{background-color:#0a1220cc}@supports (color:color-mix(in lab,red,red)){.bg-bg-1\/80{background-color:color-mix(in oklab,var(--color-bg-1) 80%,transparent)}}.bg-bg-1\/95{background-color:#0a1220f2}@supports (color:color-mix(in lab,red,red)){.bg-bg-1\/95{background-color:color-mix(in oklab,var(--color-bg-1) 95%,transparent)}}.bg-bg-2{background-color:var(--color-bg-2)}.bg-bg-2\/50{background-color:#0e1a3080}@supports (color:color-mix(in lab,red,red)){.bg-bg-2\/50{background-color:color-mix(in oklab,var(--color-bg-2) 50%,transparent)}}.bg-bg-2\/95{background-color:#0e1a30f2}@supports (color:color-mix(in lab,red,red)){.bg-bg-2\/95{background-color:color-mix(in oklab,var(--color-bg-2) 95%,transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab,red,red)){.bg-black\/70{background-color:color-mix(in oklab,var(--color-black) 70%,transparent)}}.bg-border{background-color:var(--color-border)}.bg-dim\/20{background-color:#4a709033}@supports (color:color-mix(in lab,red,red)){.bg-dim\/20{background-color:color-mix(in oklab,var(--color-dim) 20%,transparent)}}.bg-dim\/50{background-color:#4a709080}@supports (color:color-mix(in lab,red,red)){.bg-dim\/50{background-color:color-mix(in oklab,var(--color-dim) 50%,transparent)}}.bg-error{background-color:var(--color-error)}.bg-error\/10{background-color:#ef44441a}@supports (color:color-mix(in lab,red,red)){.bg-error\/10{background-color:color-mix(in oklab,var(--color-error) 10%,transparent)}}.bg-error\/20{background-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.bg-error\/20{background-color:color-mix(in oklab,var(--color-error) 20%,transparent)}}.bg-error\/90{background-color:#ef4444e6}@supports (color:color-mix(in lab,red,red)){.bg-error\/90{background-color:color-mix(in oklab,var(--color-error) 90%,transparent)}}.bg-running{background-color:var(--color-running)}.bg-running\/10{background-color:#3b82f61a}@supports (color:color-mix(in lab,red,red)){.bg-running\/10{background-color:color-mix(in oklab,var(--color-running) 10%,transparent)}}.bg-running\/20{background-color:#3b82f633}@supports (color:color-mix(in lab,red,red)){.bg-running\/20{background-color:color-mix(in oklab,var(--color-running) 20%,transparent)}}.bg-running\/60{background-color:#3b82f699}@supports (color:color-mix(in lab,red,red)){.bg-running\/60{background-color:color-mix(in oklab,var(--color-running) 60%,transparent)}}.bg-success{background-color:var(--color-success)}.bg-success\/10{background-color:#22c55e1a}@supports (color:color-mix(in lab,red,red)){.bg-success\/10{background-color:color-mix(in oklab,var(--color-success) 10%,transparent)}}.bg-success\/20{background-color:#22c55e33}@supports (color:color-mix(in lab,red,red)){.bg-success\/20{background-color:color-mix(in oklab,var(--color-success) 20%,transparent)}}.bg-success\/30{background-color:#22c55e4d}@supports (color:color-mix(in lab,red,red)){.bg-success\/30{background-color:color-mix(in oklab,var(--color-success) 30%,transparent)}}.bg-success\/60{background-color:#22c55e99}@supports (color:color-mix(in lab,red,red)){.bg-success\/60{background-color:color-mix(in oklab,var(--color-success) 60%,transparent)}}.bg-warning{background-color:var(--color-warning)}.bg-warning\/10{background-color:#f59e0b1a}@supports (color:color-mix(in lab,red,red)){.bg-warning\/10{background-color:color-mix(in oklab,var(--color-warning) 10%,transparent)}}.bg-warning\/20{background-color:#f59e0b33}@supports (color:color-mix(in lab,red,red)){.bg-warning\/20{background-color:color-mix(in oklab,var(--color-warning) 20%,transparent)}}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-cyan-500{--tw-gradient-from:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-600{--tw-gradient-from:var(--color-purple-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-600{--tw-gradient-to:var(--color-indigo-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-1{padding-left:calc(var(--spacing) * 1)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.tracking-\[2px\]{--tw-tracking:2px;letter-spacing:2px}.tracking-\[4px\]{--tw-tracking:4px;letter-spacing:4px}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-black{color:var(--color-black)}.text-bright{color:var(--color-bright)}.text-cyan-400{color:var(--color-cyan-400)}.text-dim{color:var(--color-dim)}.text-error{color:var(--color-error)}.text-error\/80{color:#ef4444cc}@supports (color:color-mix(in lab,red,red)){.text-error\/80{color:color-mix(in oklab,var(--color-error) 80%,transparent)}}.text-green-400{color:var(--color-green-400)}.text-purple-400{color:var(--color-purple-400)}.text-running{color:var(--color-running)}.text-success{color:var(--color-success)}.text-text{color:var(--color-text)}.text-warning{color:var(--color-warning)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow-\[0_0_6px_rgba\(59\,130\,246\,0\.5\)\]{--tw-shadow:0 0 6px var(--tw-shadow-color,#3b82f680);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media(hover:hover){.hover\:border-dim:hover{border-color:var(--color-dim)}.hover\:bg-error\/10:hover{background-color:#ef44441a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-error\/10:hover{background-color:color-mix(in oklab,var(--color-error) 10%,transparent)}}.hover\:bg-running\/10:hover{background-color:#3b82f61a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-running\/10:hover{background-color:color-mix(in oklab,var(--color-running) 10%,transparent)}}.hover\:bg-running\/30:hover{background-color:#3b82f64d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-running\/30:hover{background-color:color-mix(in oklab,var(--color-running) 30%,transparent)}}.hover\:bg-success\/10:hover{background-color:#22c55e1a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-success\/10:hover{background-color:color-mix(in oklab,var(--color-success) 10%,transparent)}}.hover\:bg-warning\/10:hover{background-color:#f59e0b1a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-warning\/10:hover{background-color:color-mix(in oklab,var(--color-warning) 10%,transparent)}}.hover\:from-purple-500:hover{--tw-gradient-from:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-indigo-500:hover{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:text-bright:hover{color:var(--color-bright)}.hover\:text-error:hover{color:var(--color-error)}.hover\:text-text:hover{color:var(--color-text)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-\[0_0_10px_rgba\(59\,130\,246\,0\.7\)\]:hover{--tw-shadow:0 0 10px var(--tw-shadow-color,#3b82f6b3);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:not-sr-only:focus{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.focus\:absolute:focus{position:absolute}.focus\:top-2:focus{top:calc(var(--spacing) * 2)}.focus\:left-2:focus{left:calc(var(--spacing) * 2)}.focus\:z-50:focus{z-index:50}.focus\:rounded:focus{border-radius:.25rem}.focus\:border-running:focus{border-color:var(--color-running)}.focus\:border-running\/50:focus{border-color:#3b82f680}@supports (color:color-mix(in lab,red,red)){.focus\:border-running\/50:focus{border-color:color-mix(in oklab,var(--color-running) 50%,transparent)}}.focus\:bg-bg-1:focus{background-color:var(--color-bg-1)}.focus\:px-3:focus{padding-inline:calc(var(--spacing) * 3)}.focus\:py-1\.5:focus{padding-block:calc(var(--spacing) * 1.5)}.focus\:text-sm:focus{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.focus\:text-bright:focus{color:var(--color-bright)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:p-6{padding:calc(var(--spacing) * 6)}}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--color-bg);color:var(--color-text);font-family:var(--font-mono);background-image:linear-gradient(#1e3a5a12 1px,#0000 1px),linear-gradient(90deg,#1e3a5a12 1px,#0000 1px);background-size:40px 40px;min-height:100vh;animation:25s linear infinite gridDrift}@keyframes gridDrift{to{background-position:0 40px}}@keyframes borderPulse{0%,to{opacity:.5}50%{opacity:1}}@keyframes shimmer{0%{background-position:-200% 0}to{background-position:200% 0}}@media(prefers-reduced-motion:reduce){*,:before,:after{transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media(max-width:767px){.desktop-nav{display:none}.mobile-tab-bar{z-index:30;background:var(--color-bg-1);border-top:1px solid var(--color-border);padding:.5rem 0;display:flex;position:fixed;bottom:0;left:0;right:0}.mobile-tab-bar button{color:var(--color-dim);cursor:pointer;background:0 0;border:none;flex-direction:column;flex:1;align-items:center;gap:2px;padding:.25rem;font-size:9px;display:flex}.mobile-tab-bar button.active{color:var(--color-bright)}main{padding-bottom:60px}}@media(min-width:768px){.mobile-tab-bar{display:none}}.agent-card-3d{perspective:800px;transition:transform .3s ease-out}@media(hover:hover)and (min-width:768px){.agent-card-3d:hover{z-index:1}}@media(prefers-reduced-motion:reduce),(hover:none){.agent-card-3d{perspective:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/dashboard/dist/assets/index-BM0_AibZ.js b/dashboard/dist/assets/index-BM0_AibZ.js deleted file mode 100644 index f47d062..0000000 --- a/dashboard/dist/assets/index-BM0_AibZ.js +++ /dev/null @@ -1,16 +0,0 @@ -var zr=Object.defineProperty;var Jr=(e,t,n)=>t in e?zr(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var It=(e,t,n)=>Jr(e,typeof t!="symbol"?t+"":t,n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))i(s);new MutationObserver(s=>{for(const l of s)if(l.type==="childList")for(const o of l.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function n(s){const l={};return s.integrity&&(l.integrity=s.integrity),s.referrerPolicy&&(l.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?l.credentials="include":s.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function i(s){if(s.ep)return;s.ep=!0;const l=n(s);fetch(s.href,l)}})();var rt,T,sr,lr,z,Ft,or,ar,cr,yt,ht,pt,qe={},He=[],Qr=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,nt=Array.isArray;function G(e,t){for(var n in t)e[n]=t[n];return e}function wt(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function Xr(e,t,n){var i,s,l,o={};for(l in t)l=="key"?i=t[l]:l=="ref"?s=t[l]:o[l]=t[l];if(arguments.length>2&&(o.children=arguments.length>3?rt.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(l in e.defaultProps)o[l]===void 0&&(o[l]=e.defaultProps[l]);return Ie(e,o,i,s,null)}function Ie(e,t,n,i,s){var l={type:e,props:t,key:n,ref:i,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++sr,__i:-1,__u:0};return s==null&&T.vnode!=null&&T.vnode(l),l}function Ae(e){return e.children}function pe(e,t){this.props=e,this.context=t}function xe(e,t){if(t==null)return e.__?xe(e.__,e.__i+1):null;for(var n;tt&&z.sort(ar),e=z.shift(),t=z.length,Zr(e)}finally{z.length=Ve.__r=0}}function ur(e,t,n,i,s,l,o,c,u,d,f){var a,p,h,b,w,_,g,v=i&&i.__k||He,E=t.length;for(u=en(n,t,v,u,E),a=0;a0?o=e.__k[l]=Ie(o.type,o.props,o.key,o.ref?o.ref:null,o.__v):e.__k[l]=o,u=l+p,o.__=e,o.__b=e.__b+1,c=null,(d=o.__i=tn(o,n,u,a))!=-1&&(a--,(c=n[d])&&(c.__u|=2)),c==null||c.__v==null?(d==-1&&(s>f?p--:su?p--:p++,o.__u|=4))):e.__k[l]=null;if(a)for(l=0;l(f?1:0)){for(s=n-1,l=n+1;s>=0||l=0?s--:l++])!=null&&(2&d.__u)==0&&c==d.key&&u==d.type)return o}return-1}function jt(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Qr.test(t)?n:n+"px"}function Re(e,t,n,i,s){var l,o;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof i=="string"&&(e.style.cssText=i=""),i)for(t in i)n&&t in n||jt(e.style,t,"");if(n)for(t in n)i&&n[t]==i[t]||jt(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")l=t!=(t=t.replace(cr,"$1")),o=t.toLowerCase(),t=o in e||t=="onFocusOut"||t=="onFocusIn"?o.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+l]=n,n?i?n.u=i.u:(n.u=yt,e.addEventListener(t,l?pt:ht,l)):e.removeEventListener(t,l?pt:ht,l);else{if(s=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Ut(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=yt++;else if(t.t0?e:nt(e)?e.map(fr):G({},e)}function rn(e,t,n,i,s,l,o,c,u){var d,f,a,p,h,b,w,_=n.props||qe,g=t.props,v=t.type;if(v=="svg"?s="http://www.w3.org/2000/svg":v=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),l!=null){for(d=0;d=n.__.length&&n.__.push({}),n.__[e]}function k(e){return Te=1,on(gr,e)}function on(e,t,n){var i=Tt(Se++,2);if(i.t=e,!i.__c&&(i.__=[gr(void 0,t),function(c){var u=i.__N?i.__N[0]:i.__[0],d=i.t(u,c);u!==d&&(i.__N=[d,i.__[1]],i.__c.setState({}))}],i.__c=$,!$.__f)){var s=function(c,u,d){if(!i.__c.__H)return!0;var f=i.__c.__H.__.filter(function(p){return p.__c});if(f.every(function(p){return!p.__N}))return!l||l.call(this,c,u,d);var a=i.__c.props!==c;return f.some(function(p){if(p.__N){var h=p.__[0];p.__=p.__N,p.__N=void 0,h!==p.__[0]&&(a=!0)}}),l&&l.call(this,c,u,d)||a};$.__f=!0;var l=$.shouldComponentUpdate,o=$.componentWillUpdate;$.componentWillUpdate=function(c,u,d){if(this.__e){var f=l;l=void 0,s(c,u,d),l=f}o&&o.call(this,c,u,d)},$.shouldComponentUpdate=s}return i.__N||i.__}function O(e,t){var n=Tt(Se++,3);!A.__s&&vr(n.__H,t)&&(n.__=e,n.u=t,$.__H.__h.push(n))}function fe(e){return Te=5,be(function(){return{current:e}},[])}function be(e,t){var n=Tt(Se++,7);return vr(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function Pe(e,t){return Te=8,be(function(){return e},t)}function an(){for(var e;e=br.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Fe),t.__h.some(xt),t.__h=[]}catch(n){t.__h=[],A.__e(n,e.__v)}}}A.__b=function(e){$=null,Ht&&Ht(e)},A.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Wt&&Wt(e,t)},A.__r=function(e){Vt&&Vt(e),Se=0;var t=($=e.__c).__H;t&&(lt===$?(t.__h=[],$.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(Fe),t.__h.some(xt),t.__h=[],Se=0)),lt=$},A.diffed=function(e){Gt&&Gt(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(br.push(t)!==1&&qt===A.requestAnimationFrame||((qt=A.requestAnimationFrame)||cn)(an)),t.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),lt=$=null},A.__c=function(e,t){t.some(function(n){try{n.__h.some(Fe),n.__h=n.__h.filter(function(i){return!i.__||xt(i)})}catch(i){t.some(function(s){s.__h&&(s.__h=[])}),t=[],A.__e(i,n.__v)}}),Bt&&Bt(e,t)},A.unmount=function(e){Kt&&Kt(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(i){try{Fe(i)}catch(s){t=s}}),n.__H=void 0,t&&A.__e(t,n.__v))};var Yt=typeof requestAnimationFrame=="function";function cn(e){var t,n=function(){clearTimeout(i),Yt&&cancelAnimationFrame(t),setTimeout(e)},i=setTimeout(n,35);Yt&&(t=requestAnimationFrame(n))}function Fe(e){var t=$,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),$=t}function xt(e){var t=$;e.__c=e.__(),$=t}function vr(e,t){return!e||e.length!==t.length||t.some(function(n,i){return n!==e[i]})}function gr(e,t){return typeof t=="function"?t(e):t}var dn=Symbol.for("preact-signals");function it(){if(K>1)K--;else{var e,t=!1;for((function(){var s=Be;for(Be=void 0;s!==void 0;)s.S.v===s.v&&(s.S.i=s.i),s=s.o})();we!==void 0;){var n=we;for(we=void 0,Ge++;n!==void 0;){var i=n.u;if(n.u=void 0,n.f&=-3,!(8&n.f)&&yr(n))try{n.c()}catch(s){t||(e=s,t=!0)}n=i}}if(Ge=0,K--,t)throw e}}function Et(e){if(K>0)return e();bt=++un,K++;try{return e()}finally{it()}}var S=void 0;function mr(e){var t=S;S=void 0;try{return e()}finally{S=t}}var we=void 0,K=0,Ge=0,un=0,bt=0,Be=void 0,Ke=0;function _r(e){if(S!==void 0){var t=e.n;if(t===void 0||t.t!==S)return t={i:0,S:e,p:S.s,n:void 0,t:S,e:void 0,x:void 0,r:t},S.s!==void 0&&(S.s.n=t),S.s=t,e.n=t,32&S.f&&e.S(t),t;if(t.i===-1)return t.i=0,t.n!==void 0&&(t.n.p=t.p,t.p!==void 0&&(t.p.n=t.n),t.p=S.s,t.n=void 0,S.s.n=t,S.s=t),t}}function P(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.l=0,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}P.prototype.brand=dn;P.prototype.h=function(){return!0};P.prototype.S=function(e){var t=this,n=this.t;n!==e&&e.e===void 0&&(e.x=n,this.t=e,n!==void 0?n.e=e:mr(function(){var i;(i=t.W)==null||i.call(t)}))};P.prototype.U=function(e){var t=this;if(this.t!==void 0){var n=e.e,i=e.x;n!==void 0&&(n.x=i,e.e=void 0),i!==void 0&&(i.e=n,e.x=void 0),e===this.t&&(this.t=i,i===void 0&&mr(function(){var s;(s=t.Z)==null||s.call(t)}))}};P.prototype.subscribe=function(e){var t=this;return Oe(function(){var n=t.value,i=S;S=void 0;try{e(n)}finally{S=i}},{name:"sub"})};P.prototype.valueOf=function(){return this.value};P.prototype.toString=function(){return this.value+""};P.prototype.toJSON=function(){return this.value};P.prototype.peek=function(){var e=S;S=void 0;try{return this.value}finally{S=e}};Object.defineProperty(P.prototype,"value",{get:function(){var e=_r(this);return e!==void 0&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(Ge>100)throw new Error("Cycle detected");(function(n){K!==0&&Ge===0&&n.l!==bt&&(n.l=bt,Be={S:n,v:n.v,i:n.i,o:Be})})(this),this.v=e,this.i++,Ke++,K++;try{for(var t=this.t;t!==void 0;t=t.x)t.t.N()}finally{it()}}}});function m(e,t){return new P(e,t)}function yr(e){for(var t=e.s;t!==void 0;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function wr(e){for(var t=e.s;t!==void 0;t=t.n){var n=t.S.n;if(n!==void 0&&(t.r=n),t.S.n=t,t.i=-1,t.n===void 0){e.s=t;break}}}function kr(e){for(var t=e.s,n=void 0;t!==void 0;){var i=t.p;t.i===-1?(t.S.U(t),i!==void 0&&(i.n=t.n),t.n!==void 0&&(t.n.p=i)):n=t,t.S.n=t.r,t.r!==void 0&&(t.r=void 0),t=i}e.s=n}function de(e,t){P.call(this,void 0),this.x=e,this.s=void 0,this.g=Ke-1,this.f=4,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}de.prototype=new P;de.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if((36&this.f)==32||(this.f&=-5,this.g===Ke))return!0;if(this.g=Ke,this.f|=1,this.i>0&&!yr(this))return this.f&=-2,!0;var e=S;try{wr(this),S=this;var t=this.x();(16&this.f||this.v!==t||this.i===0)&&(this.v=t,this.f&=-17,this.i++)}catch(n){this.v=n,this.f|=16,this.i++}return S=e,kr(this),this.f&=-2,!0};de.prototype.S=function(e){if(this.t===void 0){this.f|=36;for(var t=this.s;t!==void 0;t=t.n)t.S.S(t)}P.prototype.S.call(this,e)};de.prototype.U=function(e){if(this.t!==void 0&&(P.prototype.U.call(this,e),this.t===void 0)){this.f&=-33;for(var t=this.s;t!==void 0;t=t.n)t.S.U(t)}};de.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;e!==void 0;e=e.x)e.t.N()}};Object.defineProperty(de.prototype,"value",{get:function(){if(1&this.f)throw new Error("Cycle detected");var e=_r(this);if(this.h(),e!==void 0&&(e.i=this.i),16&this.f)throw this.v;return this.v}});function ae(e,t){return new de(e,t)}function Sr(e){var t=e.m;if(e.m=void 0,typeof t=="function"){K++;var n=S;S=void 0;try{t()}catch(i){throw e.f&=-2,e.f|=8,Ct(e),i}finally{S=n,it()}}}function Ct(e){for(var t=e.s;t!==void 0;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,Sr(e)}function hn(e){if(S!==this)throw new Error("Out-of-order effect");kr(this),S=e,this.f&=-2,8&this.f&&Ct(this),it()}function _e(e,t){this.x=e,this.m=void 0,this.s=void 0,this.u=void 0,this.f=32,this.name=t==null?void 0:t.name}_e.prototype.c=function(){var e=this.S();try{if(8&this.f||this.x===void 0)return;var t=this.x();typeof t=="function"&&(this.m=t)}finally{e()}};_e.prototype.S=function(){if(1&this.f)throw new Error("Cycle detected");this.f|=1,this.f&=-9,Sr(this),wr(this),K++;var e=S;return S=this,hn.bind(this,e)};_e.prototype.N=function(){2&this.f||(this.f|=2,this.u=we,we=this)};_e.prototype.d=function(){this.f|=8,1&this.f||Ct(this)};_e.prototype.dispose=function(){this.d()};function Oe(e,t){var n=new _e(e,t);try{n.c()}catch(s){throw n.d(),s}var i=n.d.bind(n);return i[Symbol.dispose]=i,i}var Tr,Le,pn=typeof window<"u"&&!!window.__PREACT_SIGNALS_DEVTOOLS__,Er=[];Oe(function(){Tr=this.N})();function ye(e,t){T[e]=t.bind(null,T[e]||function(){})}function We(e){if(Le){var t=Le;Le=void 0,t()}Le=e&&e.S()}function Cr(e){var t=this,n=e.data,i=xn(n);i.value=n;var s=be(function(){for(var c=t,u=t.__v;u=u.__;)if(u.__c){u.__c.__$f|=4;break}var d=ae(function(){var h=i.value.value;return h===0?0:h===!0?"":h||""}),f=ae(function(){return!Array.isArray(d.value)&&!lr(d.value)}),a=Oe(function(){if(this.N=$r,f.value){var h=d.value;c.__v&&c.__v.__e&&c.__v.__e.nodeType===3&&(c.__v.__e.data=h)}}),p=t.__$u.d;return t.__$u.d=function(){a(),p.call(this)},[f,d]},[]),l=s[0],o=s[1];return l.value?o.peek():o.value}Cr.displayName="ReactiveTextNode";Object.defineProperties(P.prototype,{constructor:{configurable:!0,value:void 0},type:{configurable:!0,value:Cr},props:{configurable:!0,get:function(){var e=this;return{data:{get value(){return e.value}}}}},__b:{configurable:!0,value:1}});ye("__b",function(e,t){if(typeof t.type=="string"){var n,i=t.props;for(var s in i)if(s!=="children"){var l=i[s];l instanceof P&&(n||(t.__np=n={}),n[s]=l,i[s]=l.peek())}}e(t)});ye("__r",function(e,t){if(e(t),t.type!==Ae){We();var n,i=t.__c;i&&(i.__$f&=-2,(n=i.__$u)===void 0&&(i.__$u=n=(function(s,l){var o;return Oe(function(){o=this},{name:l}),o.c=s,o})(function(){var s;pn&&((s=n.y)==null||s.call(n)),i.__$f|=1,i.setState({})},typeof t.type=="function"?t.type.displayName||t.type.name:""))),We(n)}});ye("__e",function(e,t,n,i){We(),e(t,n,i)});ye("diffed",function(e,t){We();var n;if(typeof t.type=="string"&&(n=t.__e)){var i=t.__np,s=t.props;if(i){var l=n.U;if(l)for(var o in l){var c=l[o];c!==void 0&&!(o in i)&&(c.d(),l[o]=void 0)}else l={},n.U=l;for(var u in i){var d=l[u],f=i[u];d===void 0?(d=fn(n,u,f),l[u]=d):d.o(f,s)}for(var a in i)s[a]=i[a]}}e(t)});function fn(e,t,n,i){var s=t in e&&e.ownerSVGElement===void 0,l=m(n),o=n.peek();return{o:function(c,u){l.value=c,o=c.peek()},d:Oe(function(){this.N=$r;var c=l.value.value;o!==c?(o=void 0,s?e[t]=c:c!=null&&(c!==!1||t[4]==="-")?e.setAttribute(t,c):e.removeAttribute(t)):o=void 0})}}ye("unmount",function(e,t){if(typeof t.type=="string"){var n=t.__e;if(n){var i=n.U;if(i){n.U=void 0;for(var s in i){var l=i[s];l&&l.d()}}}t.__np=void 0}else{var o=t.__c;if(o){var c=o.__$u;c&&(o.__$u=void 0,c.d())}}e(t)});ye("__h",function(e,t,n,i){(i<3||i===9)&&(t.__$f|=2),e(t,n,i)});pe.prototype.shouldComponentUpdate=function(e,t){if(this.__R)return!0;var n=this.__$u,i=n&&n.s!==void 0;for(var s in t)return!0;if(this.__f||typeof this.u=="boolean"&&this.u===!0){var l=2&this.__$f;if(!(i||l||4&this.__$f)||1&this.__$f)return!0}else if(!(i||4&this.__$f)||3&this.__$f)return!0;for(var o in e)if(o!=="__source"&&e[o]!==this.props[o])return!0;for(var c in this.props)if(!(c in e))return!0;return!1};function xn(e,t){return be(function(){return m(e,t)},[])}var bn=function(e){queueMicrotask(function(){queueMicrotask(e)})};function vn(){Et(function(){for(var e;e=Er.shift();)Tr.call(e)})}function $r(){Er.push(this)===1&&(T.requestAnimationFrame||bn)(vn)}const gn=()=>{try{return location.hash.replace("#","")||null}catch{return null}},Ar=e=>{var t;try{return((t=globalThis.localStorage)==null?void 0:t.getItem(e))??null}catch{return null}},Ee=m("idle"),D=m(!1),R=m(0),ve=m(""),Ye=m([]),ke=m(null),ge=m(null),$t=m(!1),vt=m({}),me=m(null),ze=m(!1),Or=m(0),At=m(0),ie=m("healthy"),gt=m(null),W=m(gn()||Ar("obsidian-view")||"home"),Je=m(!1),X=m(null),se=m(!1),re=m(!1),Rr=m(Ar("obsidian-cd")||""),Pr=m({}),Ce=m({}),Lr=m(null),Nr=m(null),Qe=m(null),Ot=m(null),mt=m({queue:[],errors:[],cost:[]}),mn=ae(()=>D.value?"running":Ee.value==="done"&&ge.value&&Rr.value!==ge.value?"just-completed":["failed","error","killed"].includes(Ee.value)?"error":"idle"),ne=m([]);let _n=0;function C(e,t="info"){const n=_n++,i=ne.value.length>=3?ne.value.slice(1):ne.value;ne.value=[...i,{id:n,message:e,type:t}],t==="info"&&setTimeout(()=>Dr(n),4e3)}function Dr(e){ne.value=ne.value.filter(t=>t.id!==e)}function yn(){return r("div",{class:"fixed top-4 right-4 z-50 flex flex-col gap-2",style:{maxWidth:"360px"},children:ne.value.map(e=>r(wn,{toast:e,onDismiss:()=>Dr(e.id)},e.id))})}function wn({toast:e,onDismiss:t}){const n=e.type==="error"?"bg-error/90":"bg-bg-2/95 border border-border",i=e.type==="error"?"text-white":"text-text";return r("div",{role:e.type==="error"?"alert":"status",class:`${n} ${i} px-4 py-3 rounded text-sm flex items-center gap-2`,children:[r("span",{class:"flex-1",children:e.message}),r("button",{onClick:t,class:"text-dim hover:text-bright ml-2","aria-label":"Dismiss",children:"×"})]})}const kn=typeof window<"u"?window.__TRIGGER_KEY__:"",Z=m(null),_t=m(!1),Xe=m(null),ce=m({}),B=m({});async function Ze(e,t){const n=await fetch(e,{...t,headers:{"X-Trigger-Key":kn,...t==null?void 0:t.headers}});if(n.status===401)throw window.location.href="/login",new Error("Unauthorized");if(!n.ok){const i=await n.json().catch(()=>({}));throw new Error(i.error||`HTTP ${n.status}`)}return n.json()}async function Rt(){_t.value=!0,Xe.value=null;try{const[e,t]=await Promise.allSettled([Ze("/api/overrides"),Ze("/api/correlation")]),n=e.status==="fulfilled"?e.value:{overrides:[],bounds:{},defaults:{},history:[]},i=t.status==="fulfilled"?t.value:{layers:{},recommendations:[],maturity:"early"};Z.value={...n,correlation:i}}catch(e){Xe.value=e.message}finally{_t.value=!1}}async function Sn(e,t){B.value={...B.value,[e]:!0};try{await Ze("/api/overrides/approve",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e,value:t})});const{[e]:n,...i}=ce.value;ce.value=i,await Rt(),C(`Saved: ${e}`)}catch(n){C(n.message,"error")}finally{const{[e]:n,...i}=B.value;B.value=i}}async function Tn(e){B.value={...B.value,[e]:!0};try{await Ze("/api/overrides/revert",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e})});const{[e]:t,...n}=ce.value;ce.value=n,await Rt(),C(`Reverted: ${e}`)}catch(t){C(t.message,"error")}finally{const{[e]:t,...n}=B.value;B.value=n}}function ot(e,t){ce.value={...ce.value,[e]:t}}function En(e,t){var s;const i=(((s=Z.value)==null?void 0:s.overrides)??[]).find(l=>l.key===e);return i?i.value:t}function Cn(e){var t;return(((t=Z.value)==null?void 0:t.overrides)??[]).some(n=>n.key===e)}function $n(){Z.value=null}let M=null,ee=1e3,Q=null;const zt=3e4,An=2e3;function Me(){if(M)return;const e=typeof window<"u"?window.__TRIGGER_KEY__:"",t=`/stream?key=${encodeURIComponent(e)}`;M=new EventSource(t),ee=1e3,Q&&(clearTimeout(Q),Q=null),ze.value=!0,M.addEventListener("state",n=>{try{const i=JSON.parse(n.data);Et(()=>{const s=D.value;Ee.value=i.last_status,D.value=i.running,R.value=i.stage_num,ve.value=i.topic,ke.value=i.started_at,ge.value=i.finished_at,$t.value=i.analytics_running,i.active_jobs&&(vt.value=i.active_jobs),s&&!i.running&&(me.value=null),!s&&i.running&&(Je.value=!0)})}catch{}}),M.addEventListener("log",n=>{try{const i=JSON.parse(n.data);Ye.value=[...Ye.value,...i].slice(-An)}catch{}}),M.addEventListener("cost",n=>{try{const i=JSON.parse(n.data);me.value=i.usd_total}catch{}}),M.addEventListener("stage_summary",n=>{try{const i=JSON.parse(n.data);Pr.value=i}catch{}}),M.addEventListener("intel_updated",()=>{Ce.value={}}),M.addEventListener("tuning_updated",()=>{$n()}),M.addEventListener("reconnect",()=>{Jt(),setTimeout(Me,1e3)}),M.onerror=()=>{ze.value=!1,Jt(),fetch("/api/pulse",{headers:{"X-Trigger-Key":e}}).then(n=>{if(n.status===401){window.location.href="/login";return}ee=Math.min(ee*2,zt),Q=setTimeout(Me,ee)}).catch(()=>{ee=Math.min(ee*2,zt),Q=setTimeout(Me,ee)})}}function Jt(){ze.value=!1,Q&&(clearTimeout(Q),Q=null),M&&(M.close(),M=null)}const On=typeof window<"u"?window.__TRIGGER_KEY__:"";async function L(e,t){const n=await fetch(e,{...t,headers:{"X-Trigger-Key":On,...t==null?void 0:t.headers}});if(n.status===401)throw window.location.href="/login",new Error("Unauthorized");if(!n.ok)throw new Error(`HTTP ${n.status}`);return n.json()}async function Qt(){const e=await L("/api/pulse");return Et(()=>{Ee.value=e.status,D.value=e.running,R.value=e.stage_num,ve.value=e.topic,ke.value=e.started_at,ge.value=e.finished_at,$t.value=e.analytics_running,Or.value=e.queue_depth??0,At.value=e.errors_24h??0,ie.value=e.health??"healthy",gt.value=e.last_cost_usd;const t=mt.value;mt.value={queue:[...t.queue,e.queue_depth??0].slice(-10),errors:[...t.errors,e.errors_24h??0].slice(-10),cost:[...t.cost,e.last_cost_usd??0].slice(-10)}}),e}async function Rn(e){const t=await L(`/api/dashboard?sections=${e}`),n={...Ce.value};for(const[i,s]of Object.entries(t))n[i]=s;return Ce.value=n,t}async function Ir(){return L("/history")}async function Pn(){return L("/schedule")}async function Fr(e,t=1){return L("/trigger",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topic:e,resume_from:t})})}async function Ln(){return L("/kill",{method:"POST"})}async function Nn(){return L("/queue")}async function Dn(e){return L("/queue/add",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topic:e})})}async function In(e){return L("/queue/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:e})})}let je=null;function Fn(){Mn(),Qt().catch(()=>{});const e=()=>{const t=D.value?15e3:5e3;je=setTimeout(()=>{Qt().catch(()=>{}).finally(e)},t)};e()}function Mn(){je!==null&&(clearTimeout(je),je=null)}async function jn(){const e=await Ir();e.length>0&&(Lr.value=e[e.length-1]??null)}async function Un(){const e=await Pn();Nr.value=e.schedule}async function qn(){try{return await L("/api/last-error")}catch{return null}}async function Hn(e){try{const t=e?`/api/run-detail?run_id=${e}`:"/api/run-detail";return await L(t)}catch{return null}}async function Vn(e=24){const t=await L(`/api/errors?hours=${e}`);return Qe.value=t,t}async function Gn(e=7){const t=await L(`/api/agent-stats?days=${e}`);return Ot.value=t,t}async function Mr(){return L("/api/setup/status")}async function Bn(e,t){return L("/api/setup/validate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e,value:t})})}async function Kn(e){return L("/api/setup/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})}let at=null;const jr="obsidian-sound";function Ur(){var e;try{return typeof localStorage<"u"&&((e=globalThis.localStorage)==null?void 0:e.getItem(jr))==="1"}catch{return!1}}function Wn(){if(!at)try{at=new AudioContext}catch{return null}return at}function qr(){var t;const e=!Ur();try{(t=globalThis.localStorage)==null||t.setItem(jr,e?"1":"0")}catch{}return e&&Wn(),e}function Yn(){return Ur()}const Hr=[{id:"home",label:"HOME",key:"1"},{id:"queue",label:"QUEUE",key:"2"},{id:"intel",label:"INTEL",key:"3"},{id:"health",label:"HEALTH",key:"4"},{id:"tuning",label:"TUNING",key:"5"},{id:"setup",label:"SETUP",key:"6"}];function J(e){typeof document<"u"&&"startViewTransition"in document?document.startViewTransition(()=>{location.hash=e,W.value=e}):(location.hash=e,W.value=e);try{localStorage.setItem("obsidian-view",e)}catch{}}function zn(){return r("nav",{class:"mobile-tab-bar",role:"tablist",children:Hr.map(e=>r("button",{role:"tab","aria-selected":W.value===e.id,onClick:()=>J(e.id),class:W.value===e.id?"active":"",children:e.label},e.id))})}function Jn(){const e=D.value?"bg-running":ie.value==="healthy"?"bg-success":ie.value==="degraded"?"bg-warning":"bg-error";return r("header",{class:"border-b border-border px-4 py-3 flex items-center gap-4",children:[r("div",{class:"flex items-center gap-3 mr-4",children:[r("div",{class:`w-2.5 h-2.5 rounded-full ${e}`,style:D.value?{animation:"borderPulse 1.5s ease-in-out infinite"}:{}}),r("span",{class:"text-bright font-bold text-sm tracking-[4px]",children:"OBSIDIAN"}),r("span",{class:"text-dim text-[9px] tracking-[2px] hidden sm:inline",children:"AUTOMATED DOCUMENTARY PIPELINE"}),!ze.value&&r("span",{class:"text-warning text-[10px] tracking-wider animate-pulse",children:"RECONNECTING"})]}),r("nav",{role:"tablist",class:"flex gap-1 desktop-nav",children:Hr.map(t=>r("button",{role:"tab","aria-selected":W.value===t.id,onClick:()=>J(t.id),class:`px-3 py-1.5 text-xs tracking-wider transition-colors rounded - ${W.value===t.id?"bg-bg-2 text-bright border border-border":"text-dim hover:text-text"}`,children:t.label},t.id))}),r("div",{class:"flex-1"}),D.value&&me.value!==null&&r("span",{class:"text-running text-xs font-bold","aria-live":"polite",children:["$",me.value.toFixed(2)]}),Object.keys(vt.value).length>0?r("div",{class:"flex gap-1.5",children:Object.keys(vt.value).map(t=>r("span",{class:"text-warning text-[10px] tracking-wider animate-pulse uppercase",children:t.replace(/_/g," ")},t))}):$t.value&&r("span",{class:"text-warning text-xs animate-pulse",children:"ANALYTICS"}),D.value&&r("button",{onClick:async()=>{try{await Ln(),C("Pipeline killed")}catch{C("Failed to kill pipeline","error")}},class:"px-2 py-1 text-xs text-error border border-error/30 rounded hover:bg-error/10","aria-label":"Kill pipeline",children:"KILL"}),r("button",{onClick:()=>{const t=qr();C(t?"Sound on":"Sound off")},class:"text-dim hover:text-bright text-xs","aria-label":"Toggle sound",children:Yn()?"🔊":"🔇"})]})}function Qn(){if(!D.value)return null;const e=R.value/13*100;return r("div",{class:"fixed bottom-0 left-0 w-full h-[3px] z-50",style:{background:"transparent"},children:r("div",{class:"h-full bg-gradient-to-r from-cyan-500 to-blue-500 transition-all duration-500",style:{width:`${e}%`,boxShadow:"0 0 8px rgba(6, 182, 212, 0.6)"}})})}function Xn(){if(!se.value)return null;const e=fe(null),t=fe(null),[n,i]=k(""),[s,l]=k(!1),[o,c]=k(!1),u=fe(null);O(()=>{var a;u.current=document.activeElement,(a=e.current)==null||a.focus()},[]),O(()=>{const a=p=>{var h;if(p.key==="Escape"){(h=u.current)==null||h.focus(),se.value=!1;return}if(p.key==="Tab"&&t.current){const b=t.current.querySelectorAll("button, input, [tabindex], a[href], select, textarea");if(b.length===0)return;const w=b[0],_=b[b.length-1];p.shiftKey&&document.activeElement===w?(p.preventDefault(),_.focus()):!p.shiftKey&&document.activeElement===_&&(p.preventDefault(),w.focus())}};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[]);const d=()=>{var a;(a=u.current)==null||a.focus(),se.value=!1},f=async()=>{var a;if(!(s||!n.trim())){l(!0);try{await Fr(n.trim()),c(!0),setTimeout(()=>{d(),C(`Fired: ${n.trim()}`),c(!1)},500)}catch(p){(a=p==null?void 0:p.message)!=null&&a.includes("409")?C("Pipeline already running","error"):C("Failed to trigger pipeline","error"),l(!1)}}};return r("div",{ref:t,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/70",onClick:a=>{a.target===a.currentTarget&&d()},children:r("div",{class:`w-[420px] max-w-[90vw] p-6 bg-bg-1 border rounded - ${o?"border-success":"border-border"} transition-colors`,children:[r("div",{class:"text-bright text-sm font-bold mb-3 tracking-wider",children:"QUICK FIRE"}),r("input",{ref:e,type:"text",value:n,onInput:a=>i(a.target.value),onKeyDown:a=>{a.key==="Enter"&&f()},disabled:s,placeholder:"Enter topic...",class:`w-full bg-bg-2 border border-border text-bright px-3 py-2 rounded text-sm - font-mono outline-none focus:border-running disabled:opacity-50`}),r("div",{class:"flex justify-end gap-2 mt-3",children:[r("button",{onClick:d,class:"text-dim text-xs hover:text-text",children:"Cancel"}),r("button",{onClick:f,disabled:s||!n.trim(),class:`px-3 py-1.5 text-xs font-bold bg-running/20 text-running border border-running/30 - rounded hover:bg-running/30 disabled:opacity-50`,children:s?"Firing...":"FIRE"})]})]})})}const Zn={1:"#06B6D4",2:"#22D3EE",3:"#0891B2",4:"#67E8F9",5:"#F59E0B",6:"#14B8A6",7:"#8B5CF6",8:"#A78BFA",9:"#7C3AED",10:"#C4B5FD",11:"#22C55E",12:"#4ADE80",13:"#16A34A"},le=[{num:1,name:"Research Agent",codename:"ORACLE"},{num:2,name:"Originality Agent",codename:"ARCHAEON"},{num:3,name:"Narrative Architect",codename:"ARCHITECT"},{num:4,name:"Script Writer",codename:"SCRIBE"},{num:5,name:"Fact Verification",codename:"ARBITER"},{num:6,name:"SEO Agent",codename:"CIPHER"},{num:7,name:"Scene Breakdown",codename:"SPECTER"},{num:8,name:"Audio Engine",codename:"RESONANCE"},{num:9,name:"Footage Hunter",codename:"HUNTER"},{num:10,name:"Image Generator",codename:"VISION"},{num:11,name:"Video Assembly",codename:"HERALD"},{num:12,name:"Render Engine",codename:"CARVER"},{num:13,name:"Upload & Publish",codename:"MIRROR"}];function ei(){const e=X.value,t=fe(null);if(O(()=>{var u,d;if(e==null)return;const c=f=>{if(f.key==="Escape"&&(X.value=null),f.key==="Tab"&&t.current){const a=t.current.querySelectorAll("button, [tabindex], a[href], input, select, textarea");if(a.length===0)return;const p=a[0],h=a[a.length-1];f.shiftKey&&document.activeElement===p?(f.preventDefault(),h.focus()):!f.shiftKey&&document.activeElement===h&&(f.preventDefault(),p.focus())}};return document.addEventListener("keydown",c),(d=(u=t.current)==null?void 0:u.querySelector("button"))==null||d.focus(),()=>document.removeEventListener("keydown",c)},[e]),e==null)return null;const n=le.find(c=>c.num===e);if(!n)return null;const i=Zn[n.num]??"var(--color-running)",s=n.num<=4,l=n.num>=7&&n.num<=10,o=n.num>=11;return r("div",{class:"fixed inset-0 z-40 flex justify-end",onClick:()=>{X.value=null},children:[r("div",{class:"absolute inset-0 bg-black/40"}),r("div",{ref:t,class:"relative w-80 bg-bg-1 border-l border-border h-full overflow-y-auto p-4",onClick:c=>c.stopPropagation(),children:[r("button",{onClick:()=>{X.value=null},class:"absolute top-3 right-3 text-dim hover:text-bright","aria-label":"Close panel",children:"×"}),r("div",{class:"mb-4",children:[r("div",{class:"text-[10px] text-dim tracking-wider",children:["STAGE ",n.num]}),r("div",{class:"text-xl font-bold",style:{color:i},children:n.codename}),r("div",{class:"text-sm text-text mt-1",children:n.name})]}),r("div",{class:"flex gap-2 mb-4",children:[r("span",{class:`text-[10px] px-2 py-0.5 rounded border ${s?"border-cyan-500/30 text-cyan-400":l?"border-purple-500/30 text-purple-400":o?"border-green-500/30 text-green-400":"border-warning/30 text-warning"}`,children:s?"WRITING":l?"MEDIA":o?"DELIVERY":"VERIFY"}),(n.num===8||n.num===9)&&r("span",{class:"text-[10px] px-2 py-0.5 rounded border border-running/30 text-running",children:"PARALLEL"})]}),r("div",{class:"backdrop-blur-sm bg-bg-2 border border-border rounded p-3 text-xs text-dim",children:[s&&r("div",{children:"Text stage — generates script, research, or analysis content"}),n.num===5&&r("div",{children:"Verification — validates scoring config and content quality"}),n.num===6&&r("div",{children:"Planning — determines visual and narrative structure"}),l&&r("div",{children:"Media stage — produces audio, images, or video assets"}),o&&r("div",{children:"Delivery — renders, uploads, or finalizes output"})]})]})]})}const ct=[{title:"Welcome to The Obsidian Archive",desc:"Automated dark history documentaries — 13 AI agents, one pipeline, zero manual work."},{title:"Queue",desc:"Browse and manage topics. Score-ranked by trend strength, audience demand, and era gaps."},{title:"Real-Time Pipeline",desc:"Watch 13 AI agents research, write, verify, generate media, and render your video live."},{title:"YouTube-Ready Output",desc:"Get a fully rendered documentary with SEO metadata, analytics, and automated upload."}];function ti(){const[e,t]=k(0),[n,i]=k(!1);if(O(()=>{try{localStorage.getItem("obsidian-onboarded")||i(!0)}catch{}},[]),!n)return null;const s=()=>{i(!1);try{localStorage.setItem("obsidian-onboarded","true")}catch{}},l=ct[e],o=e===ct.length-1;return r("div",{class:"fixed inset-0 z-50 flex items-center justify-center bg-black/70",children:r("div",{class:"w-[400px] max-w-[90vw] backdrop-blur-md bg-bg-1/95 border border-border rounded-lg p-6",children:[r("div",{class:"text-[10px] text-dim tracking-wider mb-1",children:[e+1," / ",ct.length]}),r("div",{class:"text-bright font-bold text-sm mb-2",children:l.title}),r("div",{class:"text-text text-xs leading-relaxed mb-6",children:l.desc}),r("div",{class:"flex justify-between items-center",children:[r("button",{onClick:s,class:"text-dim text-xs hover:text-text",children:"Skip"}),r("button",{onClick:()=>o?s():t(c=>c+1),class:"px-4 py-1.5 text-xs font-bold bg-running/20 text-running border border-running/30 rounded hover:bg-running/30",children:o?"Got it":"Next"})]})]})})}const ri=[{key:"1",desc:"Home view"},{key:"2",desc:"Queue view"},{key:"3",desc:"Intel view"},{key:"4",desc:"Health view"},{key:"5",desc:"Tuning view"},{key:"T",desc:"Quick-fire topic"},{key:"L",desc:"Toggle log panel"},{key:"S",desc:"Toggle sound"},{key:"?",desc:"This help overlay"},{key:"Esc",desc:"Close overlay / panel"}];function ni(){return re.value?r("div",{class:"fixed inset-0 z-50 flex items-center justify-center bg-black/70",onClick:e=>{e.target===e.currentTarget&&(re.value=!1)},children:r("div",{class:"w-[340px] max-w-[90vw] backdrop-blur-md bg-bg-1/95 border border-border rounded-lg p-5",children:[r("div",{class:"text-bright font-bold text-sm mb-4 tracking-wider",children:"KEYBOARD SHORTCUTS"}),r("div",{class:"space-y-2",children:ri.map(e=>r("div",{class:"flex items-center gap-3 text-xs",children:[r("kbd",{class:"bg-bg-2 border border-border rounded px-2 py-0.5 text-bright font-mono min-w-[32px] text-center",children:e.key}),r("span",{class:"text-text",children:e.desc})]},e.key))}),r("button",{onClick:()=>{re.value=!1},class:"mt-4 text-dim text-xs hover:text-text",children:"Close"})]})}):null}function ii(e){if(e.length<6)return"→";const t=e.slice(-3),n=e.slice(-6,-3),i=t.reduce((l,o)=>l+o,0)/t.length,s=n.reduce((l,o)=>l+o,0)/n.length;return i>s*1.05?"↑":i=2&&r("svg",{class:"mt-1",width:"80",height:"16",viewBox:"0 0 80 16",children:r("polyline",{fill:"none",stroke:"currentColor","stroke-width":"1.5",class:"text-dim",points:n.map((s,l)=>{const o=l/(n.length-1)*78+1,c=Math.max(...n,1),u=15-s/c*14;return`${o},${u}`}).join(" ")})})]})}function li(){const e=mt.value;return r("div",{class:"flex gap-3 flex-wrap","aria-live":"polite",children:[r(Ne,{label:"Queue",value:Or.value,trend:e.queue}),r(Ne,{label:"Errors (24h)",value:At.value,trend:e.errors}),r(Ne,{label:"Health",value:ie.value}),r(Ne,{label:"Last Cost",value:gt.value!==null?`$${gt.value.toFixed(2)}`:"—",trend:e.cost})]})}const Xt={idle:"bg-dim/20 text-dim",running:"bg-running/20 text-running",done:"bg-success/20 text-success",failed:"bg-error/20 text-error",error:"bg-error/20 text-error",killed:"bg-warning/20 text-warning"};function Pt({status:e}){const t=Xt[e]??Xt.idle;return r("span",{class:`inline-block px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wider ${t}`,children:e})}function Vr({title:e,children:t}){return r("div",{class:"flex flex-col items-center justify-center py-16 text-center",children:[r("p",{class:"text-dim text-lg mb-2",children:e}),t&&r("div",{class:"text-dim text-sm",children:t})]})}const oi=ae(()=>Ye.value.slice(-300)),ai=ae(()=>oi.value.map(e=>/STAGE\s+\d+/.test(e)?{text:e,type:"stage"}:/ERROR|FAIL/i.test(e)?{text:e,type:"error"}:/✓|SUCCESS/i.test(e)?{text:e,type:"success"}:{text:e,type:"default"})),ci={stage:"text-cyan-400",error:"text-error",success:"text-success",default:"text-text"};function di(){const e=mn.value,[t,n]=k([]);O(()=>{e==="just-completed"&&jn().catch(()=>{}),e==="idle"&&(Un().catch(()=>{}),Ir().then(l=>n(l.slice(-3).reverse())).catch(()=>{}))},[e]);const[i,s]=k("");return O(()=>{if(!D.value||!ke.value)return;const l=()=>{const c=new Date(ke.value).getTime(),u=Math.floor((Date.now()-c)/1e3),d=Math.floor(u/60),f=u%60;s(`${d}m ${f}s`)};l();const o=setInterval(l,1e3);return()=>clearInterval(o)},[D.value,ke.value]),r("div",{class:"p-4 flex-1",children:[r(li,{}),r("div",{class:"mt-6",children:[e==="running"&&r(ui,{elapsed:i}),e==="just-completed"&&r(hi,{}),e==="error"&&r(pi,{}),e==="idle"&&r(fi,{historyItems:t})]})]})}function ui({elapsed:e}){var u,d,f,a,p;const t=le.find(h=>h.num===R.value),n=Pr.value,[i,s]=k(!1),[l,o]=k(null),c=[];return(u=n[1])!=null&&u.facts&&c.push(`Research: ${n[1].facts} facts`),(d=n[4])!=null&&d.words&&c.push(`Script: ${Number(n[4].words).toLocaleString()} words`),(f=n[8])!=null&&f.duration_s&&c.push(`Audio: ${Math.floor(Number(n[8].duration_s)/60)}m ${Number(n[8].duration_s)%60}s`),(a=n[9])!=null&&a.images_found&&c.push(`Images: ${n[9].images_found}`),(p=n[10])!=null&&p.generated&&c.push(`Generated: ${n[10].passed_quality??n[10].generated}/${n[10].generated}`),r("div",{class:"flex gap-4",children:[r("div",{class:"flex-1",children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4",children:[r("div",{class:"flex items-center gap-3 mb-2",children:[r(Pt,{status:"running"}),r("span",{class:"text-bright font-bold",children:ve.value})]}),r("div",{class:"flex gap-4 text-sm text-dim",children:[r("span",{children:["Stage ",R.value,"/13"]}),r("span",{children:(t==null?void 0:t.codename)??"..."}),r("span",{children:e}),me.value!=null&&r("span",{class:"text-running",children:["$",me.value.toFixed(2)]})]})]}),c.length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded px-4 py-2 mb-4 text-xs text-dim",children:c.join(" | ")}),r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4",children:r("div",{class:"flex gap-2 flex-wrap",children:le.map(h=>{const b=h.num{X.value=h.num},style:{cursor:"pointer"},children:h.num},h.num)})})}),r("div",{class:"grid grid-cols-4 gap-2",children:le.map(h=>{const b=h.num{_?o(l===h.num?null:h.num):X.value=h.num},children:[r("div",{class:"text-dim text-[9px]",children:h.num}),r("div",{class:`font-bold ${w?"text-running":b?"text-success":"text-dim"}`,children:h.codename}),(_==null?void 0:_.timing_s)!=null&&!g&&r("div",{class:"text-[9px] text-dim mt-0.5",children:[Number(_.timing_s).toFixed(1),"s"]}),g&&r("div",{class:"mt-2 space-y-1 text-[10px]",children:Object.entries(_).map(([v,E])=>r("div",{class:"flex gap-1",children:[r("span",{class:"text-dim",children:[v.replace(/_/g," "),":"]}),r("span",{class:"text-text",children:typeof E=="object"?JSON.stringify(E):String(E)})]},v))})]},h.num)})}),Object.keys(n).length>0&&r("div",{class:"mt-2",children:[r("button",{onClick:()=>s(!i),class:"text-[10px] text-dim hover:text-text",children:i?"HIDE RAW":"RAW"}),i&&r("pre",{class:"mt-1 bg-bg-2 border border-border rounded p-2 text-[10px] text-text overflow-x-auto max-h-48",children:JSON.stringify(n,null,2)})]})]}),Je.value&&r("div",{class:"w-[285px] bg-bg-1 border border-border rounded p-3 overflow-y-auto max-h-[calc(100vh-200px)]",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"LIVE LOG"}),r("div",{class:"font-mono text-[11px] space-y-0.5",children:ai.value.map((h,b)=>r("div",{class:ci[h.type],children:h.text},b))})]})]})}function hi(){var l;const e=Lr.value,[t,n]=k(null),[i,s]=k(!1);return O(()=>{Hn().then(o=>n(o))},[]),r("div",{class:"space-y-4",children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-success/30 rounded p-6",children:[r("div",{class:"flex items-center gap-3 mb-4",children:[r("span",{class:"text-success text-2xl font-bold",style:{background:"linear-gradient(90deg, var(--color-bright), var(--color-success), var(--color-bright))",backgroundClip:"text",WebkitBackgroundClip:"text",color:"transparent",backgroundSize:"200%",animation:"shimmer 2s ease-in-out infinite"},children:"DONE"}),r("span",{class:"text-bright font-bold",children:(e==null?void 0:e.topic)??ve.value})]}),e&&r("div",{class:"flex gap-4 text-sm text-dim mb-4",children:[r("span",{children:e.elapsed_seconds?`${Math.floor(e.elapsed_seconds/60)}m`:"—"}),e.cost_usd!=null&&r("span",{children:["$",e.cost_usd.toFixed(2)]}),r("span",{children:[e.stages_completed,"/13 stages"]})]}),r("button",{onClick:()=>{Rr.value=ge.value??"";try{localStorage.setItem("obsidian-cd",ge.value??"")}catch{}},class:"px-3 py-1.5 text-xs text-dim border border-border rounded hover:text-bright",children:"Got it"})]}),t&&r("div",{class:"space-y-3",children:[t.costs.usd_total>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"COST BREAKDOWN"}),r("div",{class:"flex gap-4 text-xs mb-2",children:r("span",{class:"text-bright font-bold",children:["$",t.costs.usd_total.toFixed(2)," total"]})}),Object.keys(t.costs.per_service).length>0&&r("div",{class:"space-y-1",children:Object.entries(t.costs.per_service).map(([o,c])=>r("div",{class:"flex items-center gap-2 text-[10px]",children:[r("span",{class:"text-dim w-20",children:o}),r("div",{class:"flex-1 h-1.5 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded",style:{width:`${c/t.costs.usd_total*100}%`}})}),r("span",{class:"text-text w-12 text-right",children:["$",c.toFixed(2)]})]},o))})]}),Object.keys(t.stage_timings).length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"STAGE TIMINGS"}),r("div",{class:"space-y-1",children:Object.entries(t.stage_timings).map(([o,c])=>{const u=Math.max(...Object.values(t.stage_timings)),d=le.find(f=>String(f.num)===o);return r("div",{class:"flex items-center gap-2 text-[10px]",children:[r("span",{class:"text-dim w-24 truncate",children:(d==null?void 0:d.codename)??`Stage ${o}`}),r("div",{class:"flex-1 h-1.5 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-success/60 rounded",style:{width:`${c/u*100}%`}})}),r("span",{class:"text-text w-12 text-right",children:[c.toFixed(1),"s"]})]},o)})})]}),Object.keys(t.quality).length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"QUALITY GATES"}),r("div",{class:"grid grid-cols-3 gap-2 text-xs",children:[t.quality.fact_verification&&r(he,{label:"Fact Check",value:t.quality.fact_verification,pass:t.quality.fact_verification==="APPROVED"}),t.quality.compliance&&r(he,{label:"Compliance",value:t.quality.compliance.risk_level,pass:t.quality.compliance.risk_level==="green"}),t.quality.seo_score!=null&&r(he,{label:"SEO Score",value:String(t.quality.seo_score),pass:t.quality.seo_score>=70}),t.quality.qa_tier2_sync_pct!=null&&r(he,{label:"Audio Sync",value:`${t.quality.qa_tier2_sync_pct.toFixed(1)}%`,pass:t.quality.qa_tier2_sync_pct>=90}),t.quality.predictive_score!=null&&r(he,{label:"Prediction",value:t.quality.predictive_score.toFixed(1),pass:t.quality.predictive_score>=6}),t.quality.script_doctor&&r(he,{label:"Script Doctor",value:((l=Object.values(t.quality.script_doctor)[0])==null?void 0:l.toFixed(2))??"—",pass:!0})]})]}),t.agent_performance.length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"AGENT PERFORMANCE"}),r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-[10px]",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"Agent"}),r("th",{class:"text-right p-1",children:"Time"}),r("th",{class:"text-right p-1",children:"SLA"}),r("th",{class:"text-left p-1",children:"Model"}),r("th",{class:"text-left p-1",children:"Status"})]})}),r("tbody",{children:t.agent_performance.map((o,c)=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-text",children:o.agent}),r("td",{class:`p-1 text-right ${o.elapsed_s>o.sla_s?"text-error":"text-success"}`,children:[o.elapsed_s.toFixed(1),"s"]}),r("td",{class:"p-1 text-right text-dim",children:[o.sla_s,"s"]}),r("td",{class:"p-1 text-dim",children:o.model}),r("td",{class:`p-1 ${o.status==="success"?"text-success":"text-error"}`,children:o.status})]},c))})]})})]}),r("button",{onClick:()=>s(!i),class:"text-[10px] text-dim hover:text-text",children:i?"HIDE RAW":"RAW"}),i&&r("pre",{class:"bg-bg-2 border border-border rounded p-2 text-[10px] text-text overflow-x-auto max-h-60",children:JSON.stringify(t,null,2)})]})]})}function he({label:e,value:t,pass:n}){return r("div",{class:`backdrop-blur-sm bg-bg-2 border rounded p-2 text-center ${n?"border-success/30":"border-error/30"}`,children:[r("div",{class:"text-dim text-[9px]",children:e}),r("div",{class:`font-bold text-sm ${n?"text-success":"text-error"}`,children:t})]})}function pi(){const e=Ye.value.slice(-5),[t,n]=k(null);return O(()=>{qn().then(i=>n(i))},[]),r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-error/30 rounded p-6",children:[r("div",{class:"flex items-center gap-3 mb-3",children:[r(Pt,{status:Ee.value}),r("span",{class:"text-bright font-bold",children:ve.value})]}),r("div",{class:"text-sm text-dim mb-2",children:["Failed at stage ",R.value]}),t&&r("div",{class:"bg-bg-2 border border-border rounded p-3 mb-4 space-y-2 text-xs",children:[r("div",{class:"text-dim text-[10px] tracking-wider",children:"PIPELINE DOCTOR DIAGNOSIS"}),r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Stage"}),r("span",{class:"text-bright",children:[t.stage_name," (#",t.stage_num,")"]})]}),t.diagnosis&&r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Diagnosis"}),r("span",{class:"text-text",children:t.diagnosis})]}),t.root_cause&&r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Root Cause"}),r("span",{class:"text-text",children:t.root_cause})]}),t.strategy&&r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Strategy"}),r("span",{class:"text-running",children:t.strategy})]})]}),!t&&r("div",{class:"bg-bg-2 rounded p-3 mb-4 text-[11px] font-mono text-error",children:e.map((i,s)=>r("div",{children:i},s))}),r("div",{class:"flex gap-2",children:[r("button",{onClick:async()=>{try{await Fr(ve.value,R.value),C(`Retrying from stage ${R.value}`)}catch{C("Failed to retry","error")}},class:"px-3 py-1.5 text-xs text-running border border-running/30 rounded hover:bg-running/10",children:["Retry from Stage ",R.value]}),r("button",{onClick:()=>{location.hash="health"},class:"px-3 py-1.5 text-xs text-dim border border-border rounded hover:text-text",children:"Health View"})]})]})}function fi({historyItems:e}){var n,i,s;const t=Nr.value;return r("div",{children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4 flex items-center justify-between",children:[r("div",{children:[r("div",{class:"text-bright text-sm font-bold",children:"Ready to fire"}),r("div",{class:"text-dim text-xs mt-1",children:"Press T to quick-fire a topic"})]}),r("button",{onClick:()=>{se.value=!0},class:"px-4 py-2 text-sm font-bold bg-running/20 text-running border border-running/30 rounded hover:bg-running/30",children:"FIRE"})]}),e.length>0?r("div",{class:"space-y-2 mb-4",children:[r("div",{class:"text-dim text-[10px] tracking-wider",children:"RECENT RUNS"}),e.map((l,o)=>r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3 flex items-center gap-3",children:[l.youtube_id&&r("a",{href:`https://youtube.com/watch?v=${l.youtube_id}`,target:"_blank",rel:"noopener noreferrer",children:r("img",{src:`https://img.youtube.com/vi/${l.youtube_id}/mqdefault.jpg`,class:"rounded border border-border",width:80,alt:`Thumbnail: ${l.topic}`})}),r(Pt,{status:l.status}),r("span",{class:"text-sm text-bright flex-1",children:l.topic}),r("div",{class:"flex flex-col items-end gap-0.5",children:[l.cost_usd!=null&&r("span",{class:"text-xs text-dim",children:["$",l.cost_usd.toFixed(2)]}),l.elapsed_seconds>0&&r("span",{class:"text-[10px] text-dim",children:[Math.floor(l.elapsed_seconds/60),"m"]})]})]},o))]}):r(Vr,{title:"Welcome. Add a topic to get started.",children:r("span",{children:["Go to ",r("a",{href:"#queue",class:"text-running underline",children:"Queue"})," or press T"]})}),t&&t.length>0&&r("div",{class:"text-dim text-xs mt-4",children:["Next: ",(n=t[0])==null?void 0:n.job," (",(i=t[0])==null?void 0:i.day," ",(s=t[0])==null?void 0:s.time_utc," UTC)"]})]})}const Zt={trending:"Trending",audience_request:"Audience Request",era_gap:"Era Gap",manual:"Manual"};function xi(e){return e>.8?{label:"HOT",cls:"text-error bg-error/20 border-error/30"}:e>=.6?{label:"WARM",cls:"text-warning bg-warning/20 border-warning/30"}:{label:"COOL",cls:"text-dim bg-bg-2 border-border"}}function bi(e,t,n){return[...e].sort((i,s)=>{const l=i[t],o=s[t],c=typeof l=="number"?l-o:String(l).localeCompare(String(o));return n==="asc"?c:-c})}function vi(){const[e,t]=k([]),[n,i]=k(!0),[s,l]=k(""),[o,c]=k(!1),[u,d]=k("score"),[f,a]=k("desc"),[p,h]=k(null),b=async()=>{try{const x=await Nn();t(x)}catch{C("Failed to load queue","error")}finally{i(!1)}};O(()=>{b()},[]);const w=async()=>{if(!(!s.trim()||o)){c(!0);try{await Dn(s.trim()),l(""),C("Topic added"),await b()}catch{C("Failed to add topic","error")}finally{c(!1)}}},_=async x=>{try{await In(x),t(e.filter(y=>y.id!==x)),C("Topic removed")}catch{C("Failed to remove topic","error")}},g=x=>{u===x?a(y=>y==="asc"?"desc":"asc"):(d(x),a(x==="score"?"desc":"asc"))},v=bi(e,u,f),E=x=>u===x?f==="asc"?" ▴":" ▾":"";return r("div",{class:"p-4",children:[r("div",{class:"flex gap-2 mb-4",children:[r("input",{type:"text",value:s,onInput:x=>l(x.target.value),onKeyDown:x=>{x.key==="Enter"&&w()},placeholder:"Add a topic...",class:"flex-1 bg-bg-2 border border-border text-bright px-3 py-2 rounded text-sm font-mono outline-none focus:border-running"}),r("button",{onClick:w,disabled:o||!s.trim(),class:"px-4 py-2 text-xs font-bold bg-running/20 text-running border border-running/30 rounded hover:bg-running/30 disabled:opacity-50",children:"ADD"})]}),n?r("div",{class:"text-dim text-sm",children:"Loading..."}):e.length===0?r(Vr,{title:"Queue is empty",children:r("span",{children:"Add topics above to get started"})}):r("div",{children:[r("div",{class:"flex items-center gap-3 px-3 py-2 text-[10px] tracking-wider text-dim",children:[r("span",{class:"w-16",children:"STATUS"}),r("button",{class:"flex-1 text-left hover:text-text",onClick:()=>g("topic"),children:["TOPIC",E("topic")]}),r("span",{class:"w-10 text-center",children:"TIER"}),r("button",{class:"w-14 text-right hover:text-text",onClick:()=>g("score"),children:["SCORE",E("score")]}),r("button",{class:"w-24 text-right hover:text-text",onClick:()=>g("source"),children:["SOURCE",E("source")]}),r("span",{class:"w-5"})]}),r("div",{class:"space-y-1",children:v.map(x=>{const y=xi(x.score),Y=p===x.id;return r("div",{children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3 flex items-center gap-3 cursor-pointer",onClick:()=>h(Y?null:x.id),children:[r("span",{class:`text-xs px-1.5 py-0.5 rounded w-16 text-center ${x.status==="queued"?"bg-running/20 text-running":x.status==="done"?"bg-success/20 text-success":"bg-dim/20 text-dim"}`,children:x.status}),r("span",{class:"text-sm text-bright flex-1",children:x.topic}),r("span",{class:`text-[9px] px-1.5 py-0.5 rounded border font-bold w-10 text-center ${y.cls}`,children:y.label}),r("span",{class:"text-xs text-dim w-14 text-right",children:x.score.toFixed(2)}),r("span",{class:"text-xs text-dim w-24 text-right",children:Zt[x.source]??x.source}),r("button",{onClick:N=>{N.stopPropagation(),_(x.id)},class:"text-dim hover:text-error text-xs w-5 text-center","aria-label":`Delete ${x.topic}`,children:"×"})]}),Y&&r("div",{class:"bg-bg-2 border border-border/50 rounded-b mx-1 px-3 py-2 text-[10px] space-y-1",children:[r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim w-20",children:"Score"}),r("span",{class:"text-bright",children:x.score.toFixed(4)})]}),r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim w-20",children:"Source"}),r("span",{class:"text-text",children:Zt[x.source]??x.source})]}),Object.entries(x).filter(([N])=>!["id","topic","status","score","source"].includes(N)).map(([N,U])=>r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim w-20",children:N.replace(/_/g," ")}),r("span",{class:"text-text",children:typeof U=="object"?JSON.stringify(U):String(U??"—")})]},N))]})]},x.id)})}),r("div",{class:"text-dim text-[10px] mt-3",children:[e.length," topic",e.length!==1?"s":""]})]})]})}function H({class:e="",width:t="100%",height:n="1rem"}){return r("div",{class:`animate-pulse bg-bg-2 rounded ${e}`,style:{width:t,height:n},"aria-hidden":"true"})}class Gr extends pe{constructor(){super(...arguments);It(this,"state",{error:null})}static getDerivedStateFromError(n){return{error:n}}render(){return this.state.error?this.props.fallback??r("div",{class:"p-4 border border-error/30 bg-error/10 text-error text-sm rounded",children:["Something went wrong: ",this.state.error.message]}):this.props.children}}function et({renderer:e,height:t=200,class:n="",ariaLabel:i}){const s=fe(null),[l,o]=k({width:0,height:t}),[c,u]=k(null);return O(()=>{const d=s.current;if(!d)return;let f;const a=new ResizeObserver(p=>{clearTimeout(f),f=setTimeout(()=>{const h=p[0];h&&o({width:h.contentRect.width,height:t})},150)});return a.observe(d),()=>{clearTimeout(f),a.disconnect()}},[t]),r("div",{ref:s,class:`relative ${n}`,style:{minHeight:`${t}px`},children:[l.width>0&&r("svg",{width:l.width,height:l.height,class:"overflow-visible",...i?{role:"img","aria-label":i}:{},children:e(l.width,l.height,u)}),c&&r("div",{class:"absolute z-10 bg-bg-2 border border-border rounded px-2 py-1 text-[11px] text-bright pointer-events-none",style:{left:`${Math.min(c.x,l.width-120)}px`,top:`${Math.max(c.y-40,0)}px`},children:[r("div",{class:"text-dim",children:c.label}),r("div",{class:"font-bold",children:c.value})]})]})}function gi(e,t,n,i){if(!e.length)return[];const s={top:10,right:10,bottom:30,left:40},l=t-s.left-s.right,o=n-s.top-s.bottom,c=Math.max(...e.map(a=>a.value),1),u=Math.max(4,Math.min(40,l/e.length-4)),d=e.map((a,p)=>{const h=s.left+l/e.length*p+(l/e.length-u)/2,b=a.value/c*o,w=s.top+o-b;return r("g",{children:[r("rect",{x:h,y:w,width:u,height:b,fill:a.color||"var(--color-running)",rx:2,opacity:.8,onMouseEnter:()=>i({x:h,y:w,label:a.label,value:a.value.toLocaleString()}),onMouseLeave:()=>i(null)}),u>12&&r("text",{x:h+u/2,y:n-8,"text-anchor":"middle",fill:"var(--color-dim)","font-size":"9",children:a.label.slice(0,6)})]},p)});return[...[0,.25,.5,.75,1].map(a=>{const p=Math.round(c*a),h=s.top+o-o*a;return r("g",{children:[r("line",{x1:s.left-4,y1:h,x2:s.left+l,y2:h,stroke:"var(--color-border)","stroke-width":.5}),r("text",{x:s.left-6,y:h+3,"text-anchor":"end",fill:"var(--color-dim)","font-size":"9",children:p})]},`t${a}`)}),...d]}function Lt(e,t,n,i){if(!e.length)return[];const s={top:5,right:10,bottom:5,left:80},l=t-s.left-s.right,o=n-s.top-s.bottom,c=Math.max(...e.map(d=>d.value),1),u=Math.max(4,Math.min(24,o/e.length-4));return e.map((d,f)=>{const a=s.top+o/e.length*f+(o/e.length-u)/2,p=d.value/c*l;return r("g",{children:[r("text",{x:s.left-6,y:a+u/2+3,"text-anchor":"end",fill:"var(--color-dim)","font-size":"10",children:d.label.slice(0,12)}),r("rect",{x:s.left,y:a,width:Math.max(2,p),height:u,fill:d.color||"var(--color-running)",rx:2,opacity:.8,onMouseEnter:()=>i({x:s.left+p,y:a,label:d.label,value:d.value.toLocaleString()}),onMouseLeave:()=>i(null)}),r("text",{x:s.left+p+6,y:a+u/2+3,fill:"var(--color-text)","font-size":"10",children:d.value.toLocaleString()})]},f)})}const mi=["summary","performance","content","audience","config"];function _i(){const[e,t]=k("summary"),[n,i]=k(!1);O(()=>{Ce.value[e]===void 0&&(i(!0),Rn(e).catch(()=>{}).finally(()=>i(!1)))},[e]);const s=Ce.value[e];return r("div",{class:"p-4",children:[r("div",{class:"flex gap-1 mb-4",children:mi.map(l=>r("button",{onClick:()=>t(l),class:`px-3 py-1.5 text-xs tracking-wider rounded - ${e===l?"bg-bg-2 text-bright border border-border":"text-dim hover:text-text"}`,children:l.toUpperCase()},l))}),r(Gr,{children:n?r("div",{class:"space-y-3",children:[r(H,{height:"2rem"}),r(H,{height:"4rem"}),r(H,{height:"4rem"})]}):s?e==="summary"?r(yi,{data:s}):e==="performance"?r(wi,{data:s}):e==="content"?r(ki,{data:s}):e==="audience"?r(Si,{data:s}):e==="config"?r(Ci,{data:s}):r(Ti,{data:s}):r("div",{class:"text-dim text-sm",children:"No data available"})})]})}function yi({data:e}){const t=(e==null?void 0:e.signals)??[],n={info:"border-running/30",success:"border-success/30",warning:"border-warning/30",error:"border-error/30",neutral:"border-border"};return r("div",{class:"space-y-3",children:t.map((i,s)=>r("div",{class:`backdrop-blur-sm bg-bg-1/80 border rounded p-3 ${n[i.type]??"border-border"}`,children:[r("div",{class:"text-dim text-[10px] tracking-wider",children:i.label}),r("div",{class:"text-bright text-sm mt-1",children:i.value})]},s))})}function wi({data:e}){const t=Nt(e.per_video_stats),n=e.era_performance,i=e.retention_analysis;return r("div",{class:"space-y-4",children:[t.length>0&&r(j,{title:"PER VIDEO PERFORMANCE",children:r(et,{height:200,renderer:(s,l,o)=>gi(t.slice(0,20).map(c=>({label:c.title??"",value:Number(c.views??0),color:"var(--color-running)"})),s,l,o)})}),i&&r(j,{title:"RETENTION ANALYSIS",children:r(st,{data:i})}),n&&r(j,{title:"ERA PERFORMANCE",children:r(et,{height:180,renderer:(s,l,o)=>Lt(Object.entries(n).map(([c,u])=>({label:c,value:Number((u==null?void 0:u.avg_views)??u??0)})),s,l,o)})}),Object.keys(e).length===0&&r("div",{class:"text-dim text-sm",children:"No performance data available"})]})}function ki({data:e}){const t=e.tag_performance,n=Nt(e.top_performing_videos);return r("div",{class:"space-y-4",children:[t&&Object.keys(t).length>0&&r(j,{title:"TAG PERFORMANCE",children:r(et,{height:Math.min(300,Object.keys(t).length*28+20),renderer:(i,s,l)=>Lt(Object.entries(t).sort(([,o],[,c])=>c-o).slice(0,10).map(([o,c])=>({label:o,value:c,color:"var(--color-success)"})),i,s,l)})}),n.length>0&&r(Ei,{title:"TOP PERFORMING VIDEOS",items:n,limit:5}),Object.entries(e).filter(([i])=>!["tag_performance","top_performing_videos"].includes(i)).map(([i,s])=>r(j,{title:i.replace(/_/g," ").toUpperCase(),children:r(st,{data:s})},i))]})}function Si({data:e}){const t=Nt(e.audience_requests);return r("div",{class:"space-y-4",children:[t.length>0&&r(j,{title:"AUDIENCE REQUESTS",children:r(et,{height:Math.min(250,t.length*28+20),renderer:(n,i,s)=>Lt(t.slice(0,8).map(l=>({label:String(l.topic??l),value:Number(l.count??1),color:"var(--color-warning)"})),n,i,s)})}),Object.entries(e).filter(([n])=>n!=="audience_requests").map(([n,i])=>r(j,{title:n.replace(/_/g," ").toUpperCase(),children:typeof i=="object"&&i!==null&&!Array.isArray(i)?r(st,{data:i}):r("pre",{class:"text-[11px] text-text overflow-x-auto max-h-60",children:JSON.stringify(i,null,2)})},n))]})}function j({title:e,children:t}){return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:e}),t]})}function st({data:e}){return r("div",{class:"space-y-1 text-xs",children:Object.entries(e).map(([t,n])=>r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[100px]",children:t.replace(/_/g," ")}),r("span",{class:"text-text",children:typeof n=="object"?JSON.stringify(n):String(n??"—")})]},t))})}function Ti({data:e}){return r("div",{class:"space-y-4",children:Object.entries(e).map(([t,n])=>r(j,{title:t.replace(/_/g," ").toUpperCase(),children:r("pre",{class:"text-[11px] text-text overflow-x-auto max-h-60",children:typeof n=="object"?JSON.stringify(n,null,2):String(n)})},t))})}function Ei({title:e,items:t,limit:n}){const[i,s]=k(!1),l=i?t:t.slice(0,n);return r(j,{title:e,children:[r("div",{class:"space-y-1.5",children:l.map((o,c)=>{const u=o;return r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"text-dim w-4",children:[c+1,"."]}),r("span",{class:"text-bright flex-1 truncate",children:String(u.title??u.topic??JSON.stringify(o))}),u.views!=null&&r("span",{class:"text-dim",children:[Number(u.views).toLocaleString()," views"]})]},c)})}),t.length>n&&r("button",{onClick:()=>s(!i),class:"text-running text-[10px] mt-2 hover:underline",children:i?"Show less":`Show all ${t.length}`})]})}function Ci({data:e}){const t=e.scoring_weights??e.scoring_adjustments,n=e.quality_thresholds,i=Object.entries(e).filter(([s])=>!["scoring_weights","scoring_adjustments","quality_thresholds"].includes(s));return r("div",{class:"space-y-4",children:[t&&Object.keys(t).length>0&&r(j,{title:"SCORING WEIGHTS",children:r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"Factor"}),Object.keys(Object.values(t)[0]??{}).map(s=>r("th",{class:"text-right p-1 capitalize",children:s},s))]})}),r("tbody",{children:Object.entries(t).map(([s,l])=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-text",children:s.replace(/_/g," ")}),Object.values(l).map((o,c)=>r("td",{class:"p-1 text-right text-bright",children:o},c))]},s))})]})})}),n&&Object.keys(n).length>0&&r(j,{title:"QUALITY THRESHOLDS",children:r("div",{class:"space-y-1 text-xs",children:Object.entries(n).map(([s,l])=>r("div",{class:"flex items-center gap-2",children:[r("span",{class:"text-dim flex-1",children:s.replace(/_/g," ")}),r("span",{class:`font-bold ${l>=.8?"text-success":l>=.5?"text-warning":"text-error"}`,children:typeof l=="number"?l.toFixed(2):String(l)})]},s))})}),i.map(([s,l])=>r(j,{title:s.replace(/_/g," ").toUpperCase(),children:typeof l=="object"&&l!==null&&!Array.isArray(l)?r(st,{data:l}):r("pre",{class:"text-[11px] text-text overflow-x-auto max-h-60",children:JSON.stringify(l,null,2)})},s)),Object.keys(e).length===0&&r("div",{class:"text-dim text-sm",children:"No config data available"})]})}function Nt(e){return Array.isArray(e)?e:[]}function $i(){var a;const[e,t]=k(null),[n,i]=k(null),[s,l]=k(null),[o,c]=k(null),[u,d]=k({});O(()=>{const h={"X-Trigger-Key":typeof window<"u"?window.__TRIGGER_KEY__:""};fetch("/music",{headers:h}).then(b=>b.json()).then(t).catch(()=>{}),fetch("/trends",{headers:h}).then(b=>b.json()).then(i).catch(()=>{}),fetch("/audit",{headers:h}).then(b=>b.json()).then(l).catch(()=>{}),fetch("/schedule",{headers:h}).then(b=>b.json()).then(c).catch(()=>{})},[]);const f=p=>d(h=>({...h,[p]:!h[p]}));return r("div",{class:"p-4",children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4",children:[r("div",{class:"text-bright text-sm font-bold mb-2",children:"System Health"}),r("div",{class:"flex gap-4 text-sm",children:[r("span",{class:ie.value==="healthy"?"text-success":ie.value==="degraded"?"text-warning":"text-error",children:ie.value.toUpperCase()}),r("span",{class:"text-dim",children:[At.value," errors (24h)"]})]})]}),D.value&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-running/30 rounded p-4 mb-4",children:[r("div",{class:"text-running text-sm font-bold mb-2",children:"Live Status"}),r("div",{class:"text-sm text-dim",children:["Active: Stage ",R.value," — ",((a=le.find(p=>p.num===R.value))==null?void 0:a.codename)??"..."]})]}),r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4 overflow-x-auto",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"AGENT PERFORMANCE"}),r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"#"}),r("th",{class:"text-left p-1",children:"Agent"}),r("th",{class:"text-left p-1",children:"Codename"})]})}),r("tbody",{children:le.map(p=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-dim",children:p.num}),r("td",{class:"p-1 text-text",children:p.name}),r("td",{class:"p-1 text-bright",children:p.codename})]},p.num))})]})]}),r("div",{class:"space-y-2",children:[r(te,{title:"Music Library",expanded:u.music??!1,onToggle:()=>f("music"),badge:(e==null?void 0:e.total_tracks)!=null?`${e.total_tracks} tracks`:void 0,children:e?r(Ai,{data:e}):r(H,{height:"3rem"})}),r(te,{title:"Trend Detection",expanded:u.trends??!1,onToggle:()=>f("trends"),badge:n!=null&&n.trends?`${n.trends.length} trends`:void 0,children:n?r(Ri,{data:n}):r(H,{height:"3rem"})}),r(te,{title:"Image Audit",expanded:u.audit??!1,onToggle:()=>f("audit"),badge:s!=null&&s.entries?`${s.entries.length} entries`:void 0,children:s?r(Pi,{data:s}):r(H,{height:"3rem"})}),r(te,{title:"Schedule",expanded:u.schedule??!1,onToggle:()=>f("schedule"),badge:o!=null&&o.schedule?`${o.schedule.length} jobs`:void 0,children:o?r(Li,{data:o}):r(H,{height:"3rem"})}),r(te,{title:"Error Log",expanded:u.errors??!1,onToggle:()=>f("errors"),badge:Qe.value?`${Qe.value.length} groups`:void 0,children:r(Ni,{})}),r(te,{title:"Agent Performance",expanded:u["agent-perf"]??!1,onToggle:()=>f("agent-perf"),children:r(Di,{})}),r(te,{title:"Cost Breakdown",expanded:u.cost??!1,onToggle:()=>f("cost"),children:r(Ii,{})})]})]})}function te({title:e,expanded:t,onToggle:n,badge:i,children:s}){return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded",children:[r("button",{onClick:n,class:"w-full flex items-center justify-between p-3 text-sm text-bright hover:text-white",children:[r("span",{class:"flex items-center gap-2",children:[e,i&&r("span",{class:"text-[10px] text-dim bg-bg-2 px-1.5 py-0.5 rounded",children:i})]}),r("span",{class:"text-dim",children:t?"▾":"▸"})]}),t&&r("div",{class:"px-3 pb-3",children:s})]})}function Ai({data:e}){const t=e.tracks_by_mood??{},n=Object.entries(t).sort(([,i],[,s])=>s-i);return r("div",{children:[r("div",{class:"text-sm text-dim mb-2",children:["Total: ",r("span",{class:"text-bright",children:e.total_tracks??0})," tracks"]}),n.length>0&&r("div",{class:"space-y-1",children:n.map(([i,s])=>{const l=e.total_tracks?s/e.total_tracks*100:0;return r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"w-20 text-dim capitalize",children:i}),r("div",{class:"flex-1 h-2 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded",style:{width:`${l}%`}})}),r("span",{class:"text-dim w-8 text-right",children:s})]},i)})})]})}function er(e){if(!e)return"text-dim";const t=(Date.now()-new Date(e).getTime())/864e5;return t<7?"text-success":t<30?"text-warning":"text-dim"}function Oi(e){if(!e)return"(stale)";const t=(Date.now()-new Date(e).getTime())/864e5;return t<7?"(recent)":t<30?"(aging)":"(stale)"}function Ri({data:e}){const t=e.trends??[];return t.length?r("div",{class:"space-y-1",children:t.map((n,i)=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:`w-2 h-2 rounded-full ${er(n.fetched_at).replace("text-","bg-")}`}),r("span",{class:`flex-1 ${er(n.fetched_at)}`,children:n.topic}),r("span",{class:"text-dim text-[10px]",children:Oi(n.fetched_at)}),n.score!=null&&r("span",{class:"text-dim",children:n.score.toFixed(1)})]},i))}):r("div",{class:"text-dim text-xs",children:"No trends detected"})}function Pi({data:e}){const t=e.entries??[];return t.length?r("div",{class:"space-y-1 max-h-48 overflow-y-auto",children:t.map((n,i)=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:n.status==="pass"?"text-success":n.status==="fail"?"text-error":"text-warning",children:n.status==="pass"?"✓":n.status==="fail"?"✗":"⚠"}),r("span",{class:"text-text flex-1 truncate",children:n.file??"unknown"}),n.reason&&r("span",{class:"text-dim truncate max-w-[200px]",children:n.reason})]},i))}):r("div",{class:"text-dim text-xs",children:"No audit entries"})}function Li({data:e}){const t=e.schedule??[],[,n]=k(0);return O(()=>{const i=setInterval(()=>n(s=>s+1),6e4);return()=>clearInterval(i)},[]),t.length?r("div",{class:"space-y-1",children:t.map((i,s)=>{const l=Fi(i.day,i.time_utc);return r("div",{class:"flex items-center gap-3 text-xs",children:[r("span",{class:"text-bright flex-1",children:i.job}),r("span",{class:"text-dim",children:[i.day," ",i.time_utc," UTC"]}),l&&r("span",{class:"text-running",children:l})]},s)})}):r("div",{class:"text-dim text-xs",children:"No scheduled jobs"})}function Ni(){const[e,t]=k(!1);O(()=>{e||Vn().finally(()=>t(!0))},[e]);const n=Qe.value??[];return n.length?r("div",{class:"space-y-1 max-h-60 overflow-y-auto",children:n.map((i,s)=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:i.severity==="critical"?"text-error":"text-warning",children:[i.count,"x"]}),r("span",{class:"text-bright flex-1 truncate",children:i.agent}),r("span",{class:"text-dim truncate max-w-[200px]",children:i.error_type})]},s))}):r("div",{class:"text-dim text-xs",children:"No errors in last 24h"})}function Di(){const[e,t]=k(!1);O(()=>{e||Gn().finally(()=>t(!0))},[e]);const n=Ot.value??[];return n.length?r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"Agent"}),r("th",{class:"text-right p-1",children:"Calls"}),r("th",{class:"text-right p-1",children:"Avg (s)"}),r("th",{class:"text-right p-1",children:"P95 (s)"}),r("th",{class:"text-right p-1",children:"Success"}),r("th",{class:"text-right p-1",children:"SLA Miss"})]})}),r("tbody",{children:n.map((i,s)=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-bright truncate max-w-[120px]",children:i.agent}),r("td",{class:"p-1 text-dim text-right",children:i.calls}),r("td",{class:"p-1 text-text text-right",children:i.avg_latency}),r("td",{class:"p-1 text-text text-right",children:i.p95_latency}),r("td",{class:`p-1 text-right ${i.success_rate>=95?"text-success":i.success_rate>=80?"text-warning":"text-error"}`,children:[i.success_rate,"%"]}),r("td",{class:`p-1 text-right ${i.sla_breach_rate<=5?"text-dim":"text-warning"}`,children:[i.sla_breach_rate,"%"]})]},s))})]})}):r("div",{class:"text-dim text-xs",children:"No agent data yet"})}function Ii(){const e=Ot.value??[];if(!e.length)return r("div",{class:"text-dim text-xs",children:"No cost data yet"});const t=[...e].sort((s,l)=>(l.avg_input_tokens+l.avg_output_tokens)*l.calls-(s.avg_input_tokens+s.avg_output_tokens)*s.calls),n=t[0],i=n?(n.avg_input_tokens+n.avg_output_tokens)*n.calls:1;return r("div",{class:"space-y-1",children:t.slice(0,10).map((s,l)=>{const o=(s.avg_input_tokens+s.avg_output_tokens)*s.calls,c=i>0?o/i*100:0;return r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"w-28 text-dim truncate",children:s.agent}),r("div",{class:"flex-1 h-2 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded",style:{width:`${c}%`}})}),r("span",{class:"text-dim w-16 text-right",children:[(o/1e3).toFixed(0),"k tok"]})]},l)})})}function Fi(e,t){try{const n=e.toUpperCase(),i=new Date;if(n==="3H"){const _=i.getUTCHours(),g=(Math.floor(_/3)+1)*3,v=new Date(i);g>=24?(v.setUTCDate(v.getUTCDate()+1),v.setUTCHours(0,0,0,0)):v.setUTCHours(g,0,0,0);const E=v.getTime()-i.getTime(),x=Math.floor(E/36e5),y=Math.floor(E%36e5/6e4);return x>0?`${x}h ${y}m`:`${y}m`}if(n==="DAILY"){const[_,g]=t.split(":").map(Number);if(_==null||g==null)return null;const v=new Date(i);v.setUTCHours(_,g,0,0),v.getTime()<=i.getTime()&&v.setUTCDate(v.getUTCDate()+1);const E=v.getTime()-i.getTime(),x=Math.floor(E/36e5),y=Math.floor(E%36e5/6e4);return x>0?`${x}h ${y}m`:`${y}m`}const l={SUN:"Sunday",MON:"Monday",TUE:"Tuesday",WED:"Wednesday",THU:"Thursday",FRI:"Friday",SAT:"Saturday"}[n]??e,c=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"].indexOf(l);if(c===-1)return null;const[u,d]=t.split(":").map(Number);if(u==null||d==null)return null;const f=i.getUTCDay(),a=(c-f+7)%7,p=new Date(i);p.setUTCDate(i.getUTCDate()+a),p.setUTCHours(u,d,0,0),p.getTime()<=i.getTime()&&p.setUTCDate(p.getUTCDate()+7);const h=p.getTime()-i.getTime(),b=Math.floor(h/36e5),w=Math.floor(h%36e5/6e4);return b>0?`${b}h ${w}m`:`${w}m`}catch{return null}}const Br=[{key:"voice_speed.quote",label:"Quote Reading Speed",description:"How fast historical quotes are narrated. Lower = more dramatic.",group:"long_form",groupLabel:"Long-Form Parameters",min:.6,max:.9,default:.74,step:.01,unit:"x"},{key:"voice_speed.quote_legacy",label:"Legacy Quote Speed",description:"Narration speed for older-style quotes. Slightly faster.",group:"long_form",groupLabel:"Long-Form Parameters",min:.7,max:.95,default:.85,step:.01,unit:"x"},{key:"pause.reveal",label:"Reveal Pause",description:"Dramatic pause before a key revelation. Longer = more suspense.",group:"long_form",groupLabel:"Long-Form Parameters",min:.5,max:5,default:1.8,step:.05,unit:"s"},{key:"pause.breathing",label:"Breathing Pause",description:"Natural pause between sentences. Affects overall pacing.",group:"long_form",groupLabel:"Long-Form Parameters",min:.3,max:3,default:1.2,step:.05,unit:"s"},{key:"pause.act_transition",label:"Act Transition Pause",description:"Pause between major story acts. Time to absorb.",group:"long_form",groupLabel:"Long-Form Parameters",min:.2,max:2,default:.9,step:.05,unit:"s"},{key:"pause.default",label:"Default Pause",description:"Standard inter-sentence pause. The baseline rhythm.",group:"long_form",groupLabel:"Long-Form Parameters",min:.1,max:1.5,default:.4,step:.05,unit:"s"},{key:"short.voice_speed",label:"Short Voice Speed",description:"Main narration speed for Shorts. Faster keeps attention.",group:"short_speed",groupLabel:"Shorts Speed",min:.78,max:1.02,default:.88,step:.01,unit:"x",brandRef:"voice_speed.quote",brandThreshold:.15},{key:"short.hook_speed",label:"Short Hook Speed",description:"Opening hook speed. Slightly faster to grab attention.",group:"short_speed",groupLabel:"Shorts Speed",min:.82,max:1.05,default:.92,step:.01,unit:"x"},{key:"short.voice_stability",label:"Short Voice Stability",description:"How consistent the AI voice sounds. Lower = more expressive.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.2,max:.55,default:.38,step:.01},{key:"short.voice_style",label:"Short Voice Style",description:"Exaggeration level. Higher = more dramatic.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.3,max:.85,default:.6,step:.01},{key:"short.hook_stability",label:"Hook Voice Stability",description:"Stability of hook voice. Lower for more energy.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.15,max:.45,default:.28,step:.01},{key:"short.hook_style",label:"Hook Voice Style",description:"Style exaggeration for hook. High for max impact.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.4,max:.95,default:.75,step:.01},{key:"short.similarity_boost",label:"Voice Consistency",description:"How closely Short voice matches main channel voice.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.65,max:.95,default:.82,step:.01},{key:"short.hook_similarity_boost",label:"Hook Voice Consistency",description:"How closely hook voice matches main voice.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.7,max:.98,default:.85,step:.01},{key:"short.tail_buffer_sec",label:"Short Tail Buffer",description:"Silence before a Short loops. Affects rewatch feel.",group:"short_timing",groupLabel:"Shorts Timing",min:.3,max:2.5,default:1.5,step:.05,unit:"s"}],Dt=Object.fromEntries(Br.map(e=>[e.key,e])),Mi=[{id:"long_form",label:"Long-Form Parameters"},{id:"short_speed",label:"Shorts Speed"},{id:"short_voice",label:"Shorts Voice Character"},{id:"short_timing",label:"Shorts Timing"}],ji=[{id:1,name:"LF Param → Metrics",phase:"Phase B",requirement:"Requires param variation from manual overrides"},{id:2,name:"Short Param → Metrics",phase:"Phase B",requirement:"Requires param variation from manual overrides"},{id:3,name:"Topic Health",phase:"Phase A",requirement:"2+ topics with 2+ videos each"},{id:4,name:"Audience Transformation",phase:"Phase B",requirement:"3+ paired topics with before/after data"},{id:5,name:"Short → Parent Lift",phase:"Phase B/C",requirement:"Layers 2+4 at moderate confidence"},{id:6,name:"Era Stratification",phase:"Phase C",requirement:"5+ videos per era with varied params"},{id:7,name:"Cross-Format Signal",phase:"Phase C",requirement:"Layers 1+2 at moderate confidence"}];function Ue(e,t){return t.unit==="x"?`${e.toFixed(2)}x`:t.unit==="s"?`${e.toFixed(2)}s`:e.toFixed(2)}const tr={early:"bg-dim/20 text-dim",emerging:"bg-warning/20 text-warning",established:"bg-running/20 text-running",mature:"bg-success/20 text-success"},rr={active:"bg-success/20 text-success border-success/30",insufficient_data:"bg-warning/20 text-warning border-warning/30",inactive:"bg-bg-2 text-dim border-border"};function Ui({correlation:e}){const t=e.maturity??"early",n=e.layers??{};return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"flex items-center gap-3 mb-3",children:[r("span",{class:"text-dim text-[10px] tracking-wider",children:"DATA MATURITY"}),r("span",{class:`text-[10px] font-bold px-2 py-0.5 rounded ${tr[t]??tr.early}`,children:t.toUpperCase()}),e.video_count!=null&&r("span",{class:"text-dim text-[10px]",children:[e.video_count," videos, ",e.short_count??0," shorts"]})]}),e.maturity_description&&r("div",{class:"text-xs text-text mb-3",children:e.maturity_description}),r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"CORRELATION LAYERS"}),r("div",{class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2",children:ji.map(i=>{const s=n[String(i.id)],l=(s==null?void 0:s.status)??"inactive",o=rr[l]??rr.inactive;return r("div",{class:`border rounded p-2.5 ${o}`,children:[r("div",{class:"flex items-center gap-1.5 mb-1",children:[r("span",{class:"text-[9px] text-dim",children:["L",i.id]}),r("span",{class:"text-xs text-bright font-bold truncate",children:i.name})]}),r("div",{class:"text-[9px] text-dim",children:i.phase}),r("div",{class:"text-[9px] text-dim mt-1 leading-tight",children:(s==null?void 0:s.reason)??i.requirement})]},i.id)})}),r("div",{class:"text-dim text-[9px] mt-3 italic",children:"With weekly video cadence, expect 3-6 videos before directional signals emerge."})]})}function qi({layer:e}){var i;const t=(e==null?void 0:e.status)??"inactive",n=((i=e==null?void 0:e.results)==null?void 0:i.topic_ranking)??[];return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"flex items-center gap-2 mb-3",children:[r("span",{class:"text-dim text-[10px] tracking-wider",children:"TOPIC HEALTH (LAYER 3)"}),r("span",{class:`text-[9px] px-1.5 py-0.5 rounded ${t==="active"?"bg-success/20 text-success":t==="insufficient_data"?"bg-warning/20 text-warning":"bg-bg-2 text-dim"}`,children:t.toUpperCase().replace("_"," ")})]}),n.length===0?r("div",{class:"text-dim text-xs py-4 text-center",children:"Need 2+ videos per topic for health analysis"}):r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1.5",children:"Topic"}),r("th",{class:"text-right p-1.5",children:"Videos"}),r("th",{class:"text-right p-1.5",children:"Shorts"}),r("th",{class:"text-right p-1.5",children:"Avg Views"}),r("th",{class:"text-right p-1.5",children:"Avg Retention"})]})}),r("tbody",{children:n.map((s,l)=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1.5 text-bright font-medium",children:s.topic}),r("td",{class:"p-1.5 text-right text-text",children:s.long_count}),r("td",{class:"p-1.5 text-right text-text",children:s.short_count}),r("td",{class:"p-1.5 text-right text-text",children:s.avg_long_views!=null?s.avg_long_views.toLocaleString():"-"}),r("td",{class:"p-1.5 text-right",children:s.avg_long_retention!=null?r("span",{class:s.avg_long_retention>=50?"text-success":s.avg_long_retention>=35?"text-warning":"text-error",children:[s.avg_long_retention.toFixed(1),"%"]}):"-"})]},l))})]})})]})}function Hi({param:e}){var E;const t=fe(null),n=be(()=>ae(()=>ce.value[e.key]??null),[e.key]),i=be(()=>ae(()=>!!B.value[e.key]),[e.key]),s=En(e.key,e.default),l=n.value??s,o=n.value!==null&&Math.abs(n.value-s)>e.step*.5,c=Cn(e.key);let u=null;if(e.brandRef&&e.brandThreshold){const x=(E=Dt[e.brandRef])==null?void 0:E.default;x!=null&&(u=Math.abs(l-x),u<=e.brandThreshold&&(u=null))}const d=e.max-e.min,a=Math.abs(l-e.default)/d>.5,p=x=>(x-e.min)/d*100,h=p(l),b=p(e.default),w=Pe(x=>{const y=Math.round((x-e.min)/e.step)*e.step+e.min;return Math.max(e.min,Math.min(e.max,parseFloat(y.toFixed(6))))},[e.min,e.max,e.step]),_=Pe(x=>{const y=t.current;if(!y)return;x.target.setPointerCapture(x.pointerId);const Y=y.getBoundingClientRect(),N=F=>{const Wr=Math.max(0,Math.min(1,(F-Y.left)/Y.width)),Yr=e.min+Wr*d;ot(e.key,w(Yr))};N(x.clientX);const U=F=>N(F.clientX),I=F=>{F.target.releasePointerCapture(F.pointerId),y.removeEventListener("pointermove",U),y.removeEventListener("pointerup",I)};y.addEventListener("pointermove",U),y.addEventListener("pointerup",I)},[e.key,e.min,d,w]),g=Pe(x=>{let y=l;switch(x.key){case"ArrowRight":case"ArrowUp":y+=e.step;break;case"ArrowLeft":case"ArrowDown":y-=e.step;break;case"PageUp":y+=e.step*10;break;case"PageDown":y-=e.step*10;break;case"Home":y=e.min;break;case"End":y=e.max;break;default:return}x.preventDefault(),ot(e.key,w(y))},[l,e.key,e.min,e.max,e.step,w]),v=Pe(x=>{const y=parseFloat(x.target.value);Number.isFinite(y)&&ot(e.key,w(y))},[e.key,w]);return r("div",{class:"space-y-1.5",children:[r("div",{class:"flex items-center justify-between gap-2",children:[r("div",{class:"flex items-center gap-2 min-w-0",children:[r("span",{class:"text-xs text-bright font-medium truncate",children:e.label}),c&&r("span",{class:"text-[8px] text-running bg-running/10 border border-running/20 rounded px-1",children:"OVERRIDE"})]}),r("div",{class:"flex items-center gap-1.5 shrink-0",children:[r("input",{type:"number",class:"w-16 bg-bg-2 border border-border rounded px-1.5 py-0.5 text-xs text-bright text-right tabular-nums focus:border-running/50 focus:outline-none",min:e.min,max:e.max,step:e.step,value:l.toFixed(2),onInput:v}),r("span",{class:"text-[9px] text-dim w-3",children:e.unit??""})]})]}),r("div",{class:"text-[10px] text-dim leading-tight",children:e.description}),r("div",{ref:t,class:"relative h-8 flex items-center cursor-pointer touch-none select-none",role:"slider",tabIndex:0,"aria-valuemin":e.min,"aria-valuemax":e.max,"aria-valuenow":l,"aria-label":e.label,onPointerDown:_,onKeyDown:g,children:[r("div",{class:"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 bg-bg-2 rounded-full"}),r("div",{class:"absolute top-1/2 -translate-y-1/2 h-1.5 bg-running/60 rounded-full left-0",style:{width:`${h}%`}}),r("div",{class:"absolute top-1/2 -translate-y-1/2 w-1 h-3.5 bg-dim/50 rounded-sm",style:{left:`${b}%`,transform:"translate(-50%, -50%)"},title:`Default: ${Ue(e.default,e)}`}),r("div",{class:"absolute top-1/2 w-4 h-4 rounded-full bg-running border-2 border-bg-1 shadow-[0_0_6px_rgba(59,130,246,0.5)] hover:shadow-[0_0_10px_rgba(59,130,246,0.7)] transition-shadow duration-200",style:{left:`${h}%`,transform:"translate(-50%, -50%)"}}),r("span",{class:"absolute -bottom-0.5 left-0 text-[8px] text-dim",children:Ue(e.min,e)}),r("span",{class:"absolute -bottom-0.5 right-0 text-[8px] text-dim",children:Ue(e.max,e)})]}),r("div",{class:"flex items-center gap-2 flex-wrap min-h-[20px]",children:[o&&r("button",{onClick:()=>Sn(e.key,l),disabled:i.value,class:"px-2 py-0.5 text-[10px] font-bold text-success border border-success/30 rounded hover:bg-success/10 disabled:opacity-50",children:i.value?"...":"SAVE"}),c&&r("button",{onClick:()=>Tn(e.key),disabled:i.value,class:"px-2 py-0.5 text-[10px] font-bold text-warning border border-warning/30 rounded hover:bg-warning/10 disabled:opacity-50",children:"REVERT"}),u!=null&&r("span",{class:"text-[9px] text-warning bg-warning/10 border border-warning/30 rounded px-1.5 py-0.5",children:["BRAND DRIFT ",u.toFixed(2)]}),a&&r("span",{class:"text-[9px] text-warning",children:"Significant change from default"})]})]})}function nr({label:e,params:t,defaultExpanded:n=!1}){const[i,s]=k(n);return t.length===0?null:r("div",{class:"space-y-3",children:[r("button",{onClick:()=>s(!i),class:"flex items-center gap-2 w-full text-left",children:[r("span",{class:"text-dim text-[10px] tracking-wider uppercase font-bold",children:e}),r("span",{class:"text-[10px] text-dim bg-bg-2 px-1.5 py-0.5 rounded",children:t.length}),r("div",{class:"flex-1 border-b border-border/50"}),r("span",{class:"text-dim text-xs",children:i?"▾":"▸"})]}),i&&r("div",{class:"space-y-5 pl-1",children:t.map(l=>r(Hi,{param:l},l.key))})]})}function Vi(){const e=Mi.map(t=>({...t,params:Br.filter(n=>n.group===t.id)}));return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"mb-1",children:r("span",{class:"text-dim text-[10px] tracking-wider",children:"PARAMETER TUNING"})}),r("div",{class:"text-[10px] text-dim mb-4",children:"Adjust audio and pacing parameters. Changes are saved to your override bank."}),r("div",{class:"grid grid-cols-1 lg:grid-cols-2 gap-6",children:[r("div",{class:"space-y-4",children:e.filter(t=>t.id==="long_form").map(t=>r(nr,{label:t.label,params:t.params,defaultExpanded:!0},t.id))}),r("div",{class:"space-y-4",children:e.filter(t=>t.id!=="long_form").map(t=>r(nr,{label:t.label,params:t.params},t.id))})]})]})}const ir={strong:"bg-success/20 text-success border-success/30",moderate:"bg-running/20 text-running border-running/30",directional:"bg-warning/20 text-warning border-warning/30",weak:"bg-dim/20 text-dim border-border"};function Gi({recommendations:e}){return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-3",children:"RECOMMENDATIONS"}),e.length===0?r("div",{class:"text-dim text-xs py-4 text-center",children:"Awaiting parameter variation -- adjust some parameters manually to begin data collection"}):r("div",{class:"space-y-3",children:e.map((t,n)=>{const i=Dt[t.parameter_key],s=ir[t.confidence]??ir.weak;return r("div",{class:`border rounded p-3 ${s}`,children:[r("div",{class:"flex items-center gap-2 mb-1",children:[r("span",{class:"text-xs text-bright font-bold",children:(i==null?void 0:i.label)??t.parameter_key}),r("span",{class:"text-[9px] font-bold px-1.5 py-0.5 rounded bg-bg-2/50",children:t.confidence.toUpperCase()})]}),r("div",{class:"text-sm text-bright font-bold mb-1",children:i?Ue(t.suggested_value,i):t.suggested_value.toFixed(3)}),r("div",{class:"text-[10px] text-text",children:t.interpretation}),t.quality_score!=null&&r("div",{class:"mt-2 h-1.5 bg-bg-2 rounded-full overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded-full",style:{width:`${t.quality_score*100}%`}})})]},n)})})]})}function Bi(e){const t=Date.now()-new Date(e).getTime(),n=Math.floor(t/6e4);if(n<1)return"just now";if(n<60)return`${n}m ago`;const i=Math.floor(n/60);return i<24?`${i}h ago`:`${Math.floor(i/24)}d ago`}function Ki({history:e}){const[t,n]=k(!1);return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded",children:[r("button",{onClick:()=>n(!t),class:"w-full flex items-center justify-between p-3 text-sm text-bright hover:text-white",children:[r("span",{class:"flex items-center gap-2",children:["Override History",e.length>0&&r("span",{class:"text-[10px] text-dim bg-bg-2 px-1.5 py-0.5 rounded",children:e.length})]}),r("span",{class:"text-dim",children:t?"▾":"▸"})]}),t&&r("div",{class:"px-3 pb-3",children:e.length===0?r("div",{class:"text-dim text-xs",children:"No overrides recorded yet"}):r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1.5",children:"Parameter"}),r("th",{class:"text-left p-1.5",children:"Action"}),r("th",{class:"text-right p-1.5",children:"Value"}),r("th",{class:"text-right p-1.5",children:"Previous"}),r("th",{class:"text-right p-1.5",children:"When"})]})}),r("tbody",{children:e.slice(0,20).map((i,s)=>{const l=Dt[i.key];return r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1.5 text-text",children:(l==null?void 0:l.label)??i.key}),r("td",{class:"p-1.5",children:r("span",{class:i.action==="approve"?"text-success":"text-warning",children:i.action.toUpperCase()})}),r("td",{class:"p-1.5 text-right text-bright tabular-nums",children:i.value!=null?i.value.toFixed(2):"-"}),r("td",{class:"p-1.5 text-right text-dim tabular-nums",children:i.previous_value!=null?i.previous_value.toFixed(2):"-"}),r("td",{class:"p-1.5 text-right text-dim",title:i.timestamp,children:Bi(i.timestamp)})]},s)})})]})})})]})}function Wi(){if(O(()=>{Z.value||Rt()},[]),_t.value&&!Z.value)return r("div",{class:"p-4 space-y-4",children:[r(H,{height:"10rem"}),r(H,{height:"6rem"}),r(H,{height:"20rem"})]});if(Xe.value&&!Z.value)return r("div",{class:"p-4",children:r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-error/30 rounded p-4",children:r("div",{class:"text-error text-sm",children:Xe.value})})});const e=Z.value,t=(e==null?void 0:e.correlation)??{},n=t.layers??{},i=t.recommendations??[],s=(e==null?void 0:e.history)??[];return r("div",{class:"p-4 space-y-4",children:[r("div",{class:"grid grid-cols-1 md:grid-cols-2 gap-4",children:[r(Ui,{correlation:t}),r(qi,{layer:n[3]})]}),r(Vi,{}),r(Gi,{recommendations:i}),r(Ki,{history:s})]})}const De=m(0),ue=m(null),dt=m(!0),tt=m(!1),V=m({}),q=m({}),$e=m(""),oe=m({}),ut=["Welcome","API Keys","Profile","Providers","Review"],Yi=["llm","tts","images","footage","database","notifications"],zi={llm:"AI Text Generation",tts:"Text-to-Speech",images:"Image Generation",footage:"Stock Footage",database:"Database",notifications:"Notifications"},Kr={anthropic:"Anthropic (Claude)",openai:"OpenAI (GPT)",elevenlabs:"ElevenLabs",fal:"fal.ai (Recraft / Flux)",pexels:"Pexels (Free)",local:"Save to Disk"};function Ji(e){const t=new Map;for(const n of Yi){const i=e.filter(s=>s.category===n);i.length>0&&t.set(n,i)}return t}async function Qi(e){const t=V.value[e];if(t!=null&&t.trim()){q.value={...q.value,[e]:"checking"};try{const n=await Bn(e,t);q.value={...q.value,[e]:n.valid?"valid":"invalid"},!n.valid&&n.error&&C(n.error,"error")}catch{q.value={...q.value,[e]:"invalid"}}}}async function Xi(){tt.value=!0;try{const e={};for(const[n,i]of Object.entries(V.value))i!=null&&i.trim()&&(e[n]=i.trim());const t=await Kn({keys:Object.keys(e).length>0?e:void 0,profile:$e.value||void 0,providers:Object.keys(oe.value).length>0?oe.value:void 0});if(t.success){C("Configuration saved!");const n=await Mr();ue.value=n}else C(t.errors.join(", "),"error")}catch{C("Failed to save configuration","error")}finally{tt.value=!1}}function Zi(){var e;return r("div",{class:"space-y-6",children:[r("div",{class:"text-center space-y-4",children:[r("h2",{class:"text-2xl font-bold text-bright",children:"Welcome to Obsidian Engine"}),r("p",{class:"text-text text-base max-w-xl mx-auto leading-relaxed",children:"This wizard will help you configure everything needed to generate AI-powered videos. It takes about 5 minutes."})]}),r("div",{class:"grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto mt-8",children:[r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 1"}),r("div",{class:"text-bright text-sm",children:"API Keys"}),r("div",{class:"text-dim text-xs mt-1",children:"Connect your AI services"})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 2"}),r("div",{class:"text-bright text-sm",children:"Content Profile"}),r("div",{class:"text-dim text-xs mt-1",children:"Choose your video style"})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 3"}),r("div",{class:"text-bright text-sm",children:"Providers"}),r("div",{class:"text-dim text-xs mt-1",children:"Select your service backends"})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 4"}),r("div",{class:"text-bright text-sm",children:"Review & Save"}),r("div",{class:"text-dim text-xs mt-1",children:"Verify and launch"})]})]}),((e=ue.value)==null?void 0:e.setup_complete)&&r("div",{class:"bg-success/10 border border-success/30 rounded-lg p-4 max-w-xl mx-auto mt-6 text-center",children:r("span",{class:"text-success text-sm font-bold",children:"Setup is already complete! You can review or update your configuration."})})]})}function es(){const e=ue.value;if(!e)return null;const t=Ji(e.keys);return r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"API Keys"}),r("p",{class:"text-dim text-sm mt-1",children:"Enter your API keys below. Required keys are marked with a star."})]}),Array.from(t.entries()).map(([n,i])=>r("div",{class:"space-y-3",children:[r("h3",{class:"text-sm font-bold text-accent tracking-wider uppercase",children:[zi[n]||n,i.every(s=>!s.required)&&r("span",{class:"text-dim font-normal ml-2",children:"(optional)"})]}),i.map(s=>{var u;const l=q.value[s.key]||"idle",o=s.configured&&!V.value[s.key],c=l==="valid"||o?"border-success/50":l==="invalid"?"border-error/50":l==="checking"?"border-warning/50":"border-border";return r("div",{class:"space-y-1",children:[r("label",{class:"flex items-center gap-2 text-sm text-text",children:[r("span",{children:s.label}),s.required&&r("span",{class:"text-warning text-xs",children:"*"}),o&&r("span",{class:"text-success text-xs font-bold",children:"CONFIGURED"}),r("a",{href:s.help,target:"_blank",rel:"noopener",class:"text-accent text-xs hover:underline ml-auto",children:"Get key"})]}),r("div",{class:"flex gap-2",children:[r("input",{type:"password",placeholder:o?"(already set — enter new value to replace)":`Enter ${s.label} key`,value:V.value[s.key]||"",onInput:d=>{V.value={...V.value,[s.key]:d.target.value},q.value[s.key]&&(q.value={...q.value,[s.key]:"idle"})},class:`flex-1 bg-bg-1 border ${c} rounded px-3 py-2 text-sm text-text - focus:outline-none focus:border-accent transition-colors font-mono`}),r("button",{onClick:()=>Qi(s.key),disabled:!((u=V.value[s.key])!=null&&u.trim())||l==="checking",class:`px-3 py-2 text-xs bg-bg-2 border border-border rounded - hover:border-accent disabled:opacity-30 disabled:cursor-not-allowed - transition-colors text-text`,children:l==="checking"?"Checking...":l==="valid"?"Valid":l==="invalid"?"Retry":"Test"})]})]},s.key)})]},n))]})}function ts(){const e=ue.value;return e?r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"Content Profile"}),r("p",{class:"text-dim text-sm mt-1",children:"Profiles control the tone, structure, and visual style of your videos."})]}),r("div",{class:"grid gap-3",children:e.available_profiles.map(t=>{const n=($e.value||e.profile)===t.name;return r("button",{onClick:()=>{$e.value=t.name},class:`text-left p-4 rounded-lg border transition-all ${n?"border-accent bg-accent/10 ring-1 ring-accent/30":"border-border bg-bg-2 hover:border-dim"}`,children:r("div",{class:"flex items-center gap-3",children:[r("div",{class:`w-3 h-3 rounded-full border-2 ${n?"border-accent bg-accent":"border-dim"}`}),r("div",{children:[r("div",{class:"text-bright font-bold text-sm capitalize",children:t.name.replace(/_/g," ")}),t.description&&r("div",{class:"text-dim text-xs mt-0.5",children:t.description})]}),n&&e.profile===t.name&&r("span",{class:"ml-auto text-accent text-xs",children:"current"})]})},t.name)})}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4 mt-4",children:r("div",{class:"text-dim text-xs",children:["You can create custom profiles by adding a YAML file to the ",r("code",{class:"text-accent",children:"profiles/"})," directory. See ",r("code",{class:"text-accent",children:"profiles/_template.yaml"})," for the format."]})})]}):null}function rs(){const e=ue.value;if(!e)return null;const t=["llm","tts","images","footage","upload"],n={llm:"AI model for script writing, research, and analysis",tts:"Voice synthesis for narration",images:"AI-generated images for visual scenes",footage:"Stock video clips for b-roll",upload:"Where to publish the final video"};return r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"Service Providers"}),r("p",{class:"text-dim text-sm mt-1",children:"Choose which service to use for each capability. Defaults work great for most users."})]}),r("div",{class:"space-y-4",children:t.map(i=>{const s=e.available_providers[i]||[],l=oe.value[i]||e.providers[i]||"";return r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"flex items-center justify-between mb-2",children:r("div",{children:[r("div",{class:"text-bright text-sm font-bold capitalize",children:i==="llm"?"LLM":i}),r("div",{class:"text-dim text-xs",children:n[i]})]})}),r("div",{class:"flex gap-2 flex-wrap",children:s.map(o=>r("button",{onClick:()=>{oe.value={...oe.value,[i]:o}},class:`px-3 py-1.5 text-xs rounded border transition-all ${l===o?"border-accent bg-accent/10 text-accent":"border-border text-dim hover:text-text hover:border-dim"}`,children:Kr[o]||o},o))})]},i)})})]})}function ns(){const e=ue.value;if(!e)return null;const t=e.keys.filter(i=>{var s;return i.required&&!i.configured&&!((s=V.value[i.key])!=null&&s.trim())}),n=$e.value||e.profile;return r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"Review Configuration"}),r("p",{class:"text-dim text-sm mt-1",children:"Verify your settings and save."})]}),t.length>0&&r("div",{class:"bg-error/10 border border-error/30 rounded-lg p-4",children:[r("div",{class:"text-error text-sm font-bold mb-1",children:"Missing Required Keys"}),r("ul",{class:"text-error/80 text-xs space-y-1",children:t.map(i=>r("li",{children:[i.label," —"," ",r("a",{href:i.help,target:"_blank",rel:"noopener",class:"underline",children:"Get key"})]},i.key))})]}),r("div",{class:"space-y-3",children:[r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-xs font-bold mb-2 tracking-wider",children:"API KEYS"}),r("div",{class:"space-y-1",children:e.keys.map(i=>{var l;const s=!!((l=V.value[i.key])!=null&&l.trim());return r("div",{class:"flex items-center justify-between text-xs",children:[r("span",{class:"text-text",children:i.label}),r("span",{class:s?"text-warning":i.configured?"text-success":i.required?"text-error":"text-dim",children:s?"NEW":i.configured?"SET":i.required?"MISSING":"NOT SET"})]},i.key)})})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-xs font-bold mb-2 tracking-wider",children:"PROFILE"}),r("div",{class:"text-bright text-sm capitalize",children:n.replace(/_/g," ")})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-xs font-bold mb-2 tracking-wider",children:"PROVIDERS"}),r("div",{class:"space-y-1",children:["llm","tts","images","footage","upload"].map(i=>{const s=oe.value[i]||e.providers[i]||"(default)";return r("div",{class:"flex items-center justify-between text-xs",children:[r("span",{class:"text-text capitalize",children:i==="llm"?"LLM":i}),r("span",{class:"text-bright",children:Kr[s]||s})]},i)})})]})]}),r("button",{onClick:Xi,disabled:tt.value,class:`w-full py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-bold - text-sm tracking-wider rounded-lg hover:from-purple-500 hover:to-indigo-500 - disabled:opacity-50 transition-all`,children:tt.value?"SAVING...":"SAVE CONFIGURATION"})]})}function is(){if(O(()=>{dt.value=!0,Mr().then(t=>{ue.value=t,$e.value=t.profile,oe.value={...t.providers}}).catch(()=>C("Failed to load setup status","error")).finally(()=>{dt.value=!1})},[]),dt.value)return r("div",{class:"flex-1 flex items-center justify-center",children:r("div",{class:"text-dim text-sm animate-pulse",children:"Loading setup..."})});const e=De.value;return r("div",{class:"flex-1 flex flex-col p-4 sm:p-6 max-w-4xl mx-auto w-full",children:[r("div",{class:"flex items-center gap-1 mb-8 overflow-x-auto pb-2",children:ut.map((t,n)=>r("div",{class:"flex items-center",children:[r("button",{onClick:()=>{De.value=n},class:`flex items-center gap-2 px-3 py-1.5 rounded text-xs whitespace-nowrap transition-all ${n===e?"bg-accent/10 text-accent border border-accent/30":n{De.value=Math.max(0,e-1)},disabled:e===0,class:"px-4 py-2 text-sm text-dim hover:text-text disabled:opacity-30 disabled:cursor-not-allowed",children:"Back"}),e{De.value=e+1},class:`px-6 py-2 text-sm bg-bg-2 border border-border rounded - hover:border-accent text-bright transition-colors`,children:"Next"}):null]})]})}const ss=()=>typeof location<"u"&&location.hash.replace("#","")||null;function ls(){O(()=>{Me(),Fn()},[]),O(()=>{const t=()=>{const n=ss()||"home";W.value!==n&&(W.value=n)};return window.addEventListener("hashchange",t),()=>window.removeEventListener("hashchange",t)},[]),O(()=>{const t=n=>{var s,l;const i=(s=n.target)==null?void 0:s.tagName;if(!(i==="INPUT"||i==="TEXTAREA"||(l=n.target)!=null&&l.isContentEditable)&&!(n.metaKey||n.ctrlKey||n.altKey))switch(n.key){case"1":J("home");break;case"2":J("queue");break;case"3":J("intel");break;case"4":J("health");break;case"5":J("tuning");break;case"6":J("setup");break;case"t":case"T":se.value=!0;break;case"l":case"L":Je.value=!Je.value;break;case"s":case"S":{const o=qr();C(o?"Sound on":"Sound off");break}case"?":re.value=!re.value;break;case"Escape":re.value?re.value=!1:se.value?se.value=!1:X.value!=null&&(X.value=null);break}};return document.addEventListener("keydown",t),()=>document.removeEventListener("keydown",t)},[]);const e=W.value;return r("div",{class:"min-h-screen flex flex-col",children:[r(Jn,{}),r("main",{id:"main",role:"tabpanel",class:"flex-1 flex flex-col",children:r(Gr,{children:[e==="home"&&r(di,{}),e==="queue"&&r(vi,{}),e==="intel"&&r(_i,{}),e==="health"&&r($i,{}),e==="tuning"&&r(Wi,{}),e==="setup"&&r(is,{})]})}),r(Qn,{}),r(Xn,{}),r(ei,{}),r(zn,{}),r(yn,{}),r(ti,{}),r(ni,{})]})}sn(r(ls,{}),document.getElementById("app")); diff --git a/dashboard/dist/assets/index-D0B_CvYt.js b/dashboard/dist/assets/index-D0B_CvYt.js new file mode 100644 index 0000000..dbd791a --- /dev/null +++ b/dashboard/dist/assets/index-D0B_CvYt.js @@ -0,0 +1,16 @@ +var zr=Object.defineProperty;var Jr=(e,t,n)=>t in e?zr(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var It=(e,t,n)=>Jr(e,typeof t!="symbol"?t+"":t,n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const l of i)if(l.type==="childList")for(const o of l.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const l={};return i.integrity&&(l.integrity=i.integrity),i.referrerPolicy&&(l.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?l.credentials="include":i.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function s(i){if(i.ep)return;i.ep=!0;const l=n(i);fetch(i.href,l)}})();var rt,T,ir,lr,z,Mt,or,ar,cr,yt,ht,pt,qe={},He=[],Qr=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,nt=Array.isArray;function G(e,t){for(var n in t)e[n]=t[n];return e}function wt(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function Xr(e,t,n){var s,i,l,o={};for(l in t)l=="key"?s=t[l]:l=="ref"?i=t[l]:o[l]=t[l];if(arguments.length>2&&(o.children=arguments.length>3?rt.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(l in e.defaultProps)o[l]===void 0&&(o[l]=e.defaultProps[l]);return Ie(e,o,s,i,null)}function Ie(e,t,n,s,i){var l={type:e,props:t,key:n,ref:s,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++ir,__i:-1,__u:0};return i==null&&T.vnode!=null&&T.vnode(l),l}function Ae(e){return e.children}function pe(e,t){this.props=e,this.context=t}function xe(e,t){if(t==null)return e.__?xe(e.__,e.__i+1):null;for(var n;tt&&z.sort(ar),e=z.shift(),t=z.length,Zr(e)}finally{z.length=Ve.__r=0}}function ur(e,t,n,s,i,l,o,c,u,d,f){var a,p,h,v,w,_,b,g=s&&s.__k||He,E=t.length;for(u=en(n,t,g,u,E),a=0;a0?o=e.__k[l]=Ie(o.type,o.props,o.key,o.ref?o.ref:null,o.__v):e.__k[l]=o,u=l+p,o.__=e,o.__b=e.__b+1,c=null,(d=o.__i=tn(o,n,u,a))!=-1&&(a--,(c=n[d])&&(c.__u|=2)),c==null||c.__v==null?(d==-1&&(i>f?p--:iu?p--:p++,o.__u|=4))):e.__k[l]=null;if(a)for(l=0;l(f?1:0)){for(i=n-1,l=n+1;i>=0||l=0?i--:l++])!=null&&(2&d.__u)==0&&c==d.key&&u==d.type)return o}return-1}function jt(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Qr.test(t)?n:n+"px"}function Re(e,t,n,s,i){var l,o;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof s=="string"&&(e.style.cssText=s=""),s)for(t in s)n&&t in n||jt(e.style,t,"");if(n)for(t in n)s&&n[t]==s[t]||jt(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")l=t!=(t=t.replace(cr,"$1")),o=t.toLowerCase(),t=o in e||t=="onFocusOut"||t=="onFocusIn"?o.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+l]=n,n?s?n.u=s.u:(n.u=yt,e.addEventListener(t,l?pt:ht,l)):e.removeEventListener(t,l?pt:ht,l);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Ut(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=yt++;else if(t.t0?e:nt(e)?e.map(fr):G({},e)}function rn(e,t,n,s,i,l,o,c,u){var d,f,a,p,h,v,w,_=n.props||qe,b=t.props,g=t.type;if(g=="svg"?i="http://www.w3.org/2000/svg":g=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),l!=null){for(d=0;d=n.__.length&&n.__.push({}),n.__[e]}function k(e){return Te=1,on(br,e)}function on(e,t,n){var s=Tt(Se++,2);if(s.t=e,!s.__c&&(s.__=[br(void 0,t),function(c){var u=s.__N?s.__N[0]:s.__[0],d=s.t(u,c);u!==d&&(s.__N=[d,s.__[1]],s.__c.setState({}))}],s.__c=$,!$.__f)){var i=function(c,u,d){if(!s.__c.__H)return!0;var f=s.__c.__H.__.filter(function(p){return p.__c});if(f.every(function(p){return!p.__N}))return!l||l.call(this,c,u,d);var a=s.__c.props!==c;return f.some(function(p){if(p.__N){var h=p.__[0];p.__=p.__N,p.__N=void 0,h!==p.__[0]&&(a=!0)}}),l&&l.call(this,c,u,d)||a};$.__f=!0;var l=$.shouldComponentUpdate,o=$.componentWillUpdate;$.componentWillUpdate=function(c,u,d){if(this.__e){var f=l;l=void 0,i(c,u,d),l=f}o&&o.call(this,c,u,d)},$.shouldComponentUpdate=i}return s.__N||s.__}function O(e,t){var n=Tt(Se++,3);!A.__s&&gr(n.__H,t)&&(n.__=e,n.u=t,$.__H.__h.push(n))}function fe(e){return Te=5,ve(function(){return{current:e}},[])}function ve(e,t){var n=Tt(Se++,7);return gr(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function Pe(e,t){return Te=8,ve(function(){return e},t)}function an(){for(var e;e=vr.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Me),t.__h.some(xt),t.__h=[]}catch(n){t.__h=[],A.__e(n,e.__v)}}}A.__b=function(e){$=null,Ht&&Ht(e)},A.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Wt&&Wt(e,t)},A.__r=function(e){Vt&&Vt(e),Se=0;var t=($=e.__c).__H;t&&(lt===$?(t.__h=[],$.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(Me),t.__h.some(xt),t.__h=[],Se=0)),lt=$},A.diffed=function(e){Gt&&Gt(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(vr.push(t)!==1&&qt===A.requestAnimationFrame||((qt=A.requestAnimationFrame)||cn)(an)),t.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),lt=$=null},A.__c=function(e,t){t.some(function(n){try{n.__h.some(Me),n.__h=n.__h.filter(function(s){return!s.__||xt(s)})}catch(s){t.some(function(i){i.__h&&(i.__h=[])}),t=[],A.__e(s,n.__v)}}),Bt&&Bt(e,t)},A.unmount=function(e){Kt&&Kt(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(s){try{Me(s)}catch(i){t=i}}),n.__H=void 0,t&&A.__e(t,n.__v))};var Yt=typeof requestAnimationFrame=="function";function cn(e){var t,n=function(){clearTimeout(s),Yt&&cancelAnimationFrame(t),setTimeout(e)},s=setTimeout(n,35);Yt&&(t=requestAnimationFrame(n))}function Me(e){var t=$,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),$=t}function xt(e){var t=$;e.__c=e.__(),$=t}function gr(e,t){return!e||e.length!==t.length||t.some(function(n,s){return n!==e[s]})}function br(e,t){return typeof t=="function"?t(e):t}var dn=Symbol.for("preact-signals");function st(){if(K>1)K--;else{var e,t=!1;for((function(){var i=Be;for(Be=void 0;i!==void 0;)i.S.v===i.v&&(i.S.i=i.i),i=i.o})();we!==void 0;){var n=we;for(we=void 0,Ge++;n!==void 0;){var s=n.u;if(n.u=void 0,n.f&=-3,!(8&n.f)&&yr(n))try{n.c()}catch(i){t||(e=i,t=!0)}n=s}}if(Ge=0,K--,t)throw e}}function Et(e){if(K>0)return e();vt=++un,K++;try{return e()}finally{st()}}var S=void 0;function mr(e){var t=S;S=void 0;try{return e()}finally{S=t}}var we=void 0,K=0,Ge=0,un=0,vt=0,Be=void 0,Ke=0;function _r(e){if(S!==void 0){var t=e.n;if(t===void 0||t.t!==S)return t={i:0,S:e,p:S.s,n:void 0,t:S,e:void 0,x:void 0,r:t},S.s!==void 0&&(S.s.n=t),S.s=t,e.n=t,32&S.f&&e.S(t),t;if(t.i===-1)return t.i=0,t.n!==void 0&&(t.n.p=t.p,t.p!==void 0&&(t.p.n=t.n),t.p=S.s,t.n=void 0,S.s.n=t,S.s=t),t}}function P(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.l=0,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}P.prototype.brand=dn;P.prototype.h=function(){return!0};P.prototype.S=function(e){var t=this,n=this.t;n!==e&&e.e===void 0&&(e.x=n,this.t=e,n!==void 0?n.e=e:mr(function(){var s;(s=t.W)==null||s.call(t)}))};P.prototype.U=function(e){var t=this;if(this.t!==void 0){var n=e.e,s=e.x;n!==void 0&&(n.x=s,e.e=void 0),s!==void 0&&(s.e=n,e.x=void 0),e===this.t&&(this.t=s,s===void 0&&mr(function(){var i;(i=t.Z)==null||i.call(t)}))}};P.prototype.subscribe=function(e){var t=this;return Oe(function(){var n=t.value,s=S;S=void 0;try{e(n)}finally{S=s}},{name:"sub"})};P.prototype.valueOf=function(){return this.value};P.prototype.toString=function(){return this.value+""};P.prototype.toJSON=function(){return this.value};P.prototype.peek=function(){var e=S;S=void 0;try{return this.value}finally{S=e}};Object.defineProperty(P.prototype,"value",{get:function(){var e=_r(this);return e!==void 0&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(Ge>100)throw new Error("Cycle detected");(function(n){K!==0&&Ge===0&&n.l!==vt&&(n.l=vt,Be={S:n,v:n.v,i:n.i,o:Be})})(this),this.v=e,this.i++,Ke++,K++;try{for(var t=this.t;t!==void 0;t=t.x)t.t.N()}finally{st()}}}});function m(e,t){return new P(e,t)}function yr(e){for(var t=e.s;t!==void 0;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function wr(e){for(var t=e.s;t!==void 0;t=t.n){var n=t.S.n;if(n!==void 0&&(t.r=n),t.S.n=t,t.i=-1,t.n===void 0){e.s=t;break}}}function kr(e){for(var t=e.s,n=void 0;t!==void 0;){var s=t.p;t.i===-1?(t.S.U(t),s!==void 0&&(s.n=t.n),t.n!==void 0&&(t.n.p=s)):n=t,t.S.n=t.r,t.r!==void 0&&(t.r=void 0),t=s}e.s=n}function de(e,t){P.call(this,void 0),this.x=e,this.s=void 0,this.g=Ke-1,this.f=4,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}de.prototype=new P;de.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if((36&this.f)==32||(this.f&=-5,this.g===Ke))return!0;if(this.g=Ke,this.f|=1,this.i>0&&!yr(this))return this.f&=-2,!0;var e=S;try{wr(this),S=this;var t=this.x();(16&this.f||this.v!==t||this.i===0)&&(this.v=t,this.f&=-17,this.i++)}catch(n){this.v=n,this.f|=16,this.i++}return S=e,kr(this),this.f&=-2,!0};de.prototype.S=function(e){if(this.t===void 0){this.f|=36;for(var t=this.s;t!==void 0;t=t.n)t.S.S(t)}P.prototype.S.call(this,e)};de.prototype.U=function(e){if(this.t!==void 0&&(P.prototype.U.call(this,e),this.t===void 0)){this.f&=-33;for(var t=this.s;t!==void 0;t=t.n)t.S.U(t)}};de.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;e!==void 0;e=e.x)e.t.N()}};Object.defineProperty(de.prototype,"value",{get:function(){if(1&this.f)throw new Error("Cycle detected");var e=_r(this);if(this.h(),e!==void 0&&(e.i=this.i),16&this.f)throw this.v;return this.v}});function ae(e,t){return new de(e,t)}function Sr(e){var t=e.m;if(e.m=void 0,typeof t=="function"){K++;var n=S;S=void 0;try{t()}catch(s){throw e.f&=-2,e.f|=8,Ct(e),s}finally{S=n,st()}}}function Ct(e){for(var t=e.s;t!==void 0;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,Sr(e)}function hn(e){if(S!==this)throw new Error("Out-of-order effect");kr(this),S=e,this.f&=-2,8&this.f&&Ct(this),st()}function _e(e,t){this.x=e,this.m=void 0,this.s=void 0,this.u=void 0,this.f=32,this.name=t==null?void 0:t.name}_e.prototype.c=function(){var e=this.S();try{if(8&this.f||this.x===void 0)return;var t=this.x();typeof t=="function"&&(this.m=t)}finally{e()}};_e.prototype.S=function(){if(1&this.f)throw new Error("Cycle detected");this.f|=1,this.f&=-9,Sr(this),wr(this),K++;var e=S;return S=this,hn.bind(this,e)};_e.prototype.N=function(){2&this.f||(this.f|=2,this.u=we,we=this)};_e.prototype.d=function(){this.f|=8,1&this.f||Ct(this)};_e.prototype.dispose=function(){this.d()};function Oe(e,t){var n=new _e(e,t);try{n.c()}catch(i){throw n.d(),i}var s=n.d.bind(n);return s[Symbol.dispose]=s,s}var Tr,Le,pn=typeof window<"u"&&!!window.__PREACT_SIGNALS_DEVTOOLS__,Er=[];Oe(function(){Tr=this.N})();function ye(e,t){T[e]=t.bind(null,T[e]||function(){})}function We(e){if(Le){var t=Le;Le=void 0,t()}Le=e&&e.S()}function Cr(e){var t=this,n=e.data,s=xn(n);s.value=n;var i=ve(function(){for(var c=t,u=t.__v;u=u.__;)if(u.__c){u.__c.__$f|=4;break}var d=ae(function(){var h=s.value.value;return h===0?0:h===!0?"":h||""}),f=ae(function(){return!Array.isArray(d.value)&&!lr(d.value)}),a=Oe(function(){if(this.N=$r,f.value){var h=d.value;c.__v&&c.__v.__e&&c.__v.__e.nodeType===3&&(c.__v.__e.data=h)}}),p=t.__$u.d;return t.__$u.d=function(){a(),p.call(this)},[f,d]},[]),l=i[0],o=i[1];return l.value?o.peek():o.value}Cr.displayName="ReactiveTextNode";Object.defineProperties(P.prototype,{constructor:{configurable:!0,value:void 0},type:{configurable:!0,value:Cr},props:{configurable:!0,get:function(){var e=this;return{data:{get value(){return e.value}}}}},__b:{configurable:!0,value:1}});ye("__b",function(e,t){if(typeof t.type=="string"){var n,s=t.props;for(var i in s)if(i!=="children"){var l=s[i];l instanceof P&&(n||(t.__np=n={}),n[i]=l,s[i]=l.peek())}}e(t)});ye("__r",function(e,t){if(e(t),t.type!==Ae){We();var n,s=t.__c;s&&(s.__$f&=-2,(n=s.__$u)===void 0&&(s.__$u=n=(function(i,l){var o;return Oe(function(){o=this},{name:l}),o.c=i,o})(function(){var i;pn&&((i=n.y)==null||i.call(n)),s.__$f|=1,s.setState({})},typeof t.type=="function"?t.type.displayName||t.type.name:""))),We(n)}});ye("__e",function(e,t,n,s){We(),e(t,n,s)});ye("diffed",function(e,t){We();var n;if(typeof t.type=="string"&&(n=t.__e)){var s=t.__np,i=t.props;if(s){var l=n.U;if(l)for(var o in l){var c=l[o];c!==void 0&&!(o in s)&&(c.d(),l[o]=void 0)}else l={},n.U=l;for(var u in s){var d=l[u],f=s[u];d===void 0?(d=fn(n,u,f),l[u]=d):d.o(f,i)}for(var a in s)i[a]=s[a]}}e(t)});function fn(e,t,n,s){var i=t in e&&e.ownerSVGElement===void 0,l=m(n),o=n.peek();return{o:function(c,u){l.value=c,o=c.peek()},d:Oe(function(){this.N=$r;var c=l.value.value;o!==c?(o=void 0,i?e[t]=c:c!=null&&(c!==!1||t[4]==="-")?e.setAttribute(t,c):e.removeAttribute(t)):o=void 0})}}ye("unmount",function(e,t){if(typeof t.type=="string"){var n=t.__e;if(n){var s=n.U;if(s){n.U=void 0;for(var i in s){var l=s[i];l&&l.d()}}}t.__np=void 0}else{var o=t.__c;if(o){var c=o.__$u;c&&(o.__$u=void 0,c.d())}}e(t)});ye("__h",function(e,t,n,s){(s<3||s===9)&&(t.__$f|=2),e(t,n,s)});pe.prototype.shouldComponentUpdate=function(e,t){if(this.__R)return!0;var n=this.__$u,s=n&&n.s!==void 0;for(var i in t)return!0;if(this.__f||typeof this.u=="boolean"&&this.u===!0){var l=2&this.__$f;if(!(s||l||4&this.__$f)||1&this.__$f)return!0}else if(!(s||4&this.__$f)||3&this.__$f)return!0;for(var o in e)if(o!=="__source"&&e[o]!==this.props[o])return!0;for(var c in this.props)if(!(c in e))return!0;return!1};function xn(e,t){return ve(function(){return m(e,t)},[])}var vn=function(e){queueMicrotask(function(){queueMicrotask(e)})};function gn(){Et(function(){for(var e;e=Er.shift();)Tr.call(e)})}function $r(){Er.push(this)===1&&(T.requestAnimationFrame||vn)(gn)}const bn=()=>{try{return location.hash.replace("#","")||null}catch{return null}},Ar=e=>{var t;try{return((t=globalThis.localStorage)==null?void 0:t.getItem(e))??null}catch{return null}},Ee=m("idle"),I=m(!1),R=m(0),ge=m(""),Ye=m([]),ke=m(null),be=m(null),$t=m(!1),gt=m({}),me=m(null),ze=m(!1),Or=m(0),At=m(0),se=m("healthy"),bt=m(null),W=m(bn()||Ar("obsidian-view")||"home"),Je=m(!1),X=m(null),ie=m(!1),re=m(!1),Rr=m(Ar("obsidian-cd")||""),Pr=m({}),Ce=m({}),Lr=m(null),Nr=m(null),Qe=m(null),Ot=m(null),mt=m({queue:[],errors:[],cost:[]}),mn=ae(()=>I.value?"running":Ee.value==="done"&&be.value&&Rr.value!==be.value?"just-completed":["failed","error","killed"].includes(Ee.value)?"error":"idle"),ne=m([]);let _n=0;function C(e,t="info"){const n=_n++,s=ne.value.length>=3?ne.value.slice(1):ne.value;ne.value=[...s,{id:n,message:e,type:t}],t==="info"&&setTimeout(()=>Dr(n),4e3)}function Dr(e){ne.value=ne.value.filter(t=>t.id!==e)}function yn(){return r("div",{class:"fixed top-4 right-4 z-50 flex flex-col gap-2",style:{maxWidth:"360px"},children:ne.value.map(e=>r(wn,{toast:e,onDismiss:()=>Dr(e.id)},e.id))})}function wn({toast:e,onDismiss:t}){const n=e.type==="error"?"bg-error/90":"bg-bg-2/95 border border-border",s=e.type==="error"?"text-white":"text-text";return r("div",{role:e.type==="error"?"alert":"status",class:`${n} ${s} px-4 py-3 rounded text-sm flex items-center gap-2`,children:[r("span",{class:"flex-1",children:e.message}),r("button",{onClick:t,class:"text-dim hover:text-bright ml-2","aria-label":"Dismiss",children:"×"})]})}const kn=typeof window<"u"?window.__TRIGGER_KEY__:"",Z=m(null),_t=m(!1),Xe=m(null),ce=m({}),B=m({});async function Ze(e,t){const n=await fetch(e,{...t,headers:{"X-Trigger-Key":kn,...t==null?void 0:t.headers}});if(n.status===401)throw window.location.href="/login",new Error("Unauthorized");if(!n.ok){const s=await n.json().catch(()=>({}));throw new Error(s.error||`HTTP ${n.status}`)}return n.json()}async function Rt(){_t.value=!0,Xe.value=null;try{const[e,t]=await Promise.allSettled([Ze("/api/overrides"),Ze("/api/correlation")]),n=e.status==="fulfilled"?e.value:{overrides:[],bounds:{},defaults:{},history:[]},s=t.status==="fulfilled"?t.value:{layers:{},recommendations:[],maturity:"early"};Z.value={...n,correlation:s}}catch(e){Xe.value=e.message}finally{_t.value=!1}}async function Sn(e,t){B.value={...B.value,[e]:!0};try{await Ze("/api/overrides/approve",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e,value:t})});const{[e]:n,...s}=ce.value;ce.value=s,await Rt(),C(`Saved: ${e}`)}catch(n){C(n.message,"error")}finally{const{[e]:n,...s}=B.value;B.value=s}}async function Tn(e){B.value={...B.value,[e]:!0};try{await Ze("/api/overrides/revert",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e})});const{[e]:t,...n}=ce.value;ce.value=n,await Rt(),C(`Reverted: ${e}`)}catch(t){C(t.message,"error")}finally{const{[e]:t,...n}=B.value;B.value=n}}function ot(e,t){ce.value={...ce.value,[e]:t}}function En(e,t){var i;const s=(((i=Z.value)==null?void 0:i.overrides)??[]).find(l=>l.key===e);return s?s.value:t}function Cn(e){var t;return(((t=Z.value)==null?void 0:t.overrides)??[]).some(n=>n.key===e)}function $n(){Z.value=null}let j=null,ee=1e3,Q=null;const zt=3e4,An=2e3;function Fe(){if(j)return;const e=typeof window<"u"?window.__TRIGGER_KEY__:"",t=`/stream?key=${encodeURIComponent(e)}`;j=new EventSource(t),ee=1e3,Q&&(clearTimeout(Q),Q=null),ze.value=!0,j.addEventListener("state",n=>{try{const s=JSON.parse(n.data);Et(()=>{const i=I.value;Ee.value=s.last_status,I.value=s.running,R.value=s.stage_num,ge.value=s.topic,ke.value=s.started_at,be.value=s.finished_at,$t.value=s.analytics_running,s.active_jobs&&(gt.value=s.active_jobs),i&&!s.running&&(me.value=null),!i&&s.running&&(Je.value=!0)})}catch{}}),j.addEventListener("log",n=>{try{const s=JSON.parse(n.data);Ye.value=[...Ye.value,...s].slice(-An)}catch{}}),j.addEventListener("cost",n=>{try{const s=JSON.parse(n.data);me.value=s.usd_total}catch{}}),j.addEventListener("stage_summary",n=>{try{const s=JSON.parse(n.data);Pr.value=s}catch{}}),j.addEventListener("intel_updated",()=>{Ce.value={}}),j.addEventListener("tuning_updated",()=>{$n()}),j.addEventListener("reconnect",()=>{Jt(),setTimeout(Fe,1e3)}),j.onerror=()=>{ze.value=!1,Jt(),fetch("/api/pulse",{headers:{"X-Trigger-Key":e}}).then(n=>{if(n.status===401){window.location.href="/login";return}ee=Math.min(ee*2,zt),Q=setTimeout(Fe,ee)}).catch(()=>{ee=Math.min(ee*2,zt),Q=setTimeout(Fe,ee)})}}function Jt(){ze.value=!1,Q&&(clearTimeout(Q),Q=null),j&&(j.close(),j=null)}const On=typeof window<"u"?window.__TRIGGER_KEY__:"";async function N(e,t){const n=await fetch(e,{...t,headers:{"X-Trigger-Key":On,...t==null?void 0:t.headers}});if(n.status===401)throw window.location.href="/login",new Error("Unauthorized");if(!n.ok)throw new Error(`HTTP ${n.status}`);return n.json()}async function Qt(){const e=await N("/api/pulse");return Et(()=>{Ee.value=e.status,I.value=e.running,R.value=e.stage_num,ge.value=e.topic,ke.value=e.started_at,be.value=e.finished_at,$t.value=e.analytics_running,Or.value=e.queue_depth??0,At.value=e.errors_24h??0,se.value=e.health??"healthy",bt.value=e.last_cost_usd;const t=mt.value;mt.value={queue:[...t.queue,e.queue_depth??0].slice(-10),errors:[...t.errors,e.errors_24h??0].slice(-10),cost:[...t.cost,e.last_cost_usd??0].slice(-10)}}),e}async function Rn(e){const t=await N(`/api/dashboard?sections=${e}`),n={...Ce.value};for(const[s,i]of Object.entries(t))n[s]=i;return Ce.value=n,t}async function Ir(){return N("/history")}async function Pn(){return N("/schedule")}async function Mr(e,t=1){return N("/trigger",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topic:e,resume_from:t})})}async function Ln(){return N("/kill",{method:"POST"})}async function Nn(){return N("/queue")}async function Dn(e){return N("/queue/add",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topic:e})})}async function In(e){return N("/queue/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:e})})}let je=null;function Mn(){Fn(),Qt().catch(()=>{});const e=()=>{const t=I.value?15e3:5e3;je=setTimeout(()=>{Qt().catch(()=>{}).finally(e)},t)};e()}function Fn(){je!==null&&(clearTimeout(je),je=null)}async function jn(){const e=await Ir();e.length>0&&(Lr.value=e[e.length-1]??null)}async function Un(){const e=await Pn();Nr.value=e.schedule}async function qn(){try{return await N("/api/last-error")}catch{return null}}async function Hn(e){try{const t=e?`/api/run-detail?run_id=${e}`:"/api/run-detail";return await N(t)}catch{return null}}async function Vn(e=24){const t=await N(`/api/errors?hours=${e}`);return Qe.value=t,t}async function Gn(e=7){const t=await N(`/api/agent-stats?days=${e}`);return Ot.value=t,t}async function Fr(){return N("/api/setup/status")}async function Bn(e,t){return N("/api/setup/validate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e,value:t})})}async function Kn(e){return N("/api/setup/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})}let at=null;const jr="obsidian-sound";function Ur(){var e;try{return typeof localStorage<"u"&&((e=globalThis.localStorage)==null?void 0:e.getItem(jr))==="1"}catch{return!1}}function Wn(){if(!at)try{at=new AudioContext}catch{return null}return at}function qr(){var t;const e=!Ur();try{(t=globalThis.localStorage)==null||t.setItem(jr,e?"1":"0")}catch{}return e&&Wn(),e}function Yn(){return Ur()}const Hr=[{id:"home",label:"HOME",key:"1"},{id:"queue",label:"QUEUE",key:"2"},{id:"intel",label:"INTEL",key:"3"},{id:"health",label:"HEALTH",key:"4"},{id:"tuning",label:"TUNING",key:"5"},{id:"setup",label:"SETUP",key:"6"}];function J(e){typeof document<"u"&&"startViewTransition"in document?document.startViewTransition(()=>{location.hash=e,W.value=e}):(location.hash=e,W.value=e);try{localStorage.setItem("obsidian-view",e)}catch{}}function zn(){return r("nav",{class:"mobile-tab-bar",role:"tablist",children:Hr.map(e=>r("button",{role:"tab","aria-selected":W.value===e.id,onClick:()=>J(e.id),class:W.value===e.id?"active":"",children:e.label},e.id))})}function Jn(){const e=I.value?"bg-running":se.value==="healthy"?"bg-success":se.value==="degraded"?"bg-warning":"bg-error";return r("header",{class:"border-b border-border px-4 py-3 flex items-center gap-4",children:[r("div",{class:"flex items-center gap-3 mr-4",children:[r("div",{class:`w-2.5 h-2.5 rounded-full ${e}`,style:I.value?{animation:"borderPulse 1.5s ease-in-out infinite"}:{}}),r("span",{class:"text-bright font-bold text-sm tracking-[4px]",children:"OBSIDIAN"}),r("span",{class:"text-dim text-[9px] tracking-[2px] hidden sm:inline",children:"AUTOMATED DOCUMENTARY PIPELINE"}),!ze.value&&r("span",{class:"text-warning text-[10px] tracking-wider animate-pulse",children:"RECONNECTING"})]}),r("nav",{role:"tablist",class:"flex gap-1 desktop-nav",children:Hr.map(t=>r("button",{role:"tab","aria-selected":W.value===t.id,onClick:()=>J(t.id),class:`px-3 py-1.5 text-xs tracking-wider transition-colors rounded + ${W.value===t.id?"bg-bg-2 text-bright border border-border":"text-dim hover:text-text"}`,children:t.label},t.id))}),r("div",{class:"flex-1"}),I.value&&me.value!==null&&r("span",{class:"text-running text-xs font-bold","aria-live":"polite",children:["$",me.value.toFixed(2)]}),Object.keys(gt.value).length>0?r("div",{class:"flex gap-1.5",children:Object.keys(gt.value).map(t=>r("span",{class:"text-warning text-[10px] tracking-wider animate-pulse uppercase",children:t.replace(/_/g," ")},t))}):$t.value&&r("span",{class:"text-warning text-xs animate-pulse",children:"ANALYTICS"}),I.value&&r("button",{onClick:async()=>{try{await Ln(),C("Pipeline killed")}catch{C("Failed to kill pipeline","error")}},class:"px-2 py-1 text-xs text-error border border-error/30 rounded hover:bg-error/10","aria-label":"Kill pipeline",children:"KILL"}),r("button",{onClick:()=>{const t=qr();C(t?"Sound on":"Sound off")},class:"text-dim hover:text-bright text-xs","aria-label":"Toggle sound",children:Yn()?"🔊":"🔇"})]})}function Qn(){if(!I.value)return null;const e=R.value/13*100;return r("div",{class:"fixed bottom-0 left-0 w-full h-[3px] z-50",style:{background:"transparent"},children:r("div",{class:"h-full bg-gradient-to-r from-cyan-500 to-blue-500 transition-all duration-500",style:{width:`${e}%`,boxShadow:"0 0 8px rgba(6, 182, 212, 0.6)"}})})}function Xn(){if(!ie.value)return null;const e=fe(null),t=fe(null),[n,s]=k(""),[i,l]=k(!1),[o,c]=k(!1),u=fe(null);O(()=>{var a;u.current=document.activeElement,(a=e.current)==null||a.focus()},[]),O(()=>{const a=p=>{var h;if(p.key==="Escape"){(h=u.current)==null||h.focus(),ie.value=!1;return}if(p.key==="Tab"&&t.current){const v=t.current.querySelectorAll("button, input, [tabindex], a[href], select, textarea");if(v.length===0)return;const w=v[0],_=v[v.length-1];p.shiftKey&&document.activeElement===w?(p.preventDefault(),_.focus()):!p.shiftKey&&document.activeElement===_&&(p.preventDefault(),w.focus())}};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[]);const d=()=>{var a;(a=u.current)==null||a.focus(),ie.value=!1},f=async()=>{var a;if(!(i||!n.trim())){l(!0);try{await Mr(n.trim()),c(!0),setTimeout(()=>{d(),C(`Fired: ${n.trim()}`),c(!1)},500)}catch(p){(a=p==null?void 0:p.message)!=null&&a.includes("409")?C("Pipeline already running","error"):C("Failed to trigger pipeline","error"),l(!1)}}};return r("div",{ref:t,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/70",onClick:a=>{a.target===a.currentTarget&&d()},children:r("div",{class:`w-[420px] max-w-[90vw] p-6 bg-bg-1 border rounded + ${o?"border-success":"border-border"} transition-colors`,children:[r("div",{class:"text-bright text-sm font-bold mb-3 tracking-wider",children:"QUICK FIRE"}),r("input",{ref:e,type:"text",value:n,onInput:a=>s(a.target.value),onKeyDown:a=>{a.key==="Enter"&&f()},disabled:i,placeholder:"Enter topic...",class:`w-full bg-bg-2 border border-border text-bright px-3 py-2 rounded text-sm + font-mono outline-none focus:border-running disabled:opacity-50`}),r("div",{class:"flex justify-end gap-2 mt-3",children:[r("button",{onClick:d,class:"text-dim text-xs hover:text-text",children:"Cancel"}),r("button",{onClick:f,disabled:i||!n.trim(),class:`px-3 py-1.5 text-xs font-bold bg-running/20 text-running border border-running/30 + rounded hover:bg-running/30 disabled:opacity-50`,children:i?"Firing...":"FIRE"})]})]})})}const Zn={1:"#06B6D4",2:"#22D3EE",3:"#0891B2",4:"#67E8F9",5:"#F59E0B",6:"#14B8A6",7:"#8B5CF6",8:"#A78BFA",9:"#7C3AED",10:"#C4B5FD",11:"#22C55E",12:"#4ADE80",13:"#16A34A"},le=[{num:1,name:"Research Agent",codename:"ORACLE"},{num:2,name:"Originality Agent",codename:"ARCHAEON"},{num:3,name:"Narrative Architect",codename:"ARCHITECT"},{num:4,name:"Script Writer",codename:"SCRIBE"},{num:5,name:"Fact Verification",codename:"ARBITER"},{num:6,name:"SEO Agent",codename:"CIPHER"},{num:7,name:"Scene Breakdown",codename:"SPECTER"},{num:8,name:"Audio Engine",codename:"RESONANCE"},{num:9,name:"Footage Hunter",codename:"HUNTER"},{num:10,name:"Image Generator",codename:"VISION"},{num:11,name:"Video Assembly",codename:"HERALD"},{num:12,name:"Render Engine",codename:"CARVER"},{num:13,name:"Upload & Publish",codename:"MIRROR"}];function es(){const e=X.value,t=fe(null);if(O(()=>{var u,d;if(e==null)return;const c=f=>{if(f.key==="Escape"&&(X.value=null),f.key==="Tab"&&t.current){const a=t.current.querySelectorAll("button, [tabindex], a[href], input, select, textarea");if(a.length===0)return;const p=a[0],h=a[a.length-1];f.shiftKey&&document.activeElement===p?(f.preventDefault(),h.focus()):!f.shiftKey&&document.activeElement===h&&(f.preventDefault(),p.focus())}};return document.addEventListener("keydown",c),(d=(u=t.current)==null?void 0:u.querySelector("button"))==null||d.focus(),()=>document.removeEventListener("keydown",c)},[e]),e==null)return null;const n=le.find(c=>c.num===e);if(!n)return null;const s=Zn[n.num]??"var(--color-running)",i=n.num<=4,l=n.num>=7&&n.num<=10,o=n.num>=11;return r("div",{class:"fixed inset-0 z-40 flex justify-end",onClick:()=>{X.value=null},children:[r("div",{class:"absolute inset-0 bg-black/40"}),r("div",{ref:t,class:"relative w-80 bg-bg-1 border-l border-border h-full overflow-y-auto p-4",onClick:c=>c.stopPropagation(),children:[r("button",{onClick:()=>{X.value=null},class:"absolute top-3 right-3 text-dim hover:text-bright","aria-label":"Close panel",children:"×"}),r("div",{class:"mb-4",children:[r("div",{class:"text-[10px] text-dim tracking-wider",children:["STAGE ",n.num]}),r("div",{class:"text-xl font-bold",style:{color:s},children:n.codename}),r("div",{class:"text-sm text-text mt-1",children:n.name})]}),r("div",{class:"flex gap-2 mb-4",children:[r("span",{class:`text-[10px] px-2 py-0.5 rounded border ${i?"border-cyan-500/30 text-cyan-400":l?"border-purple-500/30 text-purple-400":o?"border-green-500/30 text-green-400":"border-warning/30 text-warning"}`,children:i?"WRITING":l?"MEDIA":o?"DELIVERY":"VERIFY"}),(n.num===8||n.num===9)&&r("span",{class:"text-[10px] px-2 py-0.5 rounded border border-running/30 text-running",children:"PARALLEL"})]}),r("div",{class:"backdrop-blur-sm bg-bg-2 border border-border rounded p-3 text-xs text-dim",children:[i&&r("div",{children:"Text stage — generates script, research, or analysis content"}),n.num===5&&r("div",{children:"Verification — validates scoring config and content quality"}),n.num===6&&r("div",{children:"Planning — determines visual and narrative structure"}),l&&r("div",{children:"Media stage — produces audio, images, or video assets"}),o&&r("div",{children:"Delivery — renders, uploads, or finalizes output"})]})]})]})}const ct=[{title:"Welcome to The Obsidian Archive",desc:"Automated dark history documentaries — 13 AI agents, one pipeline, zero manual work."},{title:"Queue",desc:"Browse and manage topics. Score-ranked by trend strength, audience demand, and era gaps."},{title:"Real-Time Pipeline",desc:"Watch 13 AI agents research, write, verify, generate media, and render your video live."},{title:"YouTube-Ready Output",desc:"Get a fully rendered documentary with SEO metadata, analytics, and automated upload."}];function ts(){const[e,t]=k(0),[n,s]=k(!1);if(O(()=>{try{localStorage.getItem("obsidian-onboarded")||s(!0)}catch{}},[]),!n)return null;const i=()=>{s(!1);try{localStorage.setItem("obsidian-onboarded","true")}catch{}},l=ct[e],o=e===ct.length-1;return r("div",{class:"fixed inset-0 z-50 flex items-center justify-center bg-black/70",children:r("div",{class:"w-[400px] max-w-[90vw] backdrop-blur-md bg-bg-1/95 border border-border rounded-lg p-6",children:[r("div",{class:"text-[10px] text-dim tracking-wider mb-1",children:[e+1," / ",ct.length]}),r("div",{class:"text-bright font-bold text-sm mb-2",children:l.title}),r("div",{class:"text-text text-xs leading-relaxed mb-6",children:l.desc}),r("div",{class:"flex justify-between items-center",children:[r("button",{onClick:i,class:"text-dim text-xs hover:text-text",children:"Skip"}),r("button",{onClick:()=>o?i():t(c=>c+1),class:"px-4 py-1.5 text-xs font-bold bg-running/20 text-running border border-running/30 rounded hover:bg-running/30",children:o?"Got it":"Next"})]})]})})}const rs=[{key:"1",desc:"Home view"},{key:"2",desc:"Queue view"},{key:"3",desc:"Intel view"},{key:"4",desc:"Health view"},{key:"5",desc:"Tuning view"},{key:"T",desc:"Quick-fire topic"},{key:"L",desc:"Toggle log panel"},{key:"S",desc:"Toggle sound"},{key:"?",desc:"This help overlay"},{key:"Esc",desc:"Close overlay / panel"}];function ns(){return re.value?r("div",{class:"fixed inset-0 z-50 flex items-center justify-center bg-black/70",onClick:e=>{e.target===e.currentTarget&&(re.value=!1)},children:r("div",{class:"w-[340px] max-w-[90vw] backdrop-blur-md bg-bg-1/95 border border-border rounded-lg p-5",children:[r("div",{class:"text-bright font-bold text-sm mb-4 tracking-wider",children:"KEYBOARD SHORTCUTS"}),r("div",{class:"space-y-2",children:rs.map(e=>r("div",{class:"flex items-center gap-3 text-xs",children:[r("kbd",{class:"bg-bg-2 border border-border rounded px-2 py-0.5 text-bright font-mono min-w-[32px] text-center",children:e.key}),r("span",{class:"text-text",children:e.desc})]},e.key))}),r("button",{onClick:()=>{re.value=!1},class:"mt-4 text-dim text-xs hover:text-text",children:"Close"})]})}):null}function ss(e){if(e.length<6)return"→";const t=e.slice(-3),n=e.slice(-6,-3),s=t.reduce((l,o)=>l+o,0)/t.length,i=n.reduce((l,o)=>l+o,0)/n.length;return s>i*1.05?"↑":s=2&&r("svg",{class:"mt-1",width:"80",height:"16",viewBox:"0 0 80 16",children:r("polyline",{fill:"none",stroke:"currentColor","stroke-width":"1.5",class:"text-dim",points:n.map((i,l)=>{const o=l/(n.length-1)*78+1,c=Math.max(...n,1),u=15-i/c*14;return`${o},${u}`}).join(" ")})})]})}function ls(){const e=mt.value;return r("div",{class:"flex gap-3 flex-wrap","aria-live":"polite",children:[r(Ne,{label:"Queue",value:Or.value,trend:e.queue}),r(Ne,{label:"Errors (24h)",value:At.value,trend:e.errors}),r(Ne,{label:"Health",value:se.value}),r(Ne,{label:"Last Cost",value:bt.value!==null?`$${bt.value.toFixed(2)}`:"—",trend:e.cost})]})}const Xt={idle:"bg-dim/20 text-dim",running:"bg-running/20 text-running",done:"bg-success/20 text-success",failed:"bg-error/20 text-error",error:"bg-error/20 text-error",killed:"bg-warning/20 text-warning"};function Pt({status:e}){const t=Xt[e]??Xt.idle;return r("span",{class:`inline-block px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wider ${t}`,children:e})}function Vr({title:e,children:t}){return r("div",{class:"flex flex-col items-center justify-center py-16 text-center",children:[r("p",{class:"text-dim text-lg mb-2",children:e}),t&&r("div",{class:"text-dim text-sm",children:t})]})}const os=ae(()=>Ye.value.slice(-300)),as=ae(()=>os.value.map(e=>/STAGE\s+\d+/.test(e)?{text:e,type:"stage"}:/ERROR|FAIL/i.test(e)?{text:e,type:"error"}:/✓|SUCCESS/i.test(e)?{text:e,type:"success"}:{text:e,type:"default"})),cs={stage:"text-cyan-400",error:"text-error",success:"text-success",default:"text-text"};function ds(){const e=mn.value,[t,n]=k([]);O(()=>{e==="just-completed"&&jn().catch(()=>{}),e==="idle"&&(Un().catch(()=>{}),Ir().then(l=>n(l.slice(-3).reverse())).catch(()=>{}))},[e]);const[s,i]=k("");return O(()=>{if(!I.value||!ke.value)return;const l=()=>{const c=new Date(ke.value).getTime(),u=Math.floor((Date.now()-c)/1e3),d=Math.floor(u/60),f=u%60;i(`${d}m ${f}s`)};l();const o=setInterval(l,1e3);return()=>clearInterval(o)},[I.value,ke.value]),r("div",{class:"p-4 flex-1",children:[r(ls,{}),r("div",{class:"mt-6",children:[e==="running"&&r(us,{elapsed:s}),e==="just-completed"&&r(hs,{}),e==="error"&&r(ps,{}),e==="idle"&&r(fs,{historyItems:t})]})]})}function us({elapsed:e}){var u,d,f,a,p;const t=le.find(h=>h.num===R.value),n=Pr.value,[s,i]=k(!1),[l,o]=k(null),c=[];return(u=n[1])!=null&&u.facts&&c.push(`Research: ${n[1].facts} facts`),(d=n[4])!=null&&d.words&&c.push(`Script: ${Number(n[4].words).toLocaleString()} words`),(f=n[8])!=null&&f.duration_s&&c.push(`Audio: ${Math.floor(Number(n[8].duration_s)/60)}m ${Number(n[8].duration_s)%60}s`),(a=n[9])!=null&&a.images_found&&c.push(`Images: ${n[9].images_found}`),(p=n[10])!=null&&p.generated&&c.push(`Generated: ${n[10].passed_quality??n[10].generated}/${n[10].generated}`),r("div",{class:"flex gap-4",children:[r("div",{class:"flex-1",children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4",children:[r("div",{class:"flex items-center gap-3 mb-2",children:[r(Pt,{status:"running"}),r("span",{class:"text-bright font-bold",children:ge.value})]}),r("div",{class:"flex gap-4 text-sm text-dim",children:[r("span",{children:["Stage ",R.value,"/13"]}),r("span",{children:(t==null?void 0:t.codename)??"..."}),r("span",{children:e}),me.value!=null&&r("span",{class:"text-running",children:["$",me.value.toFixed(2)]})]})]}),c.length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded px-4 py-2 mb-4 text-xs text-dim",children:c.join(" | ")}),r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4",children:r("div",{class:"flex gap-2 flex-wrap",children:le.map(h=>{const v=h.num{X.value=h.num},style:{cursor:"pointer"},children:h.num},h.num)})})}),r("div",{class:"grid grid-cols-4 gap-2",children:le.map(h=>{const v=h.num{_?o(l===h.num?null:h.num):X.value=h.num},children:[r("div",{class:"text-dim text-[9px]",children:h.num}),r("div",{class:`font-bold ${w?"text-running":v?"text-success":"text-dim"}`,children:h.codename}),(_==null?void 0:_.timing_s)!=null&&!b&&r("div",{class:"text-[9px] text-dim mt-0.5",children:[Number(_.timing_s).toFixed(1),"s"]}),b&&r("div",{class:"mt-2 space-y-1 text-[10px]",children:Object.entries(_).map(([g,E])=>r("div",{class:"flex gap-1",children:[r("span",{class:"text-dim",children:[g.replace(/_/g," "),":"]}),r("span",{class:"text-text",children:typeof E=="object"?JSON.stringify(E):String(E)})]},g))})]},h.num)})}),Object.keys(n).length>0&&r("div",{class:"mt-2",children:[r("button",{onClick:()=>i(!s),class:"text-[10px] text-dim hover:text-text",children:s?"HIDE RAW":"RAW"}),s&&r("pre",{class:"mt-1 bg-bg-2 border border-border rounded p-2 text-[10px] text-text overflow-x-auto max-h-48",children:JSON.stringify(n,null,2)})]})]}),Je.value&&r("div",{class:"w-[285px] bg-bg-1 border border-border rounded p-3 overflow-y-auto max-h-[calc(100vh-200px)]",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"LIVE LOG"}),r("div",{class:"font-mono text-[11px] space-y-0.5",children:as.value.map((h,v)=>r("div",{class:cs[h.type],children:h.text},v))})]})]})}function hs(){var l;const e=Lr.value,[t,n]=k(null),[s,i]=k(!1);return O(()=>{Hn().then(o=>n(o))},[]),r("div",{class:"space-y-4",children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-success/30 rounded p-6",children:[r("div",{class:"flex items-center gap-3 mb-4",children:[r("span",{class:"text-success text-2xl font-bold",style:{background:"linear-gradient(90deg, var(--color-bright), var(--color-success), var(--color-bright))",backgroundClip:"text",WebkitBackgroundClip:"text",color:"transparent",backgroundSize:"200%",animation:"shimmer 2s ease-in-out infinite"},children:"DONE"}),r("span",{class:"text-bright font-bold",children:(e==null?void 0:e.topic)??ge.value})]}),e&&r("div",{class:"flex gap-4 text-sm text-dim mb-4",children:[r("span",{children:e.elapsed_seconds?`${Math.floor(e.elapsed_seconds/60)}m`:"—"}),e.cost_usd!=null&&r("span",{children:["$",e.cost_usd.toFixed(2)]}),r("span",{children:[e.stages_completed,"/13 stages"]})]}),r("button",{onClick:()=>{Rr.value=be.value??"";try{localStorage.setItem("obsidian-cd",be.value??"")}catch{}},class:"px-3 py-1.5 text-xs text-dim border border-border rounded hover:text-bright",children:"Got it"})]}),t&&r("div",{class:"space-y-3",children:[t.costs.usd_total>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"COST BREAKDOWN"}),r("div",{class:"flex gap-4 text-xs mb-2",children:r("span",{class:"text-bright font-bold",children:["$",t.costs.usd_total.toFixed(2)," total"]})}),Object.keys(t.costs.per_service).length>0&&r("div",{class:"space-y-1",children:Object.entries(t.costs.per_service).map(([o,c])=>r("div",{class:"flex items-center gap-2 text-[10px]",children:[r("span",{class:"text-dim w-20",children:o}),r("div",{class:"flex-1 h-1.5 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded",style:{width:`${c/t.costs.usd_total*100}%`}})}),r("span",{class:"text-text w-12 text-right",children:["$",c.toFixed(2)]})]},o))})]}),Object.keys(t.stage_timings).length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"STAGE TIMINGS"}),r("div",{class:"space-y-1",children:Object.entries(t.stage_timings).map(([o,c])=>{const u=Math.max(...Object.values(t.stage_timings)),d=le.find(f=>String(f.num)===o);return r("div",{class:"flex items-center gap-2 text-[10px]",children:[r("span",{class:"text-dim w-24 truncate",children:(d==null?void 0:d.codename)??`Stage ${o}`}),r("div",{class:"flex-1 h-1.5 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-success/60 rounded",style:{width:`${c/u*100}%`}})}),r("span",{class:"text-text w-12 text-right",children:[c.toFixed(1),"s"]})]},o)})})]}),Object.keys(t.quality).length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"QUALITY GATES"}),r("div",{class:"grid grid-cols-3 gap-2 text-xs",children:[t.quality.fact_verification&&r(he,{label:"Fact Check",value:t.quality.fact_verification,pass:t.quality.fact_verification==="APPROVED"}),t.quality.compliance&&r(he,{label:"Compliance",value:t.quality.compliance.risk_level,pass:t.quality.compliance.risk_level==="green"}),t.quality.seo_score!=null&&r(he,{label:"SEO Score",value:String(t.quality.seo_score),pass:t.quality.seo_score>=70}),t.quality.qa_tier2_sync_pct!=null&&r(he,{label:"Audio Sync",value:`${t.quality.qa_tier2_sync_pct.toFixed(1)}%`,pass:t.quality.qa_tier2_sync_pct>=90}),t.quality.predictive_score!=null&&r(he,{label:"Prediction",value:t.quality.predictive_score.toFixed(1),pass:t.quality.predictive_score>=6}),t.quality.script_doctor&&r(he,{label:"Script Doctor",value:((l=Object.values(t.quality.script_doctor)[0])==null?void 0:l.toFixed(2))??"—",pass:!0})]})]}),t.agent_performance.length>0&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"AGENT PERFORMANCE"}),r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-[10px]",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"Agent"}),r("th",{class:"text-right p-1",children:"Time"}),r("th",{class:"text-right p-1",children:"SLA"}),r("th",{class:"text-left p-1",children:"Model"}),r("th",{class:"text-left p-1",children:"Status"})]})}),r("tbody",{children:t.agent_performance.map((o,c)=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-text",children:o.agent}),r("td",{class:`p-1 text-right ${o.elapsed_s>o.sla_s?"text-error":"text-success"}`,children:[o.elapsed_s.toFixed(1),"s"]}),r("td",{class:"p-1 text-right text-dim",children:[o.sla_s,"s"]}),r("td",{class:"p-1 text-dim",children:o.model}),r("td",{class:`p-1 ${o.status==="success"?"text-success":"text-error"}`,children:o.status})]},c))})]})})]}),r("button",{onClick:()=>i(!s),class:"text-[10px] text-dim hover:text-text",children:s?"HIDE RAW":"RAW"}),s&&r("pre",{class:"bg-bg-2 border border-border rounded p-2 text-[10px] text-text overflow-x-auto max-h-60",children:JSON.stringify(t,null,2)})]})]})}function he({label:e,value:t,pass:n}){return r("div",{class:`backdrop-blur-sm bg-bg-2 border rounded p-2 text-center ${n?"border-success/30":"border-error/30"}`,children:[r("div",{class:"text-dim text-[9px]",children:e}),r("div",{class:`font-bold text-sm ${n?"text-success":"text-error"}`,children:t})]})}function ps(){const e=Ye.value.slice(-5),[t,n]=k(null);return O(()=>{qn().then(s=>n(s))},[]),r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-error/30 rounded p-6",children:[r("div",{class:"flex items-center gap-3 mb-3",children:[r(Pt,{status:Ee.value}),r("span",{class:"text-bright font-bold",children:ge.value})]}),r("div",{class:"text-sm text-dim mb-2",children:["Failed at stage ",R.value]}),t&&r("div",{class:"bg-bg-2 border border-border rounded p-3 mb-4 space-y-2 text-xs",children:[r("div",{class:"text-dim text-[10px] tracking-wider",children:"PIPELINE DOCTOR DIAGNOSIS"}),r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Stage"}),r("span",{class:"text-bright",children:[t.stage_name," (#",t.stage_num,")"]})]}),t.diagnosis&&r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Diagnosis"}),r("span",{class:"text-text",children:t.diagnosis})]}),t.root_cause&&r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Root Cause"}),r("span",{class:"text-text",children:t.root_cause})]}),t.strategy&&r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[70px]",children:"Strategy"}),r("span",{class:"text-running",children:t.strategy})]})]}),!t&&r("div",{class:"bg-bg-2 rounded p-3 mb-4 text-[11px] font-mono text-error",children:e.map((s,i)=>r("div",{children:s},i))}),r("div",{class:"flex gap-2",children:[r("button",{onClick:async()=>{try{await Mr(ge.value,R.value),C(`Retrying from stage ${R.value}`)}catch{C("Failed to retry","error")}},class:"px-3 py-1.5 text-xs text-running border border-running/30 rounded hover:bg-running/10",children:["Retry from Stage ",R.value]}),r("button",{onClick:()=>{location.hash="health"},class:"px-3 py-1.5 text-xs text-dim border border-border rounded hover:text-text",children:"Health View"})]})]})}function fs({historyItems:e}){var n,s,i;const t=Nr.value;return r("div",{children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4 flex items-center justify-between",children:[r("div",{children:[r("div",{class:"text-bright text-sm font-bold",children:"Ready to fire"}),r("div",{class:"text-dim text-xs mt-1",children:"Press T to quick-fire a topic"})]}),r("button",{onClick:()=>{ie.value=!0},class:"px-4 py-2 text-sm font-bold bg-running/20 text-running border border-running/30 rounded hover:bg-running/30",children:"FIRE"})]}),e.length>0?r("div",{class:"space-y-2 mb-4",children:[r("div",{class:"text-dim text-[10px] tracking-wider",children:"RECENT RUNS"}),e.map((l,o)=>r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3 flex items-center gap-3",children:[l.youtube_id&&r("a",{href:`https://youtube.com/watch?v=${l.youtube_id}`,target:"_blank",rel:"noopener noreferrer",children:r("img",{src:`https://img.youtube.com/vi/${l.youtube_id}/mqdefault.jpg`,class:"rounded border border-border",width:80,alt:`Thumbnail: ${l.topic}`})}),r(Pt,{status:l.status}),r("span",{class:"text-sm text-bright flex-1",children:l.topic}),r("div",{class:"flex flex-col items-end gap-0.5",children:[l.cost_usd!=null&&r("span",{class:"text-xs text-dim",children:["$",l.cost_usd.toFixed(2)]}),l.elapsed_seconds>0&&r("span",{class:"text-[10px] text-dim",children:[Math.floor(l.elapsed_seconds/60),"m"]})]})]},o))]}):r(Vr,{title:"Welcome. Add a topic to get started.",children:r("span",{children:["Go to ",r("a",{href:"#queue",class:"text-running underline",children:"Queue"})," or press T"]})}),t&&t.length>0&&r("div",{class:"text-dim text-xs mt-4",children:["Next: ",(n=t[0])==null?void 0:n.job," (",(s=t[0])==null?void 0:s.day," ",(i=t[0])==null?void 0:i.time_utc," UTC)"]})]})}const Zt={trending:"Trending",audience_request:"Audience Request",era_gap:"Era Gap",manual:"Manual"};function xs(e){return e>.8?{label:"HOT",cls:"text-error bg-error/20 border-error/30"}:e>=.6?{label:"WARM",cls:"text-warning bg-warning/20 border-warning/30"}:{label:"COOL",cls:"text-dim bg-bg-2 border-border"}}function vs(e,t,n){return[...e].sort((s,i)=>{const l=s[t],o=i[t],c=typeof l=="number"?l-o:String(l).localeCompare(String(o));return n==="asc"?c:-c})}function gs(){const[e,t]=k([]),[n,s]=k(!0),[i,l]=k(""),[o,c]=k(!1),[u,d]=k("score"),[f,a]=k("desc"),[p,h]=k(null),v=async()=>{try{const x=await Nn();t(x)}catch{C("Failed to load queue","error")}finally{s(!1)}};O(()=>{v()},[]);const w=async()=>{if(!(!i.trim()||o)){c(!0);try{await Dn(i.trim()),l(""),C("Topic added"),await v()}catch{C("Failed to add topic","error")}finally{c(!1)}}},_=async x=>{try{await In(x),t(e.filter(y=>y.id!==x)),C("Topic removed")}catch{C("Failed to remove topic","error")}},b=x=>{u===x?a(y=>y==="asc"?"desc":"asc"):(d(x),a(x==="score"?"desc":"asc"))},g=vs(e,u,f),E=x=>u===x?f==="asc"?" ▴":" ▾":"";return r("div",{class:"p-4",children:[r("div",{class:"flex gap-2 mb-4",children:[r("input",{type:"text",value:i,onInput:x=>l(x.target.value),onKeyDown:x=>{x.key==="Enter"&&w()},placeholder:"Add a topic...",class:"flex-1 bg-bg-2 border border-border text-bright px-3 py-2 rounded text-sm font-mono outline-none focus:border-running"}),r("button",{onClick:w,disabled:o||!i.trim(),class:"px-4 py-2 text-xs font-bold bg-running/20 text-running border border-running/30 rounded hover:bg-running/30 disabled:opacity-50",children:"ADD"})]}),n?r("div",{class:"text-dim text-sm",children:"Loading..."}):e.length===0?r(Vr,{title:"Queue is empty",children:r("span",{children:"Add topics above to get started"})}):r("div",{children:[r("div",{class:"flex items-center gap-3 px-3 py-2 text-[10px] tracking-wider text-dim",children:[r("span",{class:"w-16",children:"STATUS"}),r("button",{class:"flex-1 text-left hover:text-text",onClick:()=>b("topic"),children:["TOPIC",E("topic")]}),r("span",{class:"w-10 text-center",children:"TIER"}),r("button",{class:"w-14 text-right hover:text-text",onClick:()=>b("score"),children:["SCORE",E("score")]}),r("button",{class:"w-24 text-right hover:text-text",onClick:()=>b("source"),children:["SOURCE",E("source")]}),r("span",{class:"w-5"})]}),r("div",{class:"space-y-1",children:g.map(x=>{const y=xs(x.score),Y=p===x.id;return r("div",{children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3 flex items-center gap-3 cursor-pointer",onClick:()=>h(Y?null:x.id),children:[r("span",{class:`text-xs px-1.5 py-0.5 rounded w-16 text-center ${x.status==="queued"?"bg-running/20 text-running":x.status==="done"?"bg-success/20 text-success":"bg-dim/20 text-dim"}`,children:x.status}),r("span",{class:"text-sm text-bright flex-1",children:x.topic}),r("span",{class:`text-[9px] px-1.5 py-0.5 rounded border font-bold w-10 text-center ${y.cls}`,children:y.label}),r("span",{class:"text-xs text-dim w-14 text-right",children:x.score.toFixed(2)}),r("span",{class:"text-xs text-dim w-24 text-right",children:Zt[x.source]??x.source}),r("button",{onClick:D=>{D.stopPropagation(),_(x.id)},class:"text-dim hover:text-error text-xs w-5 text-center","aria-label":`Delete ${x.topic}`,children:"×"})]}),Y&&r("div",{class:"bg-bg-2 border border-border/50 rounded-b mx-1 px-3 py-2 text-[10px] space-y-1",children:[r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim w-20",children:"Score"}),r("span",{class:"text-bright",children:x.score.toFixed(4)})]}),r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim w-20",children:"Source"}),r("span",{class:"text-text",children:Zt[x.source]??x.source})]}),Object.entries(x).filter(([D])=>!["id","topic","status","score","source"].includes(D)).map(([D,U])=>r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim w-20",children:D.replace(/_/g," ")}),r("span",{class:"text-text",children:typeof U=="object"?JSON.stringify(U):String(U??"—")})]},D))]})]},x.id)})}),r("div",{class:"text-dim text-[10px] mt-3",children:[e.length," topic",e.length!==1?"s":""]})]})]})}function H({class:e="",width:t="100%",height:n="1rem"}){return r("div",{class:`animate-pulse bg-bg-2 rounded ${e}`,style:{width:t,height:n},"aria-hidden":"true"})}class Gr extends pe{constructor(){super(...arguments);It(this,"state",{error:null})}static getDerivedStateFromError(n){return{error:n}}render(){return this.state.error?this.props.fallback??r("div",{class:"p-4 border border-error/30 bg-error/10 text-error text-sm rounded",children:["Something went wrong: ",this.state.error.message]}):this.props.children}}function et({renderer:e,height:t=200,class:n="",ariaLabel:s}){const i=fe(null),[l,o]=k({width:0,height:t}),[c,u]=k(null);return O(()=>{const d=i.current;if(!d)return;let f;const a=new ResizeObserver(p=>{clearTimeout(f),f=setTimeout(()=>{const h=p[0];h&&o({width:h.contentRect.width,height:t})},150)});return a.observe(d),()=>{clearTimeout(f),a.disconnect()}},[t]),r("div",{ref:i,class:`relative ${n}`,style:{minHeight:`${t}px`},children:[l.width>0&&r("svg",{width:l.width,height:l.height,class:"overflow-visible",...s?{role:"img","aria-label":s}:{},children:e(l.width,l.height,u)}),c&&r("div",{class:"absolute z-10 bg-bg-2 border border-border rounded px-2 py-1 text-[11px] text-bright pointer-events-none",style:{left:`${Math.min(c.x,l.width-120)}px`,top:`${Math.max(c.y-40,0)}px`},children:[r("div",{class:"text-dim",children:c.label}),r("div",{class:"font-bold",children:c.value})]})]})}function bs(e,t,n,s){if(!e.length)return[];const i={top:10,right:10,bottom:30,left:40},l=t-i.left-i.right,o=n-i.top-i.bottom,c=Math.max(...e.map(a=>a.value),1),u=Math.max(4,Math.min(40,l/e.length-4)),d=e.map((a,p)=>{const h=i.left+l/e.length*p+(l/e.length-u)/2,v=a.value/c*o,w=i.top+o-v;return r("g",{children:[r("rect",{x:h,y:w,width:u,height:v,fill:a.color||"var(--color-running)",rx:2,opacity:.8,onMouseEnter:()=>s({x:h,y:w,label:a.label,value:a.value.toLocaleString()}),onMouseLeave:()=>s(null)}),u>12&&r("text",{x:h+u/2,y:n-8,"text-anchor":"middle",fill:"var(--color-dim)","font-size":"9",children:a.label.slice(0,6)})]},p)});return[...[0,.25,.5,.75,1].map(a=>{const p=Math.round(c*a),h=i.top+o-o*a;return r("g",{children:[r("line",{x1:i.left-4,y1:h,x2:i.left+l,y2:h,stroke:"var(--color-border)","stroke-width":.5}),r("text",{x:i.left-6,y:h+3,"text-anchor":"end",fill:"var(--color-dim)","font-size":"9",children:p})]},`t${a}`)}),...d]}function Lt(e,t,n,s){if(!e.length)return[];const i={top:5,right:10,bottom:5,left:80},l=t-i.left-i.right,o=n-i.top-i.bottom,c=Math.max(...e.map(d=>d.value),1),u=Math.max(4,Math.min(24,o/e.length-4));return e.map((d,f)=>{const a=i.top+o/e.length*f+(o/e.length-u)/2,p=d.value/c*l;return r("g",{children:[r("text",{x:i.left-6,y:a+u/2+3,"text-anchor":"end",fill:"var(--color-dim)","font-size":"10",children:d.label.slice(0,12)}),r("rect",{x:i.left,y:a,width:Math.max(2,p),height:u,fill:d.color||"var(--color-running)",rx:2,opacity:.8,onMouseEnter:()=>s({x:i.left+p,y:a,label:d.label,value:d.value.toLocaleString()}),onMouseLeave:()=>s(null)}),r("text",{x:i.left+p+6,y:a+u/2+3,fill:"var(--color-text)","font-size":"10",children:d.value.toLocaleString()})]},f)})}const ms=["summary","performance","content","audience","music","config"];function _s(){const[e,t]=k("summary"),[n,s]=k(!1);O(()=>{Ce.value[e]===void 0&&(s(!0),Rn(e).catch(()=>{}).finally(()=>s(!1)))},[e]);const i=Ce.value[e];return r("div",{class:"p-4",children:[r("div",{class:"flex gap-1 mb-4",children:ms.map(l=>r("button",{onClick:()=>t(l),class:`px-3 py-1.5 text-xs tracking-wider rounded + ${e===l?"bg-bg-2 text-bright border border-border":"text-dim hover:text-text"}`,children:l.toUpperCase()},l))}),r(Gr,{children:n?r("div",{class:"space-y-3",children:[r(H,{height:"2rem"}),r(H,{height:"4rem"}),r(H,{height:"4rem"})]}):i?e==="summary"?r(ys,{data:i}):e==="performance"?r(ws,{data:i}):e==="content"?r(ks,{data:i}):e==="audience"?r(Ss,{data:i}):e==="music"?r(Cs,{data:i}):e==="config"?r($s,{data:i}):r(Ts,{data:i}):r("div",{class:"text-dim text-sm",children:"No data available"})})]})}function ys({data:e}){const t=(e==null?void 0:e.signals)??[],n={info:"border-running/30",success:"border-success/30",warning:"border-warning/30",error:"border-error/30",neutral:"border-border"};return r("div",{class:"space-y-3",children:t.map((s,i)=>r("div",{class:`backdrop-blur-sm bg-bg-1/80 border rounded p-3 ${n[s.type]??"border-border"}`,children:[r("div",{class:"text-dim text-[10px] tracking-wider",children:s.label}),r("div",{class:"text-bright text-sm mt-1",children:s.value})]},i))})}function ws({data:e}){const t=Nt(e.per_video_stats),n=e.era_performance,s=e.retention_analysis;return r("div",{class:"space-y-4",children:[t.length>0&&r(L,{title:"PER VIDEO PERFORMANCE",children:r(et,{height:200,renderer:(i,l,o)=>bs(t.slice(0,20).map(c=>({label:c.title??"",value:Number(c.views??0),color:"var(--color-running)"})),i,l,o)})}),s&&r(L,{title:"RETENTION ANALYSIS",children:r(it,{data:s})}),n&&r(L,{title:"ERA PERFORMANCE",children:r(et,{height:180,renderer:(i,l,o)=>Lt(Object.entries(n).map(([c,u])=>({label:c,value:Number((u==null?void 0:u.avg_views)??u??0)})),i,l,o)})}),Object.keys(e).length===0&&r("div",{class:"text-dim text-sm",children:"No performance data available"})]})}function ks({data:e}){const t=e.tag_performance,n=Nt(e.top_performing_videos);return r("div",{class:"space-y-4",children:[t&&Object.keys(t).length>0&&r(L,{title:"TAG PERFORMANCE",children:r(et,{height:Math.min(300,Object.keys(t).length*28+20),renderer:(s,i,l)=>Lt(Object.entries(t).sort(([,o],[,c])=>c-o).slice(0,10).map(([o,c])=>({label:o,value:c,color:"var(--color-success)"})),s,i,l)})}),n.length>0&&r(Es,{title:"TOP PERFORMING VIDEOS",items:n,limit:5}),Object.entries(e).filter(([s])=>!["tag_performance","top_performing_videos"].includes(s)).map(([s,i])=>r(L,{title:s.replace(/_/g," ").toUpperCase(),children:r(it,{data:i})},s))]})}function Ss({data:e}){const t=Nt(e.audience_requests);return r("div",{class:"space-y-4",children:[t.length>0&&r(L,{title:"AUDIENCE REQUESTS",children:r(et,{height:Math.min(250,t.length*28+20),renderer:(n,s,i)=>Lt(t.slice(0,8).map(l=>({label:String(l.topic??l),value:Number(l.count??1),color:"var(--color-warning)"})),n,s,i)})}),Object.entries(e).filter(([n])=>n!=="audience_requests").map(([n,s])=>r(L,{title:n.replace(/_/g," ").toUpperCase(),children:typeof s=="object"&&s!==null&&!Array.isArray(s)?r(it,{data:s}):r("pre",{class:"text-[11px] text-text overflow-x-auto max-h-60",children:JSON.stringify(s,null,2)})},n))]})}function L({title:e,children:t}){return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-3",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:e}),t]})}function it({data:e}){return r("div",{class:"space-y-1 text-xs",children:Object.entries(e).map(([t,n])=>r("div",{class:"flex gap-2",children:[r("span",{class:"text-dim min-w-[100px]",children:t.replace(/_/g," ")}),r("span",{class:"text-text",children:typeof n=="object"?JSON.stringify(n):String(n??"—")})]},t))})}function Ts({data:e}){return r("div",{class:"space-y-4",children:Object.entries(e).map(([t,n])=>r(L,{title:t.replace(/_/g," ").toUpperCase(),children:r("pre",{class:"text-[11px] text-text overflow-x-auto max-h-60",children:typeof n=="object"?JSON.stringify(n,null,2):String(n)})},t))})}function Es({title:e,items:t,limit:n}){const[s,i]=k(!1),l=s?t:t.slice(0,n);return r(L,{title:e,children:[r("div",{class:"space-y-1.5",children:l.map((o,c)=>{const u=o;return r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"text-dim w-4",children:[c+1,"."]}),r("span",{class:"text-bright flex-1 truncate",children:String(u.title??u.topic??JSON.stringify(o))}),u.views!=null&&r("span",{class:"text-dim",children:[Number(u.views).toLocaleString()," views"]})]},c)})}),t.length>n&&r("button",{onClick:()=>i(!s),class:"text-running text-[10px] mt-2 hover:underline",children:s?"Show less":`Show all ${t.length}`})]})}function Cs({data:e}){const t=e.mood_performance,n=e.bpm_performance,s=e.recommendations,i=e.adaptation_impact,l=e.stems_impact,o=e.source_distribution,c=t?Object.entries(t).sort(([,a],[,p])=>p.avg_retention-a.avg_retention):[],u=n?Object.entries(n).sort(([,a],[,p])=>p.avg_retention-a.avg_retention):[],d=c[0],f=u[0];return r("div",{class:"space-y-4",children:[r("div",{class:"grid grid-cols-3 gap-2",children:[d&&r("div",{class:"p-3 rounded bg-bg-2 border border-success/30",children:[r("div",{class:"text-xs text-dim",children:"Best Mood"}),r("div",{class:"text-bright capitalize",children:d[0]}),r("div",{class:"text-xs text-dim",children:[d[1].avg_retention,"% retention"]})]}),i&&(i.adapted_count??0)>0&&r("div",{class:"p-3 rounded bg-bg-2 border border-border",children:[r("div",{class:"text-xs text-dim",children:"Adapted vs Looped"}),r("div",{class:"text-bright",children:[((i.adapted_avg_retention||0)-(i.looped_avg_retention||0)).toFixed(1),"%"]}),r("div",{class:"text-xs text-dim",children:"retention lift"})]}),f&&r("div",{class:"p-3 rounded bg-bg-2 border border-border",children:[r("div",{class:"text-xs text-dim",children:"Best BPM"}),r("div",{class:"text-bright",children:f[0]}),r("div",{class:"text-xs text-dim",children:[f[1].avg_retention,"% retention"]})]}),l&&(l.stems_count??0)>0&&r("div",{class:"p-3 rounded bg-bg-2 border border-border",children:[r("div",{class:"text-xs text-dim",children:"Stems vs Full Mix"}),r("div",{class:"text-bright",children:[((l.stems_avg_retention||0)-(l.no_stems_avg_retention||0)).toFixed(1),"%"]}),r("div",{class:"text-xs text-dim",children:"retention lift"})]})]}),c.length>0&&r(L,{title:"Mood Performance",children:r("div",{class:"space-y-1",children:c.map(([a,p])=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"w-20 text-dim capitalize",children:a}),r("div",{class:"flex-1 h-2 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded",style:{width:`${Math.min(100,p.avg_retention)}%`}})}),r("span",{class:"text-dim w-16 text-right",children:[p.avg_retention,"% · ",p.video_count,"v"]})]},a))})}),u.length>0&&r(L,{title:"BPM Performance",children:r("div",{class:"space-y-1",children:u.map(([a,p])=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"w-20 text-dim",children:a}),r("div",{class:"flex-1 h-2 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-accent/60 rounded",style:{width:`${Math.min(100,p.avg_retention)}%`}})}),r("span",{class:"text-dim w-16 text-right",children:[p.avg_retention,"% · ",p.count,"v"]})]},a))})}),o&&Object.keys(o).length>0&&r(L,{title:"Music Source",children:r("div",{class:"flex gap-3 text-xs",children:Object.entries(o).map(([a,p])=>r("span",{class:"text-dim",children:[a,": ",r("span",{class:"text-text",children:p})]},a))})}),s&&s.length>0&&r(L,{title:"Recommendations",children:r("ul",{class:"space-y-1",children:s.map((a,p)=>r("li",{class:"text-xs text-text",children:["• ",a]},p))})})]})}function $s({data:e}){const t=e.scoring_weights??e.scoring_adjustments,n=e.quality_thresholds,s=Object.entries(e).filter(([i])=>!["scoring_weights","scoring_adjustments","quality_thresholds"].includes(i));return r("div",{class:"space-y-4",children:[t&&Object.keys(t).length>0&&r(L,{title:"SCORING WEIGHTS",children:r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"Factor"}),Object.keys(Object.values(t)[0]??{}).map(i=>r("th",{class:"text-right p-1 capitalize",children:i},i))]})}),r("tbody",{children:Object.entries(t).map(([i,l])=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-text",children:i.replace(/_/g," ")}),Object.values(l).map((o,c)=>r("td",{class:"p-1 text-right text-bright",children:o},c))]},i))})]})})}),n&&Object.keys(n).length>0&&r(L,{title:"QUALITY THRESHOLDS",children:r("div",{class:"space-y-1 text-xs",children:Object.entries(n).map(([i,l])=>r("div",{class:"flex items-center gap-2",children:[r("span",{class:"text-dim flex-1",children:i.replace(/_/g," ")}),r("span",{class:`font-bold ${l>=.8?"text-success":l>=.5?"text-warning":"text-error"}`,children:typeof l=="number"?l.toFixed(2):String(l)})]},i))})}),s.map(([i,l])=>r(L,{title:i.replace(/_/g," ").toUpperCase(),children:typeof l=="object"&&l!==null&&!Array.isArray(l)?r(it,{data:l}):r("pre",{class:"text-[11px] text-text overflow-x-auto max-h-60",children:JSON.stringify(l,null,2)})},i)),Object.keys(e).length===0&&r("div",{class:"text-dim text-sm",children:"No config data available"})]})}function Nt(e){return Array.isArray(e)?e:[]}function As(){var a;const[e,t]=k(null),[n,s]=k(null),[i,l]=k(null),[o,c]=k(null),[u,d]=k({});O(()=>{const h={"X-Trigger-Key":typeof window<"u"?window.__TRIGGER_KEY__:""};fetch("/music",{headers:h}).then(v=>v.json()).then(t).catch(()=>{}),fetch("/trends",{headers:h}).then(v=>v.json()).then(s).catch(()=>{}),fetch("/audit",{headers:h}).then(v=>v.json()).then(l).catch(()=>{}),fetch("/schedule",{headers:h}).then(v=>v.json()).then(c).catch(()=>{})},[]);const f=p=>d(h=>({...h,[p]:!h[p]}));return r("div",{class:"p-4",children:[r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4",children:[r("div",{class:"text-bright text-sm font-bold mb-2",children:"System Health"}),r("div",{class:"flex gap-4 text-sm",children:[r("span",{class:se.value==="healthy"?"text-success":se.value==="degraded"?"text-warning":"text-error",children:se.value.toUpperCase()}),r("span",{class:"text-dim",children:[At.value," errors (24h)"]})]})]}),I.value&&r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-running/30 rounded p-4 mb-4",children:[r("div",{class:"text-running text-sm font-bold mb-2",children:"Live Status"}),r("div",{class:"text-sm text-dim",children:["Active: Stage ",R.value," — ",((a=le.find(p=>p.num===R.value))==null?void 0:a.codename)??"..."]})]}),r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4 mb-4 overflow-x-auto",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"AGENT PERFORMANCE"}),r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"#"}),r("th",{class:"text-left p-1",children:"Agent"}),r("th",{class:"text-left p-1",children:"Codename"})]})}),r("tbody",{children:le.map(p=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-dim",children:p.num}),r("td",{class:"p-1 text-text",children:p.name}),r("td",{class:"p-1 text-bright",children:p.codename})]},p.num))})]})]}),r("div",{class:"space-y-2",children:[r(te,{title:"Music Library",expanded:u.music??!1,onToggle:()=>f("music"),badge:(e==null?void 0:e.total_tracks)!=null?`${e.total_tracks} tracks`:void 0,children:e?r(Os,{data:e}):r(H,{height:"3rem"})}),r(te,{title:"Trend Detection",expanded:u.trends??!1,onToggle:()=>f("trends"),badge:n!=null&&n.trends?`${n.trends.length} trends`:void 0,children:n?r(Ps,{data:n}):r(H,{height:"3rem"})}),r(te,{title:"Image Audit",expanded:u.audit??!1,onToggle:()=>f("audit"),badge:i!=null&&i.entries?`${i.entries.length} entries`:void 0,children:i?r(Ls,{data:i}):r(H,{height:"3rem"})}),r(te,{title:"Schedule",expanded:u.schedule??!1,onToggle:()=>f("schedule"),badge:o!=null&&o.schedule?`${o.schedule.length} jobs`:void 0,children:o?r(Ns,{data:o}):r(H,{height:"3rem"})}),r(te,{title:"Error Log",expanded:u.errors??!1,onToggle:()=>f("errors"),badge:Qe.value?`${Qe.value.length} groups`:void 0,children:r(Ds,{})}),r(te,{title:"Agent Performance",expanded:u["agent-perf"]??!1,onToggle:()=>f("agent-perf"),children:r(Is,{})}),r(te,{title:"Cost Breakdown",expanded:u.cost??!1,onToggle:()=>f("cost"),children:r(Ms,{})})]})]})}function te({title:e,expanded:t,onToggle:n,badge:s,children:i}){return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded",children:[r("button",{onClick:n,class:"w-full flex items-center justify-between p-3 text-sm text-bright hover:text-white",children:[r("span",{class:"flex items-center gap-2",children:[e,s&&r("span",{class:"text-[10px] text-dim bg-bg-2 px-1.5 py-0.5 rounded",children:s})]}),r("span",{class:"text-dim",children:t?"▾":"▸"})]}),t&&r("div",{class:"px-3 pb-3",children:i})]})}function Os({data:e}){const t=e.tracks_by_mood??{},n=Object.entries(t).sort(([,s],[,i])=>i-s);return r("div",{class:"space-y-3",children:[e.api_status&&e.api_status!=="no_key"&&r("div",{class:"flex items-center gap-2",children:[r("span",{class:`w-2 h-2 rounded-full ${e.api_status==="connected"?"bg-success":e.api_status==="expired"?"bg-error":"bg-dim"}`}),r("span",{class:"text-xs text-dim",children:["Epidemic Sound: ",e.api_status]}),e.api_status==="expired"&&r("span",{class:"text-xs text-error",children:"Key expired"}),e.subscription_plan&&r("span",{class:"text-xs text-dim",children:["(",e.subscription_plan,")"]})]}),r("div",{class:"text-sm text-dim",children:["Total: ",r("span",{class:"text-bright",children:e.total_tracks??0})," tracks"]}),n.length>0&&r("div",{class:"space-y-1",children:n.map(([s,i])=>{const l=e.total_tracks?i/e.total_tracks*100:0;return r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"w-20 text-dim capitalize",children:s}),r("div",{class:"flex-1 h-2 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded",style:{width:`${l}%`}})}),r("span",{class:"text-dim w-8 text-right",children:i})]},s)})}),e.recent_selections&&e.recent_selections.length>0&&r("div",{children:[r("div",{class:"text-xs text-dim mb-1",children:"Recent selections:"}),e.recent_selections.slice(0,5).map((s,i)=>r("div",{class:"flex justify-between text-xs py-0.5",children:[r("span",{class:"text-text truncate max-w-[60%]",children:s.track_title}),r("span",{class:"text-dim",children:[s.mood," · ",s.source]})]},i))]}),e.usage_stats&&r("div",{class:"flex gap-3 text-xs text-dim",children:[r("span",{children:["Downloads: ",e.usage_stats.total_downloads]}),r("span",{children:["Adaptations: ",e.usage_stats.total_adaptations]})]})]})}function er(e){if(!e)return"text-dim";const t=(Date.now()-new Date(e).getTime())/864e5;return t<7?"text-success":t<30?"text-warning":"text-dim"}function Rs(e){if(!e)return"(stale)";const t=(Date.now()-new Date(e).getTime())/864e5;return t<7?"(recent)":t<30?"(aging)":"(stale)"}function Ps({data:e}){const t=e.trends??[];return t.length?r("div",{class:"space-y-1",children:t.map((n,s)=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:`w-2 h-2 rounded-full ${er(n.fetched_at).replace("text-","bg-")}`}),r("span",{class:`flex-1 ${er(n.fetched_at)}`,children:n.topic}),r("span",{class:"text-dim text-[10px]",children:Rs(n.fetched_at)}),n.score!=null&&r("span",{class:"text-dim",children:n.score.toFixed(1)})]},s))}):r("div",{class:"text-dim text-xs",children:"No trends detected"})}function Ls({data:e}){const t=e.entries??[];return t.length?r("div",{class:"space-y-1 max-h-48 overflow-y-auto",children:t.map((n,s)=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:n.status==="pass"?"text-success":n.status==="fail"?"text-error":"text-warning",children:n.status==="pass"?"✓":n.status==="fail"?"✗":"⚠"}),r("span",{class:"text-text flex-1 truncate",children:n.file??"unknown"}),n.reason&&r("span",{class:"text-dim truncate max-w-[200px]",children:n.reason})]},s))}):r("div",{class:"text-dim text-xs",children:"No audit entries"})}function Ns({data:e}){const t=e.schedule??[],[,n]=k(0);return O(()=>{const s=setInterval(()=>n(i=>i+1),6e4);return()=>clearInterval(s)},[]),t.length?r("div",{class:"space-y-1",children:t.map((s,i)=>{const l=Fs(s.day,s.time_utc);return r("div",{class:"flex items-center gap-3 text-xs",children:[r("span",{class:"text-bright flex-1",children:s.job}),r("span",{class:"text-dim",children:[s.day," ",s.time_utc," UTC"]}),l&&r("span",{class:"text-running",children:l})]},i)})}):r("div",{class:"text-dim text-xs",children:"No scheduled jobs"})}function Ds(){const[e,t]=k(!1);O(()=>{e||Vn().finally(()=>t(!0))},[e]);const n=Qe.value??[];return n.length?r("div",{class:"space-y-1 max-h-60 overflow-y-auto",children:n.map((s,i)=>r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:s.severity==="critical"?"text-error":"text-warning",children:[s.count,"x"]}),r("span",{class:"text-bright flex-1 truncate",children:s.agent}),r("span",{class:"text-dim truncate max-w-[200px]",children:s.error_type})]},i))}):r("div",{class:"text-dim text-xs",children:"No errors in last 24h"})}function Is(){const[e,t]=k(!1);O(()=>{e||Gn().finally(()=>t(!0))},[e]);const n=Ot.value??[];return n.length?r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1",children:"Agent"}),r("th",{class:"text-right p-1",children:"Calls"}),r("th",{class:"text-right p-1",children:"Avg (s)"}),r("th",{class:"text-right p-1",children:"P95 (s)"}),r("th",{class:"text-right p-1",children:"Success"}),r("th",{class:"text-right p-1",children:"SLA Miss"})]})}),r("tbody",{children:n.map((s,i)=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1 text-bright truncate max-w-[120px]",children:s.agent}),r("td",{class:"p-1 text-dim text-right",children:s.calls}),r("td",{class:"p-1 text-text text-right",children:s.avg_latency}),r("td",{class:"p-1 text-text text-right",children:s.p95_latency}),r("td",{class:`p-1 text-right ${s.success_rate>=95?"text-success":s.success_rate>=80?"text-warning":"text-error"}`,children:[s.success_rate,"%"]}),r("td",{class:`p-1 text-right ${s.sla_breach_rate<=5?"text-dim":"text-warning"}`,children:[s.sla_breach_rate,"%"]})]},i))})]})}):r("div",{class:"text-dim text-xs",children:"No agent data yet"})}function Ms(){const e=Ot.value??[];if(!e.length)return r("div",{class:"text-dim text-xs",children:"No cost data yet"});const t=[...e].sort((i,l)=>(l.avg_input_tokens+l.avg_output_tokens)*l.calls-(i.avg_input_tokens+i.avg_output_tokens)*i.calls),n=t[0],s=n?(n.avg_input_tokens+n.avg_output_tokens)*n.calls:1;return r("div",{class:"space-y-1",children:t.slice(0,10).map((i,l)=>{const o=(i.avg_input_tokens+i.avg_output_tokens)*i.calls,c=s>0?o/s*100:0;return r("div",{class:"flex items-center gap-2 text-xs",children:[r("span",{class:"w-28 text-dim truncate",children:i.agent}),r("div",{class:"flex-1 h-2 bg-bg-2 rounded overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded",style:{width:`${c}%`}})}),r("span",{class:"text-dim w-16 text-right",children:[(o/1e3).toFixed(0),"k tok"]})]},l)})})}function Fs(e,t){try{const n=e.toUpperCase(),s=new Date;if(n==="3H"){const _=s.getUTCHours(),b=(Math.floor(_/3)+1)*3,g=new Date(s);b>=24?(g.setUTCDate(g.getUTCDate()+1),g.setUTCHours(0,0,0,0)):g.setUTCHours(b,0,0,0);const E=g.getTime()-s.getTime(),x=Math.floor(E/36e5),y=Math.floor(E%36e5/6e4);return x>0?`${x}h ${y}m`:`${y}m`}if(n==="DAILY"){const[_,b]=t.split(":").map(Number);if(_==null||b==null)return null;const g=new Date(s);g.setUTCHours(_,b,0,0),g.getTime()<=s.getTime()&&g.setUTCDate(g.getUTCDate()+1);const E=g.getTime()-s.getTime(),x=Math.floor(E/36e5),y=Math.floor(E%36e5/6e4);return x>0?`${x}h ${y}m`:`${y}m`}const l={SUN:"Sunday",MON:"Monday",TUE:"Tuesday",WED:"Wednesday",THU:"Thursday",FRI:"Friday",SAT:"Saturday"}[n]??e,c=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"].indexOf(l);if(c===-1)return null;const[u,d]=t.split(":").map(Number);if(u==null||d==null)return null;const f=s.getUTCDay(),a=(c-f+7)%7,p=new Date(s);p.setUTCDate(s.getUTCDate()+a),p.setUTCHours(u,d,0,0),p.getTime()<=s.getTime()&&p.setUTCDate(p.getUTCDate()+7);const h=p.getTime()-s.getTime(),v=Math.floor(h/36e5),w=Math.floor(h%36e5/6e4);return v>0?`${v}h ${w}m`:`${w}m`}catch{return null}}const Br=[{key:"voice_speed.quote",label:"Quote Reading Speed",description:"How fast historical quotes are narrated. Lower = more dramatic.",group:"long_form",groupLabel:"Long-Form Parameters",min:.6,max:.9,default:.74,step:.01,unit:"x"},{key:"voice_speed.quote_legacy",label:"Legacy Quote Speed",description:"Narration speed for older-style quotes. Slightly faster.",group:"long_form",groupLabel:"Long-Form Parameters",min:.7,max:.95,default:.85,step:.01,unit:"x"},{key:"pause.reveal",label:"Reveal Pause",description:"Dramatic pause before a key revelation. Longer = more suspense.",group:"long_form",groupLabel:"Long-Form Parameters",min:.5,max:5,default:1.8,step:.05,unit:"s"},{key:"pause.breathing",label:"Breathing Pause",description:"Natural pause between sentences. Affects overall pacing.",group:"long_form",groupLabel:"Long-Form Parameters",min:.3,max:3,default:1.2,step:.05,unit:"s"},{key:"pause.act_transition",label:"Act Transition Pause",description:"Pause between major story acts. Time to absorb.",group:"long_form",groupLabel:"Long-Form Parameters",min:.2,max:2,default:.9,step:.05,unit:"s"},{key:"pause.default",label:"Default Pause",description:"Standard inter-sentence pause. The baseline rhythm.",group:"long_form",groupLabel:"Long-Form Parameters",min:.1,max:1.5,default:.4,step:.05,unit:"s"},{key:"short.voice_speed",label:"Short Voice Speed",description:"Main narration speed for Shorts. Faster keeps attention.",group:"short_speed",groupLabel:"Shorts Speed",min:.78,max:1.02,default:.88,step:.01,unit:"x",brandRef:"voice_speed.quote",brandThreshold:.15},{key:"short.hook_speed",label:"Short Hook Speed",description:"Opening hook speed. Slightly faster to grab attention.",group:"short_speed",groupLabel:"Shorts Speed",min:.82,max:1.05,default:.92,step:.01,unit:"x"},{key:"short.voice_stability",label:"Short Voice Stability",description:"How consistent the AI voice sounds. Lower = more expressive.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.2,max:.55,default:.38,step:.01},{key:"short.voice_style",label:"Short Voice Style",description:"Exaggeration level. Higher = more dramatic.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.3,max:.85,default:.6,step:.01},{key:"short.hook_stability",label:"Hook Voice Stability",description:"Stability of hook voice. Lower for more energy.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.15,max:.45,default:.28,step:.01},{key:"short.hook_style",label:"Hook Voice Style",description:"Style exaggeration for hook. High for max impact.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.4,max:.95,default:.75,step:.01},{key:"short.similarity_boost",label:"Voice Consistency",description:"How closely Short voice matches main channel voice.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.65,max:.95,default:.82,step:.01},{key:"short.hook_similarity_boost",label:"Hook Voice Consistency",description:"How closely hook voice matches main voice.",group:"short_voice",groupLabel:"Shorts Voice Character",min:.7,max:.98,default:.85,step:.01},{key:"short.tail_buffer_sec",label:"Short Tail Buffer",description:"Silence before a Short loops. Affects rewatch feel.",group:"short_timing",groupLabel:"Shorts Timing",min:.3,max:2.5,default:1.5,step:.05,unit:"s"}],Dt=Object.fromEntries(Br.map(e=>[e.key,e])),js=[{id:"long_form",label:"Long-Form Parameters"},{id:"short_speed",label:"Shorts Speed"},{id:"short_voice",label:"Shorts Voice Character"},{id:"short_timing",label:"Shorts Timing"}],Us=[{id:1,name:"LF Param → Metrics",phase:"Phase B",requirement:"Requires param variation from manual overrides"},{id:2,name:"Short Param → Metrics",phase:"Phase B",requirement:"Requires param variation from manual overrides"},{id:3,name:"Topic Health",phase:"Phase A",requirement:"2+ topics with 2+ videos each"},{id:4,name:"Audience Transformation",phase:"Phase B",requirement:"3+ paired topics with before/after data"},{id:5,name:"Short → Parent Lift",phase:"Phase B/C",requirement:"Layers 2+4 at moderate confidence"},{id:6,name:"Era Stratification",phase:"Phase C",requirement:"5+ videos per era with varied params"},{id:7,name:"Cross-Format Signal",phase:"Phase C",requirement:"Layers 1+2 at moderate confidence"}];function Ue(e,t){return t.unit==="x"?`${e.toFixed(2)}x`:t.unit==="s"?`${e.toFixed(2)}s`:e.toFixed(2)}const tr={early:"bg-dim/20 text-dim",emerging:"bg-warning/20 text-warning",established:"bg-running/20 text-running",mature:"bg-success/20 text-success"},rr={active:"bg-success/20 text-success border-success/30",insufficient_data:"bg-warning/20 text-warning border-warning/30",inactive:"bg-bg-2 text-dim border-border"};function qs({correlation:e}){const t=e.maturity??"early",n=e.layers??{};return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"flex items-center gap-3 mb-3",children:[r("span",{class:"text-dim text-[10px] tracking-wider",children:"DATA MATURITY"}),r("span",{class:`text-[10px] font-bold px-2 py-0.5 rounded ${tr[t]??tr.early}`,children:t.toUpperCase()}),e.video_count!=null&&r("span",{class:"text-dim text-[10px]",children:[e.video_count," videos, ",e.short_count??0," shorts"]})]}),e.maturity_description&&r("div",{class:"text-xs text-text mb-3",children:e.maturity_description}),r("div",{class:"text-dim text-[10px] tracking-wider mb-2",children:"CORRELATION LAYERS"}),r("div",{class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2",children:Us.map(s=>{const i=n[String(s.id)],l=(i==null?void 0:i.status)??"inactive",o=rr[l]??rr.inactive;return r("div",{class:`border rounded p-2.5 ${o}`,children:[r("div",{class:"flex items-center gap-1.5 mb-1",children:[r("span",{class:"text-[9px] text-dim",children:["L",s.id]}),r("span",{class:"text-xs text-bright font-bold truncate",children:s.name})]}),r("div",{class:"text-[9px] text-dim",children:s.phase}),r("div",{class:"text-[9px] text-dim mt-1 leading-tight",children:(i==null?void 0:i.reason)??s.requirement})]},s.id)})}),r("div",{class:"text-dim text-[9px] mt-3 italic",children:"With weekly video cadence, expect 3-6 videos before directional signals emerge."})]})}function Hs({layer:e}){var s;const t=(e==null?void 0:e.status)??"inactive",n=((s=e==null?void 0:e.results)==null?void 0:s.topic_ranking)??[];return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"flex items-center gap-2 mb-3",children:[r("span",{class:"text-dim text-[10px] tracking-wider",children:"TOPIC HEALTH (LAYER 3)"}),r("span",{class:`text-[9px] px-1.5 py-0.5 rounded ${t==="active"?"bg-success/20 text-success":t==="insufficient_data"?"bg-warning/20 text-warning":"bg-bg-2 text-dim"}`,children:t.toUpperCase().replace("_"," ")})]}),n.length===0?r("div",{class:"text-dim text-xs py-4 text-center",children:"Need 2+ videos per topic for health analysis"}):r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1.5",children:"Topic"}),r("th",{class:"text-right p-1.5",children:"Videos"}),r("th",{class:"text-right p-1.5",children:"Shorts"}),r("th",{class:"text-right p-1.5",children:"Avg Views"}),r("th",{class:"text-right p-1.5",children:"Avg Retention"})]})}),r("tbody",{children:n.map((i,l)=>r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1.5 text-bright font-medium",children:i.topic}),r("td",{class:"p-1.5 text-right text-text",children:i.long_count}),r("td",{class:"p-1.5 text-right text-text",children:i.short_count}),r("td",{class:"p-1.5 text-right text-text",children:i.avg_long_views!=null?i.avg_long_views.toLocaleString():"-"}),r("td",{class:"p-1.5 text-right",children:i.avg_long_retention!=null?r("span",{class:i.avg_long_retention>=50?"text-success":i.avg_long_retention>=35?"text-warning":"text-error",children:[i.avg_long_retention.toFixed(1),"%"]}):"-"})]},l))})]})})]})}function Vs({param:e}){var E;const t=fe(null),n=ve(()=>ae(()=>ce.value[e.key]??null),[e.key]),s=ve(()=>ae(()=>!!B.value[e.key]),[e.key]),i=En(e.key,e.default),l=n.value??i,o=n.value!==null&&Math.abs(n.value-i)>e.step*.5,c=Cn(e.key);let u=null;if(e.brandRef&&e.brandThreshold){const x=(E=Dt[e.brandRef])==null?void 0:E.default;x!=null&&(u=Math.abs(l-x),u<=e.brandThreshold&&(u=null))}const d=e.max-e.min,a=Math.abs(l-e.default)/d>.5,p=x=>(x-e.min)/d*100,h=p(l),v=p(e.default),w=Pe(x=>{const y=Math.round((x-e.min)/e.step)*e.step+e.min;return Math.max(e.min,Math.min(e.max,parseFloat(y.toFixed(6))))},[e.min,e.max,e.step]),_=Pe(x=>{const y=t.current;if(!y)return;x.target.setPointerCapture(x.pointerId);const Y=y.getBoundingClientRect(),D=F=>{const Wr=Math.max(0,Math.min(1,(F-Y.left)/Y.width)),Yr=e.min+Wr*d;ot(e.key,w(Yr))};D(x.clientX);const U=F=>D(F.clientX),M=F=>{F.target.releasePointerCapture(F.pointerId),y.removeEventListener("pointermove",U),y.removeEventListener("pointerup",M)};y.addEventListener("pointermove",U),y.addEventListener("pointerup",M)},[e.key,e.min,d,w]),b=Pe(x=>{let y=l;switch(x.key){case"ArrowRight":case"ArrowUp":y+=e.step;break;case"ArrowLeft":case"ArrowDown":y-=e.step;break;case"PageUp":y+=e.step*10;break;case"PageDown":y-=e.step*10;break;case"Home":y=e.min;break;case"End":y=e.max;break;default:return}x.preventDefault(),ot(e.key,w(y))},[l,e.key,e.min,e.max,e.step,w]),g=Pe(x=>{const y=parseFloat(x.target.value);Number.isFinite(y)&&ot(e.key,w(y))},[e.key,w]);return r("div",{class:"space-y-1.5",children:[r("div",{class:"flex items-center justify-between gap-2",children:[r("div",{class:"flex items-center gap-2 min-w-0",children:[r("span",{class:"text-xs text-bright font-medium truncate",children:e.label}),c&&r("span",{class:"text-[8px] text-running bg-running/10 border border-running/20 rounded px-1",children:"OVERRIDE"})]}),r("div",{class:"flex items-center gap-1.5 shrink-0",children:[r("input",{type:"number",class:"w-16 bg-bg-2 border border-border rounded px-1.5 py-0.5 text-xs text-bright text-right tabular-nums focus:border-running/50 focus:outline-none",min:e.min,max:e.max,step:e.step,value:l.toFixed(2),onInput:g}),r("span",{class:"text-[9px] text-dim w-3",children:e.unit??""})]})]}),r("div",{class:"text-[10px] text-dim leading-tight",children:e.description}),r("div",{ref:t,class:"relative h-8 flex items-center cursor-pointer touch-none select-none",role:"slider",tabIndex:0,"aria-valuemin":e.min,"aria-valuemax":e.max,"aria-valuenow":l,"aria-label":e.label,onPointerDown:_,onKeyDown:b,children:[r("div",{class:"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 bg-bg-2 rounded-full"}),r("div",{class:"absolute top-1/2 -translate-y-1/2 h-1.5 bg-running/60 rounded-full left-0",style:{width:`${h}%`}}),r("div",{class:"absolute top-1/2 -translate-y-1/2 w-1 h-3.5 bg-dim/50 rounded-sm",style:{left:`${v}%`,transform:"translate(-50%, -50%)"},title:`Default: ${Ue(e.default,e)}`}),r("div",{class:"absolute top-1/2 w-4 h-4 rounded-full bg-running border-2 border-bg-1 shadow-[0_0_6px_rgba(59,130,246,0.5)] hover:shadow-[0_0_10px_rgba(59,130,246,0.7)] transition-shadow duration-200",style:{left:`${h}%`,transform:"translate(-50%, -50%)"}}),r("span",{class:"absolute -bottom-0.5 left-0 text-[8px] text-dim",children:Ue(e.min,e)}),r("span",{class:"absolute -bottom-0.5 right-0 text-[8px] text-dim",children:Ue(e.max,e)})]}),r("div",{class:"flex items-center gap-2 flex-wrap min-h-[20px]",children:[o&&r("button",{onClick:()=>Sn(e.key,l),disabled:s.value,class:"px-2 py-0.5 text-[10px] font-bold text-success border border-success/30 rounded hover:bg-success/10 disabled:opacity-50",children:s.value?"...":"SAVE"}),c&&r("button",{onClick:()=>Tn(e.key),disabled:s.value,class:"px-2 py-0.5 text-[10px] font-bold text-warning border border-warning/30 rounded hover:bg-warning/10 disabled:opacity-50",children:"REVERT"}),u!=null&&r("span",{class:"text-[9px] text-warning bg-warning/10 border border-warning/30 rounded px-1.5 py-0.5",children:["BRAND DRIFT ",u.toFixed(2)]}),a&&r("span",{class:"text-[9px] text-warning",children:"Significant change from default"})]})]})}function nr({label:e,params:t,defaultExpanded:n=!1}){const[s,i]=k(n);return t.length===0?null:r("div",{class:"space-y-3",children:[r("button",{onClick:()=>i(!s),class:"flex items-center gap-2 w-full text-left",children:[r("span",{class:"text-dim text-[10px] tracking-wider uppercase font-bold",children:e}),r("span",{class:"text-[10px] text-dim bg-bg-2 px-1.5 py-0.5 rounded",children:t.length}),r("div",{class:"flex-1 border-b border-border/50"}),r("span",{class:"text-dim text-xs",children:s?"▾":"▸"})]}),s&&r("div",{class:"space-y-5 pl-1",children:t.map(l=>r(Vs,{param:l},l.key))})]})}function Gs(){const e=js.map(t=>({...t,params:Br.filter(n=>n.group===t.id)}));return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"mb-1",children:r("span",{class:"text-dim text-[10px] tracking-wider",children:"PARAMETER TUNING"})}),r("div",{class:"text-[10px] text-dim mb-4",children:"Adjust audio and pacing parameters. Changes are saved to your override bank."}),r("div",{class:"grid grid-cols-1 lg:grid-cols-2 gap-6",children:[r("div",{class:"space-y-4",children:e.filter(t=>t.id==="long_form").map(t=>r(nr,{label:t.label,params:t.params,defaultExpanded:!0},t.id))}),r("div",{class:"space-y-4",children:e.filter(t=>t.id!=="long_form").map(t=>r(nr,{label:t.label,params:t.params},t.id))})]})]})}const sr={strong:"bg-success/20 text-success border-success/30",moderate:"bg-running/20 text-running border-running/30",directional:"bg-warning/20 text-warning border-warning/30",weak:"bg-dim/20 text-dim border-border"};function Bs({recommendations:e}){return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded p-4",children:[r("div",{class:"text-dim text-[10px] tracking-wider mb-3",children:"RECOMMENDATIONS"}),e.length===0?r("div",{class:"text-dim text-xs py-4 text-center",children:"Awaiting parameter variation -- adjust some parameters manually to begin data collection"}):r("div",{class:"space-y-3",children:e.map((t,n)=>{const s=Dt[t.parameter_key],i=sr[t.confidence]??sr.weak;return r("div",{class:`border rounded p-3 ${i}`,children:[r("div",{class:"flex items-center gap-2 mb-1",children:[r("span",{class:"text-xs text-bright font-bold",children:(s==null?void 0:s.label)??t.parameter_key}),r("span",{class:"text-[9px] font-bold px-1.5 py-0.5 rounded bg-bg-2/50",children:t.confidence.toUpperCase()})]}),r("div",{class:"text-sm text-bright font-bold mb-1",children:s?Ue(t.suggested_value,s):t.suggested_value.toFixed(3)}),r("div",{class:"text-[10px] text-text",children:t.interpretation}),t.quality_score!=null&&r("div",{class:"mt-2 h-1.5 bg-bg-2 rounded-full overflow-hidden",children:r("div",{class:"h-full bg-running/60 rounded-full",style:{width:`${t.quality_score*100}%`}})})]},n)})})]})}function Ks(e){const t=Date.now()-new Date(e).getTime(),n=Math.floor(t/6e4);if(n<1)return"just now";if(n<60)return`${n}m ago`;const s=Math.floor(n/60);return s<24?`${s}h ago`:`${Math.floor(s/24)}d ago`}function Ws({history:e}){const[t,n]=k(!1);return r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-border rounded",children:[r("button",{onClick:()=>n(!t),class:"w-full flex items-center justify-between p-3 text-sm text-bright hover:text-white",children:[r("span",{class:"flex items-center gap-2",children:["Override History",e.length>0&&r("span",{class:"text-[10px] text-dim bg-bg-2 px-1.5 py-0.5 rounded",children:e.length})]}),r("span",{class:"text-dim",children:t?"▾":"▸"})]}),t&&r("div",{class:"px-3 pb-3",children:e.length===0?r("div",{class:"text-dim text-xs",children:"No overrides recorded yet"}):r("div",{class:"overflow-x-auto",children:r("table",{class:"w-full text-xs",children:[r("thead",{children:r("tr",{class:"text-dim border-b border-border",children:[r("th",{class:"text-left p-1.5",children:"Parameter"}),r("th",{class:"text-left p-1.5",children:"Action"}),r("th",{class:"text-right p-1.5",children:"Value"}),r("th",{class:"text-right p-1.5",children:"Previous"}),r("th",{class:"text-right p-1.5",children:"When"})]})}),r("tbody",{children:e.slice(0,20).map((s,i)=>{const l=Dt[s.key];return r("tr",{class:"border-b border-border/30",children:[r("td",{class:"p-1.5 text-text",children:(l==null?void 0:l.label)??s.key}),r("td",{class:"p-1.5",children:r("span",{class:s.action==="approve"?"text-success":"text-warning",children:s.action.toUpperCase()})}),r("td",{class:"p-1.5 text-right text-bright tabular-nums",children:s.value!=null?s.value.toFixed(2):"-"}),r("td",{class:"p-1.5 text-right text-dim tabular-nums",children:s.previous_value!=null?s.previous_value.toFixed(2):"-"}),r("td",{class:"p-1.5 text-right text-dim",title:s.timestamp,children:Ks(s.timestamp)})]},i)})})]})})})]})}function Ys(){if(O(()=>{Z.value||Rt()},[]),_t.value&&!Z.value)return r("div",{class:"p-4 space-y-4",children:[r(H,{height:"10rem"}),r(H,{height:"6rem"}),r(H,{height:"20rem"})]});if(Xe.value&&!Z.value)return r("div",{class:"p-4",children:r("div",{class:"backdrop-blur-sm bg-bg-1/80 border border-error/30 rounded p-4",children:r("div",{class:"text-error text-sm",children:Xe.value})})});const e=Z.value,t=(e==null?void 0:e.correlation)??{},n=t.layers??{},s=t.recommendations??[],i=(e==null?void 0:e.history)??[];return r("div",{class:"p-4 space-y-4",children:[r("div",{class:"grid grid-cols-1 md:grid-cols-2 gap-4",children:[r(qs,{correlation:t}),r(Hs,{layer:n[3]})]}),r(Gs,{}),r(Bs,{recommendations:s}),r(Ws,{history:i})]})}const De=m(0),ue=m(null),dt=m(!0),tt=m(!1),V=m({}),q=m({}),$e=m(""),oe=m({}),ut=["Welcome","API Keys","Profile","Providers","Review"],zs=["llm","tts","images","footage","music","database","notifications"],Js={llm:"AI Text Generation",tts:"Text-to-Speech",images:"Image Generation",footage:"Stock Footage",music:"Background Music",database:"Database",notifications:"Notifications"},Kr={anthropic:"Anthropic (Claude)",openai:"OpenAI (GPT)",elevenlabs:"ElevenLabs",fal:"fal.ai (Recraft / Flux)",pexels:"Pexels (Free)",local:"Save to Disk",epidemic_sound:"Epidemic Sound",local_music:"Local Library (Kevin MacLeod)"};function Qs(e){const t=new Map;for(const n of zs){const s=e.filter(i=>i.category===n);s.length>0&&t.set(n,s)}return t}async function Xs(e){const t=V.value[e];if(t!=null&&t.trim()){q.value={...q.value,[e]:"checking"};try{const n=await Bn(e,t);q.value={...q.value,[e]:n.valid?"valid":"invalid"},!n.valid&&n.error&&C(n.error,"error")}catch{q.value={...q.value,[e]:"invalid"}}}}async function Zs(){tt.value=!0;try{const e={};for(const[n,s]of Object.entries(V.value))s!=null&&s.trim()&&(e[n]=s.trim());const t=await Kn({keys:Object.keys(e).length>0?e:void 0,profile:$e.value||void 0,providers:Object.keys(oe.value).length>0?oe.value:void 0});if(t.success){C("Configuration saved!");const n=await Fr();ue.value=n}else C(t.errors.join(", "),"error")}catch{C("Failed to save configuration","error")}finally{tt.value=!1}}function ei(){var e;return r("div",{class:"space-y-6",children:[r("div",{class:"text-center space-y-4",children:[r("h2",{class:"text-2xl font-bold text-bright",children:"Welcome to Obsidian Engine"}),r("p",{class:"text-text text-base max-w-xl mx-auto leading-relaxed",children:"This wizard will help you configure everything needed to generate AI-powered videos. It takes about 5 minutes."})]}),r("div",{class:"grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto mt-8",children:[r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 1"}),r("div",{class:"text-bright text-sm",children:"API Keys"}),r("div",{class:"text-dim text-xs mt-1",children:"Connect your AI services"})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 2"}),r("div",{class:"text-bright text-sm",children:"Content Profile"}),r("div",{class:"text-dim text-xs mt-1",children:"Choose your video style"})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 3"}),r("div",{class:"text-bright text-sm",children:"Providers"}),r("div",{class:"text-dim text-xs mt-1",children:"Select your service backends"})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-sm font-bold mb-1",children:"Step 4"}),r("div",{class:"text-bright text-sm",children:"Review & Save"}),r("div",{class:"text-dim text-xs mt-1",children:"Verify and launch"})]})]}),((e=ue.value)==null?void 0:e.setup_complete)&&r("div",{class:"bg-success/10 border border-success/30 rounded-lg p-4 max-w-xl mx-auto mt-6 text-center",children:r("span",{class:"text-success text-sm font-bold",children:"Setup is already complete! You can review or update your configuration."})})]})}function ti(){const e=ue.value;if(!e)return null;const t=Qs(e.keys);return r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"API Keys"}),r("p",{class:"text-dim text-sm mt-1",children:"Enter your API keys below. Required keys are marked with a star."})]}),Array.from(t.entries()).map(([n,s])=>r("div",{class:"space-y-3",children:[r("h3",{class:"text-sm font-bold text-accent tracking-wider uppercase",children:[Js[n]||n,s.every(i=>!i.required)&&r("span",{class:"text-dim font-normal ml-2",children:"(optional)"})]}),s.map(i=>{var u;const l=q.value[i.key]||"idle",o=i.configured&&!V.value[i.key],c=l==="valid"||o?"border-success/50":l==="invalid"?"border-error/50":l==="checking"?"border-warning/50":"border-border";return r("div",{class:"space-y-1",children:[r("label",{class:"flex items-center gap-2 text-sm text-text",children:[r("span",{children:i.label}),i.required&&r("span",{class:"text-warning text-xs",children:"*"}),o&&r("span",{class:"text-success text-xs font-bold",children:"CONFIGURED"}),r("a",{href:i.help,target:"_blank",rel:"noopener",class:"text-accent text-xs hover:underline ml-auto",children:"Get key"})]}),r("div",{class:"flex gap-2",children:[r("input",{type:"password",placeholder:o?"(already set — enter new value to replace)":`Enter ${i.label} key`,value:V.value[i.key]||"",onInput:d=>{V.value={...V.value,[i.key]:d.target.value},q.value[i.key]&&(q.value={...q.value,[i.key]:"idle"})},class:`flex-1 bg-bg-1 border ${c} rounded px-3 py-2 text-sm text-text + focus:outline-none focus:border-accent transition-colors font-mono`}),r("button",{onClick:()=>Xs(i.key),disabled:!((u=V.value[i.key])!=null&&u.trim())||l==="checking",class:`px-3 py-2 text-xs bg-bg-2 border border-border rounded + hover:border-accent disabled:opacity-30 disabled:cursor-not-allowed + transition-colors text-text`,children:l==="checking"?"Checking...":l==="valid"?"Valid":l==="invalid"?"Retry":"Test"})]})]},i.key)})]},n))]})}function ri(){const e=ue.value;return e?r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"Content Profile"}),r("p",{class:"text-dim text-sm mt-1",children:"Profiles control the tone, structure, and visual style of your videos."})]}),r("div",{class:"grid gap-3",children:e.available_profiles.map(t=>{const n=($e.value||e.profile)===t.name;return r("button",{onClick:()=>{$e.value=t.name},class:`text-left p-4 rounded-lg border transition-all ${n?"border-accent bg-accent/10 ring-1 ring-accent/30":"border-border bg-bg-2 hover:border-dim"}`,children:r("div",{class:"flex items-center gap-3",children:[r("div",{class:`w-3 h-3 rounded-full border-2 ${n?"border-accent bg-accent":"border-dim"}`}),r("div",{children:[r("div",{class:"text-bright font-bold text-sm capitalize",children:t.name.replace(/_/g," ")}),t.description&&r("div",{class:"text-dim text-xs mt-0.5",children:t.description})]}),n&&e.profile===t.name&&r("span",{class:"ml-auto text-accent text-xs",children:"current"})]})},t.name)})}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4 mt-4",children:r("div",{class:"text-dim text-xs",children:["You can create custom profiles by adding a YAML file to the ",r("code",{class:"text-accent",children:"profiles/"})," directory. See ",r("code",{class:"text-accent",children:"profiles/_template.yaml"})," for the format."]})})]}):null}function ni(){const e=ue.value;if(!e)return null;const t=["llm","tts","images","footage","upload"],n={llm:"AI model for script writing, research, and analysis",tts:"Voice synthesis for narration",images:"AI-generated images for visual scenes",footage:"Stock video clips for b-roll",upload:"Where to publish the final video"};return r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"Service Providers"}),r("p",{class:"text-dim text-sm mt-1",children:"Choose which service to use for each capability. Defaults work great for most users."})]}),r("div",{class:"space-y-4",children:t.map(s=>{const i=e.available_providers[s]||[],l=oe.value[s]||e.providers[s]||"";return r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"flex items-center justify-between mb-2",children:r("div",{children:[r("div",{class:"text-bright text-sm font-bold capitalize",children:s==="llm"?"LLM":s}),r("div",{class:"text-dim text-xs",children:n[s]})]})}),r("div",{class:"flex gap-2 flex-wrap",children:i.map(o=>r("button",{onClick:()=>{oe.value={...oe.value,[s]:o}},class:`px-3 py-1.5 text-xs rounded border transition-all ${l===o?"border-accent bg-accent/10 text-accent":"border-border text-dim hover:text-text hover:border-dim"}`,children:Kr[o]||o},o))})]},s)})})]})}function si(){const e=ue.value;if(!e)return null;const t=e.keys.filter(s=>{var i;return s.required&&!s.configured&&!((i=V.value[s.key])!=null&&i.trim())}),n=$e.value||e.profile;return r("div",{class:"space-y-6 max-w-2xl mx-auto",children:[r("div",{class:"text-center mb-4",children:[r("h2",{class:"text-xl font-bold text-bright",children:"Review Configuration"}),r("p",{class:"text-dim text-sm mt-1",children:"Verify your settings and save."})]}),t.length>0&&r("div",{class:"bg-error/10 border border-error/30 rounded-lg p-4",children:[r("div",{class:"text-error text-sm font-bold mb-1",children:"Missing Required Keys"}),r("ul",{class:"text-error/80 text-xs space-y-1",children:t.map(s=>r("li",{children:[s.label," —"," ",r("a",{href:s.help,target:"_blank",rel:"noopener",class:"underline",children:"Get key"})]},s.key))})]}),r("div",{class:"space-y-3",children:[r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-xs font-bold mb-2 tracking-wider",children:"API KEYS"}),r("div",{class:"space-y-1",children:e.keys.map(s=>{var l;const i=!!((l=V.value[s.key])!=null&&l.trim());return r("div",{class:"flex items-center justify-between text-xs",children:[r("span",{class:"text-text",children:s.label}),r("span",{class:i?"text-warning":s.configured?"text-success":s.required?"text-error":"text-dim",children:i?"NEW":s.configured?"SET":s.required?"MISSING":"NOT SET"})]},s.key)})})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-xs font-bold mb-2 tracking-wider",children:"PROFILE"}),r("div",{class:"text-bright text-sm capitalize",children:n.replace(/_/g," ")})]}),r("div",{class:"bg-bg-2 border border-border rounded-lg p-4",children:[r("div",{class:"text-accent text-xs font-bold mb-2 tracking-wider",children:"PROVIDERS"}),r("div",{class:"space-y-1",children:["llm","tts","images","footage","upload"].map(s=>{const i=oe.value[s]||e.providers[s]||"(default)";return r("div",{class:"flex items-center justify-between text-xs",children:[r("span",{class:"text-text capitalize",children:s==="llm"?"LLM":s}),r("span",{class:"text-bright",children:Kr[i]||i})]},s)})})]})]}),r("button",{onClick:Zs,disabled:tt.value,class:`w-full py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-bold + text-sm tracking-wider rounded-lg hover:from-purple-500 hover:to-indigo-500 + disabled:opacity-50 transition-all`,children:tt.value?"SAVING...":"SAVE CONFIGURATION"})]})}function ii(){if(O(()=>{dt.value=!0,Fr().then(t=>{ue.value=t,$e.value=t.profile,oe.value={...t.providers}}).catch(()=>C("Failed to load setup status","error")).finally(()=>{dt.value=!1})},[]),dt.value)return r("div",{class:"flex-1 flex items-center justify-center",children:r("div",{class:"text-dim text-sm animate-pulse",children:"Loading setup..."})});const e=De.value;return r("div",{class:"flex-1 flex flex-col p-4 sm:p-6 max-w-4xl mx-auto w-full",children:[r("div",{class:"flex items-center gap-1 mb-8 overflow-x-auto pb-2",children:ut.map((t,n)=>r("div",{class:"flex items-center",children:[r("button",{onClick:()=>{De.value=n},class:`flex items-center gap-2 px-3 py-1.5 rounded text-xs whitespace-nowrap transition-all ${n===e?"bg-accent/10 text-accent border border-accent/30":n{De.value=Math.max(0,e-1)},disabled:e===0,class:"px-4 py-2 text-sm text-dim hover:text-text disabled:opacity-30 disabled:cursor-not-allowed",children:"Back"}),e{De.value=e+1},class:`px-6 py-2 text-sm bg-bg-2 border border-border rounded + hover:border-accent text-bright transition-colors`,children:"Next"}):null]})]})}const li=()=>typeof location<"u"&&location.hash.replace("#","")||null;function oi(){O(()=>{Fe(),Mn()},[]),O(()=>{const t=()=>{const n=li()||"home";W.value!==n&&(W.value=n)};return window.addEventListener("hashchange",t),()=>window.removeEventListener("hashchange",t)},[]),O(()=>{const t=n=>{var i,l;const s=(i=n.target)==null?void 0:i.tagName;if(!(s==="INPUT"||s==="TEXTAREA"||(l=n.target)!=null&&l.isContentEditable)&&!(n.metaKey||n.ctrlKey||n.altKey))switch(n.key){case"1":J("home");break;case"2":J("queue");break;case"3":J("intel");break;case"4":J("health");break;case"5":J("tuning");break;case"6":J("setup");break;case"t":case"T":ie.value=!0;break;case"l":case"L":Je.value=!Je.value;break;case"s":case"S":{const o=qr();C(o?"Sound on":"Sound off");break}case"?":re.value=!re.value;break;case"Escape":re.value?re.value=!1:ie.value?ie.value=!1:X.value!=null&&(X.value=null);break}};return document.addEventListener("keydown",t),()=>document.removeEventListener("keydown",t)},[]);const e=W.value;return r("div",{class:"min-h-screen flex flex-col",children:[r(Jn,{}),r("main",{id:"main",role:"tabpanel",class:"flex-1 flex flex-col",children:r(Gr,{children:[e==="home"&&r(ds,{}),e==="queue"&&r(gs,{}),e==="intel"&&r(_s,{}),e==="health"&&r(As,{}),e==="tuning"&&r(Ys,{}),e==="setup"&&r(ii,{})]})}),r(Qn,{}),r(Xn,{}),r(es,{}),r(zn,{}),r(yn,{}),r(ts,{}),r(ns,{})]})}sn(r(oi,{}),document.getElementById("app")); diff --git a/dashboard/dist/assets/index-Dj5IN1-s.css b/dashboard/dist/assets/index-Dj5IN1-s.css new file mode 100644 index 0000000..c82d80c --- /dev/null +++ b/dashboard/dist/assets/index-Dj5IN1-s.css @@ -0,0 +1 @@ +/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"Courier New", Courier, "Liberation Mono", monospace;--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-blue-500:oklch(62.3% .214 259.815);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-bold:700;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg:#020408;--color-bg-1:#0a1220;--color-bg-2:#0e1a30;--color-border:#1e3a5a;--color-text:#b0cce0;--color-dim:#4a7090;--color-bright:#e0f0ff;--color-success:#22c55e;--color-error:#ef4444;--color-warning:#f59e0b;--color-running:#3b82f6}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-1\/2{top:50%}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:calc(var(--spacing) * 0)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.row-span-2{grid-row:span 2/span 2}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-\[3px\]{height:3px}.h-full{height:100%}.h-px{height:1px}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-\[calc\(100vh-200px\)\]{max-height:calc(100vh - 200px)}.min-h-\[20px\]{min-height:20px}.min-h-screen{min-height:100vh}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-80{width:calc(var(--spacing) * 80)}.w-\[285px\]{width:285px}.w-\[340px\]{width:340px}.w-\[400px\]{width:400px}.w-\[420px\]{width:420px}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-\[60\%\]{max-width:60%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[120px\]{max-width:120px}.max-w-\[200px\]{max-width:200px}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[32px\]{min-width:32px}.min-w-\[70px\]{min-width:70px}.min-w-\[100px\]{min-width:100px}.min-w-\[140px\]{min-width:140px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-bg-1{border-color:var(--color-bg-1)}.border-border{border-color:var(--color-border)}.border-border\/30{border-color:#1e3a5a4d}@supports (color:color-mix(in lab,red,red)){.border-border\/30{border-color:color-mix(in oklab,var(--color-border) 30%,transparent)}}.border-border\/50{border-color:#1e3a5a80}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--color-border) 50%,transparent)}}.border-cyan-500\/30{border-color:#00b7d74d}@supports (color:color-mix(in lab,red,red)){.border-cyan-500\/30{border-color:color-mix(in oklab,var(--color-cyan-500) 30%,transparent)}}.border-dim{border-color:var(--color-dim)}.border-error\/30{border-color:#ef44444d}@supports (color:color-mix(in lab,red,red)){.border-error\/30{border-color:color-mix(in oklab,var(--color-error) 30%,transparent)}}.border-error\/50{border-color:#ef444480}@supports (color:color-mix(in lab,red,red)){.border-error\/50{border-color:color-mix(in oklab,var(--color-error) 50%,transparent)}}.border-green-500\/30{border-color:#00c7584d}@supports (color:color-mix(in lab,red,red)){.border-green-500\/30{border-color:color-mix(in oklab,var(--color-green-500) 30%,transparent)}}.border-purple-500\/30{border-color:#ac4bff4d}@supports (color:color-mix(in lab,red,red)){.border-purple-500\/30{border-color:color-mix(in oklab,var(--color-purple-500) 30%,transparent)}}.border-running\/20{border-color:#3b82f633}@supports (color:color-mix(in lab,red,red)){.border-running\/20{border-color:color-mix(in oklab,var(--color-running) 20%,transparent)}}.border-running\/30{border-color:#3b82f64d}@supports (color:color-mix(in lab,red,red)){.border-running\/30{border-color:color-mix(in oklab,var(--color-running) 30%,transparent)}}.border-running\/40{border-color:#3b82f666}@supports (color:color-mix(in lab,red,red)){.border-running\/40{border-color:color-mix(in oklab,var(--color-running) 40%,transparent)}}.border-running\/60{border-color:#3b82f699}@supports (color:color-mix(in lab,red,red)){.border-running\/60{border-color:color-mix(in oklab,var(--color-running) 60%,transparent)}}.border-success{border-color:var(--color-success)}.border-success\/30{border-color:#22c55e4d}@supports (color:color-mix(in lab,red,red)){.border-success\/30{border-color:color-mix(in oklab,var(--color-success) 30%,transparent)}}.border-success\/40{border-color:#22c55e66}@supports (color:color-mix(in lab,red,red)){.border-success\/40{border-color:color-mix(in oklab,var(--color-success) 40%,transparent)}}.border-success\/50{border-color:#22c55e80}@supports (color:color-mix(in lab,red,red)){.border-success\/50{border-color:color-mix(in oklab,var(--color-success) 50%,transparent)}}.border-warning\/30{border-color:#f59e0b4d}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--color-warning) 30%,transparent)}}.border-warning\/50{border-color:#f59e0b80}@supports (color:color-mix(in lab,red,red)){.border-warning\/50{border-color:color-mix(in oklab,var(--color-warning) 50%,transparent)}}.bg-bg-1{background-color:var(--color-bg-1)}.bg-bg-1\/80{background-color:#0a1220cc}@supports (color:color-mix(in lab,red,red)){.bg-bg-1\/80{background-color:color-mix(in oklab,var(--color-bg-1) 80%,transparent)}}.bg-bg-1\/95{background-color:#0a1220f2}@supports (color:color-mix(in lab,red,red)){.bg-bg-1\/95{background-color:color-mix(in oklab,var(--color-bg-1) 95%,transparent)}}.bg-bg-2{background-color:var(--color-bg-2)}.bg-bg-2\/50{background-color:#0e1a3080}@supports (color:color-mix(in lab,red,red)){.bg-bg-2\/50{background-color:color-mix(in oklab,var(--color-bg-2) 50%,transparent)}}.bg-bg-2\/95{background-color:#0e1a30f2}@supports (color:color-mix(in lab,red,red)){.bg-bg-2\/95{background-color:color-mix(in oklab,var(--color-bg-2) 95%,transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab,red,red)){.bg-black\/70{background-color:color-mix(in oklab,var(--color-black) 70%,transparent)}}.bg-border{background-color:var(--color-border)}.bg-dim{background-color:var(--color-dim)}.bg-dim\/20{background-color:#4a709033}@supports (color:color-mix(in lab,red,red)){.bg-dim\/20{background-color:color-mix(in oklab,var(--color-dim) 20%,transparent)}}.bg-dim\/50{background-color:#4a709080}@supports (color:color-mix(in lab,red,red)){.bg-dim\/50{background-color:color-mix(in oklab,var(--color-dim) 50%,transparent)}}.bg-error{background-color:var(--color-error)}.bg-error\/10{background-color:#ef44441a}@supports (color:color-mix(in lab,red,red)){.bg-error\/10{background-color:color-mix(in oklab,var(--color-error) 10%,transparent)}}.bg-error\/20{background-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.bg-error\/20{background-color:color-mix(in oklab,var(--color-error) 20%,transparent)}}.bg-error\/90{background-color:#ef4444e6}@supports (color:color-mix(in lab,red,red)){.bg-error\/90{background-color:color-mix(in oklab,var(--color-error) 90%,transparent)}}.bg-running{background-color:var(--color-running)}.bg-running\/10{background-color:#3b82f61a}@supports (color:color-mix(in lab,red,red)){.bg-running\/10{background-color:color-mix(in oklab,var(--color-running) 10%,transparent)}}.bg-running\/20{background-color:#3b82f633}@supports (color:color-mix(in lab,red,red)){.bg-running\/20{background-color:color-mix(in oklab,var(--color-running) 20%,transparent)}}.bg-running\/60{background-color:#3b82f699}@supports (color:color-mix(in lab,red,red)){.bg-running\/60{background-color:color-mix(in oklab,var(--color-running) 60%,transparent)}}.bg-success{background-color:var(--color-success)}.bg-success\/10{background-color:#22c55e1a}@supports (color:color-mix(in lab,red,red)){.bg-success\/10{background-color:color-mix(in oklab,var(--color-success) 10%,transparent)}}.bg-success\/20{background-color:#22c55e33}@supports (color:color-mix(in lab,red,red)){.bg-success\/20{background-color:color-mix(in oklab,var(--color-success) 20%,transparent)}}.bg-success\/30{background-color:#22c55e4d}@supports (color:color-mix(in lab,red,red)){.bg-success\/30{background-color:color-mix(in oklab,var(--color-success) 30%,transparent)}}.bg-success\/60{background-color:#22c55e99}@supports (color:color-mix(in lab,red,red)){.bg-success\/60{background-color:color-mix(in oklab,var(--color-success) 60%,transparent)}}.bg-warning{background-color:var(--color-warning)}.bg-warning\/10{background-color:#f59e0b1a}@supports (color:color-mix(in lab,red,red)){.bg-warning\/10{background-color:color-mix(in oklab,var(--color-warning) 10%,transparent)}}.bg-warning\/20{background-color:#f59e0b33}@supports (color:color-mix(in lab,red,red)){.bg-warning\/20{background-color:color-mix(in oklab,var(--color-warning) 20%,transparent)}}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-cyan-500{--tw-gradient-from:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-600{--tw-gradient-from:var(--color-purple-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-600{--tw-gradient-to:var(--color-indigo-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-1{padding-left:calc(var(--spacing) * 1)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.tracking-\[2px\]{--tw-tracking:2px;letter-spacing:2px}.tracking-\[4px\]{--tw-tracking:4px;letter-spacing:4px}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-black{color:var(--color-black)}.text-bright{color:var(--color-bright)}.text-cyan-400{color:var(--color-cyan-400)}.text-dim{color:var(--color-dim)}.text-error{color:var(--color-error)}.text-error\/80{color:#ef4444cc}@supports (color:color-mix(in lab,red,red)){.text-error\/80{color:color-mix(in oklab,var(--color-error) 80%,transparent)}}.text-green-400{color:var(--color-green-400)}.text-purple-400{color:var(--color-purple-400)}.text-running{color:var(--color-running)}.text-success{color:var(--color-success)}.text-text{color:var(--color-text)}.text-warning{color:var(--color-warning)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow-\[0_0_6px_rgba\(59\,130\,246\,0\.5\)\]{--tw-shadow:0 0 6px var(--tw-shadow-color,#3b82f680);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media(hover:hover){.hover\:border-dim:hover{border-color:var(--color-dim)}.hover\:bg-error\/10:hover{background-color:#ef44441a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-error\/10:hover{background-color:color-mix(in oklab,var(--color-error) 10%,transparent)}}.hover\:bg-running\/10:hover{background-color:#3b82f61a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-running\/10:hover{background-color:color-mix(in oklab,var(--color-running) 10%,transparent)}}.hover\:bg-running\/30:hover{background-color:#3b82f64d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-running\/30:hover{background-color:color-mix(in oklab,var(--color-running) 30%,transparent)}}.hover\:bg-success\/10:hover{background-color:#22c55e1a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-success\/10:hover{background-color:color-mix(in oklab,var(--color-success) 10%,transparent)}}.hover\:bg-warning\/10:hover{background-color:#f59e0b1a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-warning\/10:hover{background-color:color-mix(in oklab,var(--color-warning) 10%,transparent)}}.hover\:from-purple-500:hover{--tw-gradient-from:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:to-indigo-500:hover{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.hover\:text-bright:hover{color:var(--color-bright)}.hover\:text-error:hover{color:var(--color-error)}.hover\:text-text:hover{color:var(--color-text)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-\[0_0_10px_rgba\(59\,130\,246\,0\.7\)\]:hover{--tw-shadow:0 0 10px var(--tw-shadow-color,#3b82f6b3);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:not-sr-only:focus{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.focus\:absolute:focus{position:absolute}.focus\:top-2:focus{top:calc(var(--spacing) * 2)}.focus\:left-2:focus{left:calc(var(--spacing) * 2)}.focus\:z-50:focus{z-index:50}.focus\:rounded:focus{border-radius:.25rem}.focus\:border-running:focus{border-color:var(--color-running)}.focus\:border-running\/50:focus{border-color:#3b82f680}@supports (color:color-mix(in lab,red,red)){.focus\:border-running\/50:focus{border-color:color-mix(in oklab,var(--color-running) 50%,transparent)}}.focus\:bg-bg-1:focus{background-color:var(--color-bg-1)}.focus\:px-3:focus{padding-inline:calc(var(--spacing) * 3)}.focus\:py-1\.5:focus{padding-block:calc(var(--spacing) * 1.5)}.focus\:text-sm:focus{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.focus\:text-bright:focus{color:var(--color-bright)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:p-6{padding:calc(var(--spacing) * 6)}}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--color-bg);color:var(--color-text);font-family:var(--font-mono);background-image:linear-gradient(#1e3a5a12 1px,#0000 1px),linear-gradient(90deg,#1e3a5a12 1px,#0000 1px);background-size:40px 40px;min-height:100vh;animation:25s linear infinite gridDrift}@keyframes gridDrift{to{background-position:0 40px}}@keyframes borderPulse{0%,to{opacity:.5}50%{opacity:1}}@keyframes shimmer{0%{background-position:-200% 0}to{background-position:200% 0}}@media(prefers-reduced-motion:reduce){*,:before,:after{transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media(max-width:767px){.desktop-nav{display:none}.mobile-tab-bar{z-index:30;background:var(--color-bg-1);border-top:1px solid var(--color-border);padding:.5rem 0;display:flex;position:fixed;bottom:0;left:0;right:0}.mobile-tab-bar button{color:var(--color-dim);cursor:pointer;background:0 0;border:none;flex-direction:column;flex:1;align-items:center;gap:2px;padding:.25rem;font-size:9px;display:flex}.mobile-tab-bar button.active{color:var(--color-bright)}main{padding-bottom:60px}}@media(min-width:768px){.mobile-tab-bar{display:none}}.agent-card-3d{perspective:800px;transition:transform .3s ease-out}@media(hover:hover)and (min-width:768px){.agent-card-3d:hover{z-index:1}}@media(prefers-reduced-motion:reduce),(hover:none){.agent-card-3d{perspective:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/dashboard/dist/index.html b/dashboard/dist/index.html index 8da8efd..aa30184 100644 --- a/dashboard/dist/index.html +++ b/dashboard/dist/index.html @@ -9,8 +9,8 @@ - - + + Skip to content diff --git a/dashboard/src/views/IntelView.tsx b/dashboard/src/views/IntelView.tsx index efbfad9..7e14b2c 100644 --- a/dashboard/src/views/IntelView.tsx +++ b/dashboard/src/views/IntelView.tsx @@ -298,36 +298,51 @@ function MusicIntelTab({ data }: { data: Record }) { const moodPerf = data.mood_performance as Record | undefined const bpmPerf = data.bpm_performance as Record | undefined const recs = data.recommendations as string[] | undefined - const adapt = data.adaptation_impact as Record | undefined - const stems = data.stems_impact as Record | undefined + const adapt = data.adaptation_impact as { + adapted_avg_retention?: number; looped_avg_retention?: number + adapted_count?: number; looped_count?: number + } | undefined + const stems = data.stems_impact as { + stems_avg_retention?: number; no_stems_avg_retention?: number + stems_count?: number; no_stems_count?: number + } | undefined const sourceDist = data.source_distribution as Record | undefined const moodEntries = moodPerf ? Object.entries(moodPerf).sort(([, a], [, b]) => b.avg_retention - a.avg_retention) : [] const bpmEntries = bpmPerf ? Object.entries(bpmPerf).sort(([, a], [, b]) => b.avg_retention - a.avg_retention) : [] + const bestMood = moodEntries[0] + const bestBpm = bpmEntries[0] return (
{/* Summary cards */}
- {moodEntries.length > 0 && ( + {bestMood && (
Best Mood
-
{moodEntries[0][0]}
-
{moodEntries[0][1].avg_retention}% retention
+
{bestMood[0]}
+
{bestMood[1].avg_retention}% retention
)} - {adapt && adapt.adapted_count > 0 && ( + {adapt && (adapt.adapted_count ?? 0) > 0 && (
Adapted vs Looped
{((adapt.adapted_avg_retention || 0) - (adapt.looped_avg_retention || 0)).toFixed(1)}%
retention lift
)} - {bpmEntries.length > 0 && ( + {bestBpm && (
Best BPM
-
{bpmEntries[0][0]}
-
{bpmEntries[0][1].avg_retention}% retention
+
{bestBpm[0]}
+
{bestBpm[1].avg_retention}% retention
+
+ )} + {stems && (stems.stems_count ?? 0) > 0 && ( +
+
Stems vs Full Mix
+
{((stems.stems_avg_retention || 0) - (stems.no_stems_avg_retention || 0)).toFixed(1)}%
+
retention lift
)}
diff --git a/docker-compose.yml b/docker-compose.yml index 8999965..0a31374 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,25 @@ version: "3.8" +# Quick start: +# cp .env.example .env # fill in API keys + TRIGGER_KEY + DASHBOARD_PASSWORD +# mkdir -p data outputs remotion/public/music +# docker compose up -d --build +# +# Runtime state (channel_insights.json, lessons_learned.json) is kept in ./data. +# Uploads default to `local` (saved to ./outputs/final). To publish to YouTube, +# set `providers.upload.name: youtube` in obsidian.yaml, then put +# client_secrets.json and youtube_token.json in ./data/ (or set YOUTUBE_TOKEN_JSON in .env). +# +# The container runs as an unprivileged user (uid 1000 by default). On Linux, +# if your host uid differs, build with: APP_UID=$(id -u) APP_GID=$(id -g) docker compose up -d --build + services: obsidian: - build: . + build: + context: . + args: + APP_UID: ${APP_UID:-1000} + APP_GID: ${APP_GID:-1000} container_name: obsidian-archive ports: - "8080:8080" @@ -10,16 +27,14 @@ services: - .env environment: - PORT=8080 + - HOST=0.0.0.0 # required inside the container (app default is 127.0.0.1) - PYTHONUNBUFFERED=1 volumes: - # Persist outputs, music, and credentials between restarts + # Persist outputs and music between restarts - ./outputs:/app/outputs - ./remotion/public/music:/app/remotion/public/music - - ./youtube_token.json:/app/youtube_token.json - - ./client_secrets.json:/app/client_secrets.json:ro - # Persist intelligence data (DNA scores, analytics, optimizer reports) - - ./channel_insights.json:/app/channel_insights.json - - ./lessons_learned.json:/app/lessons_learned.json + # Runtime JSON state + YouTube credentials (client_secrets.json, youtube_token.json) + - ./data:/app/data restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 0000000..52d2745 --- /dev/null +++ b/docs/PROVIDERS.md @@ -0,0 +1,214 @@ +# Providers: bring your own services + +Every external service the pipeline uses goes through a **provider**: + +| Type | Used for | Built-in names | +|------|----------|----------------| +| `llm` | every agent (research, script, SEO, scenes…) | `anthropic` (default), `openai` | +| `tts` | narration (long-form and Shorts) | `elevenlabs` (default), `openai`, `epidemic_sound` | +| `images` | scene images, character portraits, Shorts images, thumbnails | `fal` (default) | +| `footage` | stock video for atmospheric scenes | `pexels` (default) | +| `upload` | publishing the final video and Short (stage 13) | `local` (default), `youtube` | +| `music` | background track | `auto` (default), `local`, `epidemic_sound` | +| `sfx` | per-scene sound effects and ambience | `auto` (default), `local`, `epidemic_sound` | + +`auto` picks `epidemic_sound` when `EPIDEMIC_SOUND_API_KEY` is set and `local` otherwise. + +You can replace any of them with your own class. You don't need to edit +pipeline code or register anything. + +## Quick start + +1. Write a class that extends the matching base class in `providers/base.py`: + + ```python + # my_providers/tts.py (next to run_pipeline.py) + from providers.base import TTSProvider + + class MyTTS(TTSProvider): + def __init__(self, api_url: str, voice: str = "narrator"): + self.api_url = api_url + self.voices = {"narrator": voice} + def synthesize(self, text, voice_id=None, voice_settings=None, speed=1.0): + ... # call your service, write audio to a file + return audio_path, [] # [] = let the pipeline align words + def list_voices(self): return [] + def check_credits(self): return {"remaining": -1, "limit": -1, "unit": "characters"} + @property + def name(self): return "My TTS" + ``` + +2. Point `obsidian.yaml` at it. Each key under `options` becomes a keyword argument to the constructor: + + ```yaml + providers: + tts: + name: my_providers.tts.MyTTS + options: + api_url: http://localhost:8000/tts + voice: narrator + ``` + +3. Check that it loads: + + ```bash + python -m providers + ``` + + This imports and constructs every configured provider and prints `ok` or the error for each one. It makes no API calls. + +**Loading rules** +- `name` is either a built-in name or a dotted `module.path.ClassName`. The module must be importable from the project root, either as a file or package inside the repo or as an installed package. If it can't be imported, the error names the module. +- If an option doesn't match a constructor argument, the error lists the option keys you passed. +- Each provider is created once per process and reused. +- Read API keys from environment variables (`.env` is loaded). Check them when the provider is used, not in `__init__`, so that `python -m providers` works without keys. + +Working examples are in `examples/providers/`: `http_tts.py` (any HTTP TTS service) and `folder_upload.py` (copy the video to a folder, a starting point for S3/R2/Vimeo). + +--- + +## `llm`: `LLMProvider` + +```python +def generate(self, system_prompt: str, user_prompt: str, model: str | None = None, + max_tokens: int = 4000, expect_json: bool = True, + output_schema: dict | None = None) -> dict | list | str +def generate_with_search(self, system_prompt, user_prompt, model=None, + max_tokens=4000, output_schema=None) -> str +def estimate_cost(self, input_tokens: int, output_tokens: int, model=None) -> float +name: str (property) +# optional +def resolve_model(self, tier: str) -> str | None # default: self.models.get(tier) +``` + +- **How it's called.** All agents go through `clients/claude_client.call_claude()` and `call_claude_with_search()`. If the provider is anything other than `anthropic`, these call your `generate()` or `generate_with_search()`. +- **Model tiers.** The pipeline asks for a tier, not a model: `premium` (creative writing), `full` (reasoning), or `light` (formatting and classification). It passes `model=resolve_model(tier)`. If that returns `None`, `generate()` should fall back to its default model. The easiest approach is to accept a `models` option: `models: {premium: ..., full: ..., light: ...}`. +- **JSON.** When `expect_json=True`, return a parsed `dict` or `list`. If you return a string, the pipeline parses it leniently: it removes code fences, trailing text and truncation. `output_schema` is a JSON Schema. Enforce it if your API supports structured output; otherwise treat it as a hint. +- **Search.** `generate_with_search` returns plain text. If your model has no web search, generate without it (the built-in `openai` provider does this). +- **Limitations.** Token costs aren't tracked for LLMs other than Anthropic, so the per-run budget cap doesn't cover LLM spend. The pipeline logs this once per run. Claude-vision features (image and thumbnail quality scoring) only run with `anthropic`. Without it, images are accepted as generated and thumbnails get a neutral score. +- **Built-in `openai` options:** `default_model`, `models`, `base_url` (for any OpenAI-compatible server, e.g. Ollama), `api_key_env`. + +## `tts`: `TTSProvider` + +```python +def synthesize(self, text: str, voice_id: str | None = None, + voice_settings: dict | None = None, speed: float = 1.0 + ) -> tuple[Path, list[dict]] +def list_voices(self) -> list[dict] # [{"id", "name", "description"}] +def check_credits(self) -> dict # {"remaining", "limit", "unit"} +name: str +# optional +def resolve_voice(self, role: str) -> str | None # role: "narrator" | "quote"; default: self.voices.get(role) +``` + +- **How it's called.** The pipeline splits narration into chunks of roughly one scene each. For each chunk it calls `synthesize(text, voice_id=resolve_voice(role), voice_settings=..., speed=...)`. `role` is `"quote"` for quoted historical speech and `"narrator"` for everything else. +- **`voice_settings` and `speed`.** `voice_settings` holds ElevenLabs-style delivery settings (`stability`, `similarity_boost`, `style`, `use_speaker_boost`, `speed`) that vary by scene mood. Ignore any you can't use. `speed` is usually between 0.65 and 1.0. +- **Audio.** Return any file ffmpeg can read. Anything that isn't a 44.1 kHz MP3 is transcoded. Temp files are fine; files in the system temp dir are deleted after they are copied. +- **Timestamps.** Return `[{"word", "start", "end"}]` in seconds from the start of the clip. If you return `[]`, the pipeline runs forced alignment (`media/forced_alignment.py`, which uses Whisper if `openai-whisper` is installed). Otherwise it spreads the words evenly over the clip and logs a warning; captions will then be approximate. +- **`check_credits`.** Called once at startup. Return `limit <= 0` if the quota is unknown. Raise an exception whose message contains `401` to abort the run when the key is invalid. +- **Built-in options:** + - `elevenlabs`: `model` (default `voice.model`), `voices` + - `openai`: `model` (`tts-1`), `voice` (`alloy`), `quote_voice` + +## `images`: `ImageProvider` + +```python +def generate(self, prompt: str, style: str | None = None, width: int = 1920, + height: int = 1080, seed: int | None = None) -> Path +def estimate_cost(self) -> float +name: str +# optional +supports_reference_images: bool = False +def generate_with_reference(self, prompt, reference_image: Path, width=1920, + height=1080, seed=None) -> Path +``` + +- **Sizes requested:** + + | Use | Size | + |---|---| + | Scenes | 1920x1080 | + | Shorts | 1440x2560 | + | Thumbnails | 1280x720 | + | Character portraits | 1024x1024 | + + Undersized scene images are upscaled. +- **`style`.** Set from the `IMAGE_MODEL` env var (`flux` or `recraft`). The built-in fal provider uses it to choose a model; your provider can ignore it. +- **Output.** Return a JPEG or PNG. It is copied into place. +- **Threads.** Scene images are generated by 3 worker threads, so `generate` must be thread-safe. +- **Reference images.** Set `supports_reference_images = True` and implement `generate_with_reference` to keep characters consistent. The pipeline then generates reference portraits of the main characters and passes them in. Without it, portraits are skipped. +- **Quality scoring.** Only runs with the `anthropic` LLM and `ANTHROPIC_API_KEY` set. Otherwise the first image is used and nothing is regenerated. + +## `footage`: `FootageProvider` + +```python +def search(self, query: str, orientation: str = "landscape", min_duration: int = 5, + max_results: int = 5) -> list[dict] + # [{"url", "duration", "width", "height", "preview_url", "credit"?}], best first +def download(self, url: str, output_path: Path) -> Path +name: str +``` + +- The footage hunter (stage 9) calls `search(query, orientation="landscape", min_duration=0, max_results=5)` for atmospheric scenes. It stores the first result's `url`, `width`, `height`, `duration` and `credit` in the visuals manifest, with `source` set to the provider name. The default credit is `"Footage via "`. +- Results are cached for 7 days by provider name and query. +- Historical scenes use Wikimedia Commons first; that isn't a provider. + +## `upload`: `UploadProvider` + +```python +def upload(self, video_path: Path, title: str, description: str, tags: list[str], + thumbnail_path: Path | None = None) -> dict + # {"video_id": str (non-empty), "url": str, "status": str} +name: str +``` + +- **Where it's called.** Stage 13 uses it for the long-form video and the Shorts pipeline uses it for the Short. The pipeline builds the title, the description (with chapters and sources) and the tags from the SEO stage. For the long-form video it also picks the best thumbnail. +- **Empty `video_id`.** An empty `video_id` counts as a failed upload. +- **YouTube-only steps.** Only the `youtube` provider triggers them: the sources comment, era playlist, endscreen and cards, the post-upload analytics agent, comment analysis, the optimizer's retention observation and the community post. Other providers skip all of these. +- **Built-in options:** + - `local`: `output_dir` (default `outputs/final/`) + - `youtube`: `privacy` (`public`, `unlisted` or `private`); needs `client_secrets.json` or `GOOGLE_CLIENT_SECRETS_JSON` + +## `music`: `MusicProvider` + +```python +def select_for_video(self, scenes: list[dict], total_duration: float) -> dict | None + # {"music_file": str, "music_start_offset": float?, ...} (other keys optional) +def search(self, mood: str, duration: float = 600, **kwargs) -> list[dict] +def download(self, track_id: str, output_path: Path, stem: str | None = None) -> Path +name: str +``` + +- **How it's called.** The convert stage (stage 11) calls `select_for_video` with the Remotion scenes: `mood`, `start_time`, `end_time`, `narrative_position` and so on. +- **`music_file`.** It can be a path relative to `remotion/public` (e.g. `music/track.mp3`) or an absolute path. Absolute paths and files outside `remotion/public` are copied into `remotion/public/music/`. +- **Fallback.** If you return `None` or raise, the pipeline uses the local library instead: energy-matched selection, then a random mood match, then the bundled mood tracks. +- **`search` and `download`.** The pipeline doesn't call these, but they are part of the interface. Raise `NotImplementedError` if you don't need them. +- Epidemic track adaptation (exact duration and stems) only applies to files named `epidemic_*`. + +## `sfx`: `SFXProvider` + +```python +def search(self, keyword: str, duration_max: float = 5.0, **kwargs) -> list[dict] # [{"id", "title", "duration", "tags"}] +def download(self, sfx_id: str, output_path: Path) -> Path +name: str +# optional (defaults use search + download) +def sfx_for_scene(self, scene: dict, dest_dir: Path) -> str | None # dest_dir = remotion/public/sfx +def ambient_for_scene(self, scene: dict, dest_dir: Path) -> str | None # dest_dir = remotion/public/ambience +``` + +- **When it's called.** `ambient_for_scene` is called for every scene. `sfx_for_scene` is only called for key scenes (reveals, climaxes, act transitions, breathing room). Silence-beat scenes get neither. +- **Return value.** Return a path relative to `remotion/public` (e.g. `"sfx/hit.mp3"`) or `None`. +- **Default behaviour.** The default `sfx_for_scene` searches for `" cinematic impact"` with `duration_max=5`. The default `ambient_for_scene` searches for `" ambience"` with `duration_max=30`. Both download the first result into `dest_dir` and cache it per keyword. Override them for smarter choices. + +--- + +## Testing your provider + +```python +from providers import registry +registry.clear_cache() +tts = registry.get_provider("tts") # what the pipeline will get +path, words = tts.synthesize("Hello there.") +``` + +`tests/test_provider_wiring.py` has fake providers for all seven types (`tests/_fake_providers.py`) and runs each of them through the real pipeline functions. Use it as a template. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..3f8d79b --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Examples (not used by the pipeline unless configured in obsidian.yaml).""" diff --git a/examples/providers/__init__.py b/examples/providers/__init__.py new file mode 100644 index 0000000..2952ec3 --- /dev/null +++ b/examples/providers/__init__.py @@ -0,0 +1 @@ +"""Example "bring your own" providers — see docs/PROVIDERS.md.""" diff --git a/examples/providers/folder_upload.py b/examples/providers/folder_upload.py new file mode 100644 index 0000000..f259789 --- /dev/null +++ b/examples/providers/folder_upload.py @@ -0,0 +1,49 @@ +""" +Example upload provider: copy the finished video into a folder. + +Handy for a synced folder (Dropbox, Google Drive, a NAS mount) or as a +template for S3/R2/Vimeo uploads: replace the copy with your API call and +return the id/url it gives you. + +obsidian.yaml: + providers: + upload: + name: examples.providers.folder_upload.CopyToFolder + options: + folder: ~/Videos/obsidian +""" + +from __future__ import annotations + +import json +import re +import shutil +import time +from pathlib import Path + +from providers.base import UploadProvider + + +class CopyToFolder(UploadProvider): + def __init__(self, folder: str = "./outputs/published"): + self.folder = Path(folder).expanduser() + + def upload(self, video_path, title, description, tags, thumbnail_path=None): + self.folder.mkdir(parents=True, exist_ok=True) + slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60] or "video" + video_id = f"{time.strftime('%Y%m%d-%H%M%S')}-{slug}" + + dest = self.folder / f"{video_id}.mp4" + shutil.copy2(video_path, dest) + if thumbnail_path and Path(thumbnail_path).exists(): + shutil.copy2(thumbnail_path, dest.with_suffix(".jpg")) + dest.with_suffix(".json").write_text(json.dumps({ + "title": title, "description": description, "tags": tags, + }, indent=2)) + + # video_id must be non-empty — the pipeline treats "" as a failed upload + return {"video_id": video_id, "url": dest.resolve().as_uri(), "status": "copied"} + + @property + def name(self): + return "Copy to folder" diff --git a/examples/providers/http_tts.py b/examples/providers/http_tts.py new file mode 100644 index 0000000..2eec50a --- /dev/null +++ b/examples/providers/http_tts.py @@ -0,0 +1,87 @@ +""" +Example TTS provider: any HTTP text-to-speech service. + +A starting point for wiring your own (or a self-hosted) TTS server into the +pipeline. It POSTs {"text", "voice", "speed"} to ``api_url`` and accepts +either raw audio bytes back, or JSON: + + {"audio_base64": "...", "format": "mp3", + "words": [{"word": "Hello", "start": 0.0, "end": 0.42}, ...]} # words optional + +obsidian.yaml: + providers: + tts: + name: examples.providers.http_tts.HttpTTS + options: + api_url: http://localhost:8000/tts + voice: narrator + quote_voice: elder # optional + api_key_env: MY_TTS_KEY # optional: sent as "Authorization: Bearer " + +If your service returns no word timings, return [] — the pipeline aligns +words itself (Whisper if installed, otherwise an even spread). +""" + +from __future__ import annotations + +import base64 +import os +import tempfile +from pathlib import Path + +from providers.base import TTSProvider + + +class HttpTTS(TTSProvider): + def __init__(self, api_url: str, voice: str = "default", quote_voice: str | None = None, + api_key_env: str | None = None, timeout: int = 120): + self.api_url = api_url + self.api_key_env = api_key_env + self.timeout = timeout + # resolve_voice() (from the base class) looks roles up in self.voices + self.voices = {"narrator": voice, "quote": quote_voice or voice} + + def _headers(self) -> dict: + headers = {"Content-Type": "application/json"} + if self.api_key_env: + key = os.getenv(self.api_key_env) + if not key: + raise RuntimeError(f"{self.api_key_env} not set") + headers["Authorization"] = f"Bearer {key}" + return headers + + def synthesize(self, text, voice_id=None, voice_settings=None, speed=1.0): + import requests + + r = requests.post( + self.api_url, + headers=self._headers(), + json={"text": text, "voice": voice_id or self.voices["narrator"], "speed": speed}, + timeout=self.timeout, + ) + r.raise_for_status() + + words: list[dict] = [] + if r.headers.get("content-type", "").startswith("application/json"): + data = r.json() + audio = base64.b64decode(data["audio_base64"]) + suffix = "." + data.get("format", "mp3") + words = data.get("words") or [] + else: + audio = r.content + suffix = ".wav" if "wav" in r.headers.get("content-type", "") else ".mp3" + + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(audio) + # Any format ffmpeg reads is fine: the pipeline converts to 44.1kHz MP3. + return Path(tmp.name), words + + def list_voices(self): + return [{"id": v, "name": v, "description": role} for role, v in self.voices.items()] + + def check_credits(self): + return {"remaining": -1, "limit": -1, "unit": "characters"} # unknown + + @property + def name(self): + return "HTTP TTS" diff --git a/obsidian.yaml b/obsidian.yaml index 9efdf32..517f846 100644 --- a/obsidian.yaml +++ b/obsidian.yaml @@ -18,29 +18,47 @@ profile: documentary # ── Providers ──────────────────────────────────────────────────────────────── -# Swap any service by changing the provider name. +# Every external service goes through a provider. Swap one by changing its name. # Built-in options: # llm: anthropic, openai -# tts: elevenlabs +# tts: elevenlabs, openai, epidemic_sound # images: fal # footage: pexels -# upload: local -# music: epidemic_sound, local -# sfx: epidemic_sound, local -# Or use a custom class: "my_package.module.ClassName" +# upload: local, youtube +# music: epidemic_sound, local, auto (Epidemic if EPIDEMIC_SOUND_API_KEY is set, else local) +# sfx: epidemic_sound, local, auto +# +# Bring your own: set name to the dotted path of a class that extends the base +# class in providers/base.py; "options" are passed to its constructor: +# tts: +# name: my_providers.tts.MyTTS # my_providers/tts.py in the project root +# options: {api_url: "http://localhost:8000", voice: narrator} +# Guide: docs/PROVIDERS.md Self-check: python -m providers providers: llm: name: anthropic + # openai example: + # name: openai + # options: + # default_model: gpt-4o + # models: {premium: gpt-4o, full: gpt-4o, light: gpt-4o-mini} + # # base_url: http://localhost:11434/v1 # any OpenAI-compatible server tts: name: elevenlabs + # openai example (word timestamps come from forced alignment): + # name: openai + # options: {model: tts-1, voice: onyx, quote_voice: echo} images: name: fal footage: name: pexels upload: - name: local + name: local # saves to outputs/final/; use "youtube" to publish options: # output_dir: "./outputs/final" # Uncomment to set custom output path + # To publish to YouTube (OAuth via client_secrets.json): + # name: youtube + # options: {privacy: public} # public | unlisted | private music: name: auto # "epidemic_sound", "local", or "auto" sfx: diff --git a/pipeline/audio.py b/pipeline/audio.py index 1599e20..3cd003a 100644 --- a/pipeline/audio.py +++ b/pipeline/audio.py @@ -3,7 +3,6 @@ import json import re import time -import base64 import shutil import subprocess from pathlib import Path @@ -16,8 +15,122 @@ logger = get_logger(__name__) +# ── TTS provider plumbing (shared with pipeline/shorts.py) ───────────────── + +_even_split_warned = False + + +def _audio_duration(path) -> float: + try: + from mutagen import File as _MFile + info = _MFile(str(path)) + if info is not None and info.info: + return float(info.info.length) + except Exception: + pass + return 0.0 + + +def _is_pipeline_mp3(path: Path) -> bool: + """True if path is a 44.1kHz MP3 (the format ElevenLabs returns). + + Chunks are joined with ``ffmpeg -c copy`` next to 44.1kHz mono silence + files, so every chunk must share that sample rate; anything else (e.g. + 24kHz MP3 or WAV from another provider) is transcoded. + """ + try: + from mutagen.mp3 import MP3 + info = MP3(str(path)).info + return info.sample_rate == 44100 + except Exception: + return False + + +def _store_chunk_audio(src: Path, chunk_path: Path) -> None: + """Move/transcode a provider's audio file into chunk_path as MP3 44.1kHz mono.""" + src = Path(src) + if src.suffix.lower() == ".mp3" and _is_pipeline_mp3(src): + shutil.copyfile(src, chunk_path) + else: + subprocess.run( + ["ffmpeg", "-y", "-i", str(src), "-ar", "44100", "-ac", "1", + "-c:a", "libmp3lame", "-b:a", "192k", str(chunk_path)], + check=True, capture_output=True, + ) + # Clean up provider temp files (never delete files a provider keeps elsewhere) + try: + import tempfile + if Path(tempfile.gettempdir()).resolve() in src.resolve().parents: + src.unlink(missing_ok=True) + except Exception: + pass + + +def _even_word_split(text: str, duration: float) -> list[dict]: + words = text.split() + if not words: + return [] + duration = duration or len(words) * 0.4 + per = duration / len(words) + return [{"word": w, "start": round(i * per, 3), "end": round((i + 1) * per, 3)} + for i, w in enumerate(words)] + + +def _align_chunk_words(chunk_path: Path, text: str) -> list[dict]: + """Word timestamps for a chunk whose TTS provider returned none. + + Uses media/forced_alignment (Whisper if installed); if that is unavailable + or fails, spreads words evenly over the chunk duration. + """ + global _even_split_warned + try: + from media.forced_alignment import align_audio_to_text + words = align_audio_to_text(Path(chunk_path), text) + if words: + return words + except Exception as e: + logger.debug(f"[Audio] Forced alignment failed: {e}") + if not _even_split_warned: + _even_split_warned = True + logger.warning("[Audio] TTS provider returned no word timestamps and forced alignment " + "is unavailable — spreading words evenly (captions will be approximate). " + "Install openai-whisper for accurate captions.") + return _even_word_split(text, _audio_duration(chunk_path)) + + +def synthesize_chunk(tts, text: str, chunk_path: Path, voice_settings=None, + speed: float = 1.0, role: str = "narrator") -> list[dict]: + """Synthesize one chunk with the configured TTS provider. + + Writes MP3 audio to chunk_path and returns word timestamps relative to the + start of the chunk. Timestamps come from the provider when it has them, + otherwise from forced alignment (see _align_chunk_words). + """ + voice_id = tts.resolve_voice(role) or None + audio_file, words = tts.synthesize(text, voice_id=voice_id, + voice_settings=voice_settings, speed=speed) + _store_chunk_audio(Path(audio_file), Path(chunk_path)) + words = [ + {"word": str(w["word"]), "start": round(float(w["start"]), 3), "end": round(float(w["end"]), 3)} + for w in (words or []) if str(w.get("word", "")).strip() + ] + if not words: + words = _align_chunk_words(Path(chunk_path), text) + return words + + +def _log_tts_cost(n_chars: int) -> None: + try: + from core.cost_tracker import get_active_run_id, log_cost + from providers.registry import get_provider_name + _rid = get_active_run_id() + if _rid: + log_cost(_rid, "audio", get_provider_name("tts"), n_chars, "characters") + except Exception: + pass + + def run_audio(script_data, scene_data=None, display_script=None): - import requests try: from mutagen.mp3 import MP3 except ImportError: @@ -25,9 +138,11 @@ def run_audio(script_data, scene_data=None, display_script=None): subprocess.run([sys.executable, "-m", "pip", "install", "mutagen"], check=True) from mutagen.mp3 import MP3 - ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY") - if not ELEVENLABS_API_KEY: - raise RuntimeError("ELEVENLABS_API_KEY not set — cannot generate audio") + # TTS provider from obsidian.yaml (providers.tts). Each provider raises its + # own clear error if credentials are missing. + from providers.registry import get_provider + tts = get_provider("tts") + # Voice config from obsidian.yaml (via pipeline_config) from core.pipeline_config import ( NARRATOR_VOICE_ID, QUOTE_VOICE_ID as _QUOTE_VID, @@ -63,80 +178,12 @@ def detect_quoted_speech(text): 'announced', 'commanded', 'stated', 'replied', 'exclaimed'] ) - def generate_chunk(text, voice_settings=None, voice_id=None, speed=None): - if voice_settings is None: - voice_settings = VOICE_BODY - if voice_id is None: - voice_id = VOICE_ID - if speed is None: - speed = VOICE_SPEED - url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/with-timestamps" - headers = {"xi-api-key": ELEVENLABS_API_KEY, "Content-Type": "application/json"} - payload = { - "text": text, "model_id": "eleven_v3", - "voice_settings": voice_settings, - "speed": speed, - } - last_err = None - for attempt in range(5): - try: - r = requests.post(url, headers=headers, json=payload, timeout=120) - except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as ce: - wait = 30 * (attempt + 1) - logger.warning(f" Connection/timeout error, waiting {wait}s...") - time.sleep(wait) - last_err = ce - continue - if r.status_code == 200: - return json.loads(r.text, strict=False) - elif r.status_code == 429: - wait = 60 * (attempt + 1) - logger.warning(f" Rate limited, waiting {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - last_err = Exception("ElevenLabs rate limit (429)") - elif r.status_code in (500, 502, 503): - wait = 30 * (attempt + 1) - logger.warning(f" Server error ({r.status_code}), waiting {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - last_err = Exception(f"ElevenLabs server error ({r.status_code})") - else: - raise Exception(f"ElevenLabs {r.status_code}: {r.text[:200]}") - raise last_err or Exception("Failed after 5 attempts") - - def extract_word_timestamps(data): - """Extract word-level timestamps from ElevenLabs character alignment.""" - alignment = data.get("alignment") or {} - if not isinstance(alignment, dict): - alignment = {} - chars = alignment.get("characters", []) - starts = alignment.get("character_start_times_seconds", []) - ends = alignment.get("character_end_times_seconds", []) - - chunk_words = [] - word, word_start, last_end = "", None, 0.0 - for j, ch in enumerate(chars): - if ch in (" ", "\n"): - if word and word_start is not None: - end_idx = j - 1 - word_end = ends[end_idx] if 0 <= end_idx < len(ends) else word_start + max(0.15, len(word) * 0.08) - chunk_words.append({"word": word, "start": round(word_start, 3), - "end": round(word_end, 3)}) - word, word_start = "", None - else: - if word_start is None and j < len(starts): - word_start = starts[j] - word += ch - if j < len(ends): - last_end = ends[j] - if word and word_start is not None: - chunk_words.append({"word": word, "start": round(word_start, 3), "end": round(last_end, 3)}) - return chunk_words - # ── Determine chunking strategy ─────────────────────────────────────────── # Scene-aware: chunk by scene narration with mood-specific voice settings # Legacy: chunk by text splitting with keyword-based prosody detection all_scenes = [] + scenes = [] if scene_data and isinstance(scene_data, dict): all_scenes = scene_data.get("scenes", []) @@ -324,21 +371,11 @@ def extract_word_timestamps(data): f"stab={plan['vs'].get('stability', '?')}, " f"style={plan['vs'].get('style', '?')}, " f"spd={plan['spd']})...") - data = generate_chunk(plan["text"], voice_settings=plan["vs"], - voice_id=plan["vid"], speed=plan["spd"]) - audio_bytes = base64.b64decode(data.get("audio_base64", "")) - chunk_path.write_bytes(audio_bytes) - - # Log ElevenLabs cost - try: - from core.cost_tracker import get_active_run_id, log_cost - _rid = get_active_run_id() - if _rid: - log_cost(_rid, "audio", "elevenlabs", len(plan["text"]), "characters") - except Exception: - pass - - chunk_words = extract_word_timestamps(data) + role = "quote" if plan["vid"] == QUOTE_VOICE_ID else "narrator" + chunk_words = synthesize_chunk(tts, plan["text"], chunk_path, + voice_settings=plan["vs"], speed=plan["spd"], + role=role) + _log_tts_cost(len(plan["text"])) with open(chunk_ts, "w") as f: json.dump(chunk_words, f) diff --git a/pipeline/audio_assets.py b/pipeline/audio_assets.py new file mode 100644 index 0000000..9d40d5a --- /dev/null +++ b/pipeline/audio_assets.py @@ -0,0 +1,146 @@ +"""Music / SFX / ambient selection for the Remotion convert stage. + +Everything goes through the configured providers (providers.music and +providers.sfx in obsidian.yaml). The built-in providers reproduce the +original behaviour (Epidemic Sound API first when configured, then the local +library); a custom provider fully decides what to return. + +All returned paths are relative to remotion/public (Invariant 15). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from core.log import get_logger + +logger = get_logger(__name__) + + +# ── SFX / ambient ──────────────────────────────────────────────────────────── + +def _sfx_provider(): + from providers.registry import get_provider + return get_provider("sfx") + + +def select_scene_ambient(scene: dict, public_dir: Path) -> str | None: + """Ambient bed for a scene via providers.sfx (None on error / nothing found).""" + try: + return _sfx_provider().ambient_for_scene(scene, Path(public_dir) / "ambience") or None + except Exception as e: + logger.debug(f"[Convert] Ambient selection failed: {e}") + return None + + +def select_scene_sfx(scene: dict, public_dir: Path) -> str | None: + """One-shot SFX for a key scene via providers.sfx (None on error / nothing found).""" + try: + return _sfx_provider().sfx_for_scene(scene, Path(public_dir) / "sfx") or None + except Exception as e: + logger.debug(f"[Convert] SFX selection failed: {e}") + return None + + +# ── Music ──────────────────────────────────────────────────────────────────── + +def normalize_public_path(file_ref: str, public_dir: Path, subdir: str = "music") -> str | None: + """Return a path relative to remotion/public for a provider-returned file. + + Absolute paths (or files outside remotion/public) are copied into + ``remotion/public//``. Paths already relative to remotion/public + are kept. + """ + if not file_ref: + return None + public_dir = Path(public_dir) + p = Path(str(file_ref)) + if not p.is_absolute(): + if (public_dir / p).exists(): + return p.as_posix() + if not p.exists(): + return p.as_posix() # trust the provider (e.g. file appears later) + p = p.resolve() + try: + return p.resolve().relative_to(public_dir.resolve()).as_posix() + except ValueError: + pass + if not p.exists(): + logger.warning(f"[Convert] Music file from provider not found: {p}") + return None + dest_dir = public_dir / subdir + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / p.name + if not dest.exists() or dest.stat().st_size != p.stat().st_size: + shutil.copy2(p, dest) + return f"{subdir}/{p.name}" + + +def select_background_music(remotion_scenes: list[dict], total_duration: float, + public_dir: Path) -> tuple[str | None, float]: + """Pick the background track: music provider first, then local fallbacks. + + Returns (music_file relative to remotion/public or None, start_offset). + """ + music_file = None + music_start_offset = 0.0 + provider_name = "local" + try: + from providers.registry import get_provider, get_provider_name + provider_name = get_provider_name("music") + result = get_provider("music").select_for_video(remotion_scenes, total_duration) + if result and result.get("music_file"): + music_file = normalize_public_path(result["music_file"], public_dir) + music_start_offset = float(result.get("music_start_offset", 0) or 0) + if music_file: + corr = result.get("correlation_score") + corr_txt = f", corr={corr:.3f}" if isinstance(corr, (int, float)) else "" + logger.info(f"[Convert] Music ({provider_name}): {music_file} " + f"(offset={music_start_offset:.1f}s{corr_txt})") + except Exception as e: + logger.warning(f"[Convert] Music provider '{provider_name}' failed: {e}") + music_file = None + + if not music_file: + music_start_offset = 0.0 + try: + from media import music_manager + if provider_name != "local": + smart = music_manager.get_smart_music_for_video(remotion_scenes, total_duration) + if smart and smart.get("music_file"): + music_file = smart["music_file"] + music_start_offset = float(smart.get("music_start_offset", 0) or 0) + logger.info(f"[Convert] Smart music (local library): {music_file}") + if not music_file: + music_file = music_manager.get_music_for_video(remotion_scenes, total_duration) + if music_file: + logger.info(f"[Convert] Background music (random): {music_file}") + except Exception as _music_err: + logger.warning(f"[Convert] Music manager unavailable: {_music_err}") + + # Fallback: local mood-mapped files + if not music_file: + mood_counts: dict[str, int] = {} + for s in remotion_scenes: + m = s.get("mood", "dark") + mood_counts[m] = mood_counts.get(m, 0) + 1 + dominant_mood = max(mood_counts, key=mood_counts.get) if mood_counts else "dark" + MOOD_MUSIC = { + "dark": "music/dark_01_scp_x1x.mp3", + "tense": "music/tense_01_stay_the_course.mp3", + "dramatic": "music/dramatic_01_strength_of_titans.mp3", + "cold": "music/cold_01_scp_x5x.mp3", + "reverent": "music/reverent_01_ancient_rite.mp3", + "wonder": "music/wonder_01_the_descent.mp3", + "warmth": "music/warmth_01_hearth_and_home.mp3", + "absurdity": "music/absurdity_01_scheming_weasel.mp3", + } + local_file = MOOD_MUSIC.get(dominant_mood, MOOD_MUSIC["dark"]) + if (Path(public_dir) / local_file).exists(): + music_file = local_file + logger.info(f"[Convert] Background music (local): {music_file} (mood: {dominant_mood})") + else: + logger.warning("[Convert] No background music available") + + return music_file, music_start_offset diff --git a/pipeline/convert.py b/pipeline/convert.py index 0453342..c7e9ee1 100644 --- a/pipeline/convert.py +++ b/pipeline/convert.py @@ -2,13 +2,163 @@ import os import re import shutil +import subprocess from pathlib import Path from core.paths import MEDIA_DIR, REMOTION_SRC, REMOTION_PUBLIC from core.log import get_logger +from pipeline.audio_assets import select_background_music, select_scene_ambient, select_scene_sfx logger = get_logger(__name__) +# Synthetic reflection beat injected at the act3→ending boundary (seconds). +REFLECTION_DURATION = 3.0 +# Narration encode settings — must match pipeline/audio.py mastering step. +_NARRATION_SAMPLE_RATE = 44100 +_NARRATION_BITRATE = "192k" + + +def _probe_audio(path) -> dict: + """Return {"duration", "sample_rate", "channels"} for an audio file via ffprobe.""" + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a:0", + "-show_entries", "stream=sample_rate,channels:format=duration", + "-of", "json", str(path)], + check=True, capture_output=True, text=True, timeout=60, + ) + info = json.loads(out.stdout or "{}") + stream = (info.get("streams") or [{}])[0] + return { + "duration": float((info.get("format") or {}).get("duration") or 0.0), + "sample_rate": int(stream.get("sample_rate") or _NARRATION_SAMPLE_RATE), + "channels": int(stream.get("channels") or 1), + } + + +def splice_silence(src, dst, at_seconds: float, silence_seconds: float) -> bool: + """Write a copy of ``src`` to ``dst`` with ``silence_seconds`` of silence + inserted at ``at_seconds``. Never modifies ``src``. + + Re-encodes to MP3 at the pipeline's narration settings (44.1kHz, 192k). + Returns True on success (output verified by duration), False otherwise — + on failure ``dst`` is left untouched. + """ + src, dst = Path(src), Path(dst) + tmp = dst.with_name(dst.stem + ".splice_tmp" + dst.suffix) + try: + if not src.exists(): + raise FileNotFoundError(f"source narration not found: {src}") + if src.resolve() == dst.resolve(): + raise ValueError("splice_silence refuses to overwrite its source") + info = _probe_audio(src) + src_dur = info["duration"] + if not (0.0 < at_seconds < src_dur): + raise ValueError(f"splice point {at_seconds:.3f}s outside audio (0–{src_dur:.3f}s)") + ch = info["channels"] + layout = "mono" if ch == 1 else "stereo" if ch == 2 else f"{ch}c" + sr = info["sample_rate"] + fmt = f"aformat=sample_rates={sr}:channel_layouts={layout}" + t = f"{at_seconds:.6f}" + graph = ( + f"[0:a]{fmt},asplit=2[pre_in][post_in];" + f"[pre_in]atrim=end={t},asetpts=PTS-STARTPTS[pre];" + f"[post_in]atrim=start={t},asetpts=PTS-STARTPTS[post];" + f"[1:a]{fmt},asetpts=PTS-STARTPTS[gap];" + f"[pre][gap][post]concat=n=3:v=0:a=1[out]" + ) + dst.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["ffmpeg", "-y", "-v", "error", "-i", str(src), + "-f", "lavfi", "-t", f"{silence_seconds:.6f}", + "-i", f"anullsrc=r={sr}:cl={layout}", + "-filter_complex", graph, "-map", "[out]", + "-c:a", "libmp3lame", "-ar", str(_NARRATION_SAMPLE_RATE), + "-b:a", _NARRATION_BITRATE, str(tmp)], + check=True, capture_output=True, timeout=600, + ) + out_dur = _probe_audio(tmp)["duration"] + expected = src_dur + silence_seconds + if abs(out_dur - expected) > 0.25: + raise RuntimeError(f"spliced duration {out_dur:.3f}s != expected {expected:.3f}s") + os.replace(tmp, dst) + return True + except Exception as e: + logger.warning(f"[Convert] Narration silence splice failed: {e}") + try: + tmp.unlink(missing_ok=True) + except Exception: + pass + return False + + +def _find_reflection_boundary(remotion_scenes): + """Index of the first 'ending' scene that directly follows an 'act3' scene, or None.""" + for idx in range(len(remotion_scenes) - 1): + curr_pos = remotion_scenes[idx].get("narrative_position", "") + next_pos = remotion_scenes[idx + 1].get("narrative_position", "") + if curr_pos == "act3" and next_pos == "ending": + return idx + 1 + return None + + +def _inject_reflection_scene(remotion_scenes, words, total_duration, src_audio, dst_audio, + splice_fn=None): + """Insert a synthetic reflection scene at the act3→ending boundary. + + Scenes after the boundary and every word starting at/after the injection + time are shifted by REFLECTION_DURATION, and the SAME amount of silence is + spliced into the Remotion narration copy (``dst_audio``) at that same time. + + The shift is only committed if the splice succeeds. Returns + ``(scenes, words, total_duration, spliced)``; when nothing is injected the + inputs are returned unchanged and ``spliced`` is False (caller must then + copy the narration verbatim). + """ + splice_fn = splice_fn or splice_silence + inject_idx = _find_reflection_boundary(remotion_scenes) + if inject_idx is None: + return remotion_scenes, words, total_duration, False + + dur = REFLECTION_DURATION + # Single source of truth for the boundary: scene shift, word shift and + # audio splice all use inject_t. + inject_t = remotion_scenes[inject_idx - 1].get("end_time", 0) + reflection_scene = { + "narration": "", + "start_time": inject_t, + "end_time": inject_t + dur, + "mood": "dark", + "visual_treatment": "standard", + "intent_music_volume_base": 1.15, + "intent_scene_energy": 0.1, + "narrative_position": "ending", + "narrative_function": "breathing_room", + "is_synthetic": True, + "is_breathing_room": True, + "words": [], + "ai_image": remotion_scenes[inject_idx - 1].get("ai_image", ""), + "image_url": "", + } + # Build shifted copies first — nothing is mutated until the splice succeeds. + new_scenes = list(remotion_scenes[:inject_idx]) + [reflection_scene] + [ + {**s, "start_time": s.get("start_time", 0) + dur, "end_time": s.get("end_time", 0) + dur} + for s in remotion_scenes[inject_idx:] + ] + new_words = [ + {**w, "start": w.get("start", 0) + dur, "end": w.get("end", 0) + dur} + if w.get("start", 0) >= inject_t else w + for w in words + ] + + if not splice_fn(src_audio, dst_audio, inject_t, dur): + logger.warning("[Convert] Reflection scene NOT injected — narration splice failed " + "(keeping audio/caption timing unshifted)") + return remotion_scenes, words, total_duration, False + + logger.info(f"[Convert] ✓ Injected {dur}s reflection scene at act3→ending boundary " + f"(index {inject_idx}, t={inject_t:.3f}s; {dur}s silence spliced into narration)") + return new_scenes, new_words, total_duration + dur, True + def align_scenes_to_words(n_scenes, words, total_duration, scene_word_ranges=None): """Distribute scenes aligned to actual word timestamps. @@ -124,43 +274,16 @@ def run_convert(manifest, audio_data, topic="", era=""): shutil.copy2(src, dest) ai_image_name = src.name - # Ambient sound per scene mood — try Epidemic API first, fallback to local - ambient_file = None - if os.getenv("EPIDEMIC_SOUND_API_KEY"): - try: - from media.epidemic_sfx_manager import get_ambient_for_scene - ambient_file = get_ambient_for_scene(scene) or None - except Exception: - pass - if not ambient_file: - try: - from scripts.setup_ambience import get_ambient_file - ambient_file = get_ambient_file( - scene.get("mood", "dark"), - location=scene.get("location", ""), - visual_desc=scene.get("visual_description", ""), - ) or None - except Exception: - pass - - # SFX one-shot per scene (only on key moments) — try Epidemic API first + # Ambient sound per scene mood — via providers.sfx (built-ins: Epidemic API → local) + ambient_file = select_scene_ambient(scene, REMOTION_PUBLIC) + + # SFX one-shot per scene (only on key moments) — via providers.sfx sfx_file = None sfx_start_offset = 0 try: from scripts.setup_sfx import should_play_sfx if should_play_sfx(scene): - if os.getenv("EPIDEMIC_SOUND_API_KEY"): - try: - from media.epidemic_sfx_manager import get_sfx_for_scene - sfx_file = get_sfx_for_scene(scene) or None - except Exception: - pass - if not sfx_file: - try: - from scripts.setup_sfx import get_sfx_file - sfx_file = get_sfx_file(scene.get("mood", "dramatic")) or None - except Exception: - pass + sfx_file = select_scene_sfx(scene, REMOTION_PUBLIC) # Align SFX to reveal word timestamp if available if sfx_file and scene.get("is_reveal_moment") and words: scene_words = [w for w in words @@ -245,47 +368,8 @@ def run_convert(manifest, audio_data, topic="", era=""): split_scenes.append(scene) remotion_scenes = split_scenes - # Select background music — try smart selection first, fall back to random, then local files - music_file = None - music_start_offset = 0 - try: - from media import music_manager - smart_result = music_manager.get_smart_music_for_video(remotion_scenes, total_duration) - if smart_result: - music_file = smart_result["music_file"] - music_start_offset = smart_result["music_start_offset"] - logger.info(f"[Convert] Smart music: {music_file} (offset={music_start_offset:.1f}s, " - f"corr={smart_result['correlation_score']:.3f})") - else: - music_file = music_manager.get_music_for_video(remotion_scenes, total_duration) - if music_file: - logger.info(f"[Convert] Background music (random): {music_file}") - except Exception as _music_err: - logger.warning(f"[Convert] Music manager unavailable: {_music_err}") - - # Fallback: local mood-mapped files - if not music_file: - mood_counts = {} - for s in remotion_scenes: - m = s.get("mood", "dark") - mood_counts[m] = mood_counts.get(m, 0) + 1 - dominant_mood = max(mood_counts, key=mood_counts.get) if mood_counts else "dark" - MOOD_MUSIC = { - "dark": "music/dark_01_scp_x1x.mp3", - "tense": "music/tense_01_stay_the_course.mp3", - "dramatic": "music/dramatic_01_strength_of_titans.mp3", - "cold": "music/cold_01_scp_x5x.mp3", - "reverent": "music/reverent_01_ancient_rite.mp3", - "wonder": "music/wonder_01_the_descent.mp3", - "warmth": "music/warmth_01_hearth_and_home.mp3", - "absurdity": "music/absurdity_01_scheming_weasel.mp3", - } - local_file = MOOD_MUSIC.get(dominant_mood, MOOD_MUSIC["dark"]) - if (REMOTION_PUBLIC / local_file).exists(): - music_file = local_file - logger.info(f"[Convert] Background music (local): {music_file} (mood: {dominant_mood})") - else: - logger.warning("[Convert] No background music available") + # Select background music — providers.music first, then local library fallbacks + music_file, music_start_offset = select_background_music(remotion_scenes, total_duration, REMOTION_PUBLIC) # Attempt track adaptation (exact duration + stems) if API available music_adapted = False @@ -302,8 +386,15 @@ def run_convert(manifest, audio_data, topic="", era=""): if _music_track_id: try: from media.track_adapter import adapt_to_duration + # The reflection beat (injected later) lengthens the video; fit the + # track to the final length so a non-looping adapted track doesn't + # end early. If injection is later skipped the track is merely + # trimmed by the render. + _music_target = total_duration + ( + REFLECTION_DURATION if _find_reflection_boundary(remotion_scenes) is not None else 0.0 + ) adapted = adapt_to_duration( - _music_track_id, total_duration, + _music_track_id, _music_target, scenes=remotion_scenes, download_stems=True, ) @@ -439,49 +530,25 @@ def run_convert(manifest, audio_data, topic="", era=""): f"[Pacing] Scene {_i} ({_fn}) is {_dur:.1f}s — below 25s documentary target" ) - # Inject synthetic reflection scene at act3→ending boundary + # Inject synthetic reflection scene at act3→ending boundary. + # The Remotion narration copy gets the same silence spliced in at the same + # timestamp; if the splice fails the injection is abandoned (no shift), so + # audio, captions and scene cuts can never drift apart. + narration_src = MEDIA_DIR / "narration.mp3" + narration_dst = REMOTION_PUBLIC / "narration.mp3" + narration_spliced = False try: - inject_idx = None - for idx in range(len(remotion_scenes) - 1): - curr_pos = remotion_scenes[idx].get("narrative_position", "") - next_pos = remotion_scenes[idx + 1].get("narrative_position", "") - if curr_pos == "act3" and next_pos == "ending": - inject_idx = idx + 1 - break - if inject_idx is not None: - act3_end_time = remotion_scenes[inject_idx - 1].get("end_time", 0) - reflection_scene = { - "narration": "", - "start_time": act3_end_time, - "end_time": act3_end_time + 3.0, - "mood": "dark", - "visual_treatment": "standard", - "intent_music_volume_base": 1.15, - "intent_scene_energy": 0.1, - "narrative_position": "ending", - "narrative_function": "breathing_room", - "is_synthetic": True, - "is_breathing_room": True, - "words": [], - "ai_image": remotion_scenes[inject_idx - 1].get("ai_image", ""), - "image_url": "", - } - remotion_scenes.insert(inject_idx, reflection_scene) - # Shift subsequent scene start times by 3.0s - for s in remotion_scenes[inject_idx + 1:]: - s["start_time"] = s.get("start_time", 0) + 3.0 - s["end_time"] = s.get("end_time", 0) + 3.0 - total_duration += 3.0 + (_new_scenes, _new_words, _new_total, + narration_spliced) = _inject_reflection_scene( + remotion_scenes, words, total_duration, narration_src, narration_dst) + if narration_spliced: + # Commit (plain assignments only — cannot partially fail) + remotion_scenes = _new_scenes + words[:] = _new_words # in place: video_data["word_timestamps"] is this list + total_duration = _new_total # +REFLECTION_DURATION, applied exactly once video_data["total_duration_seconds"] = total_duration - # Shift word timestamps that fall at or after the injection boundary - # so captions stay in sync with their shifted scenes - reflection_dur = 3.0 - for w in words: - if w.get("start", 0) >= act3_end_time: - w["start"] = w.get("start", 0) + reflection_dur - w["end"] = w.get("end", 0) + reflection_dur - logger.info(f"[Convert] ✓ Injected {reflection_dur}s reflection scene at act3→ending boundary (index {inject_idx})") except Exception as _refl_err: + narration_spliced = False logger.warning(f"[Convert] Reflection scene injection skipped: {_refl_err}") # Compute film grain and vignette intensity per scene @@ -583,8 +650,11 @@ def run_convert(manifest, audio_data, topic="", era=""): with open(vd_path, "w") as f: json.dump(video_data, f, indent=2) - # Copy audio - shutil.copy2(MEDIA_DIR / "narration.mp3", REMOTION_PUBLIC / "narration.mp3") + # Copy audio (unless the reflection splice already wrote the Remotion copy). + # The source narration in MEDIA_DIR is never modified, so re-running + # convert always rebuilds the Remotion copy from the pristine source. + if not narration_spliced: + shutil.copy2(narration_src, narration_dst) scenes_with_images = sum(1 for s in remotion_scenes if s.get("ai_image")) logger.info(f"[Convert] ✓ {len(remotion_scenes)} scenes, {len(words)} words, {scenes_with_images} images") diff --git a/pipeline/images.py b/pipeline/images.py index 167edbe..72b0fb4 100644 --- a/pipeline/images.py +++ b/pipeline/images.py @@ -5,9 +5,6 @@ import time import shutil import os -import sys -import subprocess -import random from pathlib import Path from datetime import datetime @@ -171,34 +168,60 @@ def _apply_color_harmonization(scenes: list, palette: list[str]): logger.info(f"[Images] Color harmonization: {harmonized} images tinted toward palette") -def _fal_subscribe_with_retry(model: str, arguments: dict, label: str = "fal.ai", - max_attempts: int = 5, backoff_base: int = 2): - """Call fal_client.subscribe with exponential-backoff retries. +# Back-compat re-export: the fal retry helper now lives in the fal provider. +from providers.images.fal import _fal_subscribe_with_retry # noqa: E402,F401 - Retries on connection errors, timeouts, and rate limits (up to max_attempts). - """ - import fal_client - for attempt in range(1, max_attempts + 1): - try: - return fal_client.subscribe(model, arguments=arguments) - except Exception as e: - err_str = str(e).lower() - is_retryable = any(kw in err_str for kw in [ - "timeout", "timed out", "connection", "rate limit", "429", - "502", "503", "504", "overloaded", "temporarily", - ]) - if attempt == max_attempts or not is_retryable: - logger.error(f" [{label}] Failed after {attempt} attempt(s): {e}") - raise - _rng = random.Random() - wait = (backoff_base ** attempt) + _rng.uniform(0, backoff_base ** attempt * 0.5) - logger.warning(f" [{label}] Attempt {attempt}/{max_attempts} failed ({type(e).__name__}: {e}), retrying in {wait:.1f}s...") - time.sleep(wait) - - -def _score_image(image_path) -> int: - """Score image quality 1-10 using Claude Haiku vision. Returns 0 on failure.""" +def _get_image_provider(): + from providers.registry import get_provider + return get_provider("images") + + +def _image_cost_service(provider) -> str: + """Cost-tracker service key for the active image provider.""" + try: + from providers.registry import get_provider_name + name = get_provider_name("images") + except Exception: + name = getattr(provider, "name", "images") + return "fal_ai" if name == "fal" else name + + +def _log_image_cost(provider) -> None: + try: + from core.cost_tracker import get_active_run_id, log_cost + _rid = get_active_run_id() + if _rid: + log_cost(_rid, "images", _image_cost_service(provider), 1, "images") + except Exception: + pass + + +def _place_image(src, dest) -> None: + """Copy a provider's output image to dest (and drop provider temp files).""" + import tempfile + src = Path(src) + shutil.copyfile(src, dest) + try: + if Path(tempfile.gettempdir()).resolve() in src.resolve().parents: + src.unlink(missing_ok=True) + except Exception: + pass + + +def _score_image(image_path) -> int | None: + """Score image quality 1-10 using Claude Haiku vision. + + Returns None when scoring is unavailable (LLM provider isn't the built-in + Anthropic one, no ANTHROPIC_API_KEY, or the call failed) — callers then + accept the image instead of regenerating it. + """ + try: + from clients.claude_client import anthropic_vision_available + if not anthropic_vision_available(): + return None + except Exception: + return None try: from clients.claude_client import client as _vc, track_usage with open(image_path, "rb") as f: @@ -215,9 +238,9 @@ def _score_image(image_path) -> int: except Exception: pass match = re.search(r'\d+', resp.content[0].text) - return int(match.group()) if match else 0 + return int(match.group()) if match else None except Exception: - return 0 + return None def _ensure_min_resolution(image_path, min_width: int = 1920, min_height: int = 1080) -> bool: @@ -269,13 +292,13 @@ def _sharpen_for_video(image_path) -> bool: def _generate_single_image(idx, scene, total_scenes, assets_dir, image_model, mood_light, style_recraft, style_flux, character_portraits=None, visual_bible=None, - era_constraints=None): + era_constraints=None, image_provider=None): """Generate + quality-score one scene image. Thread-safe: unique file path per scene. + Images come from the configured ImageProvider (providers.images). Returns (idx, updated_scene, success). """ import copy - from pipeline.helpers import download_file _thread = threading.current_thread().name scene = copy.deepcopy(scene) img_path = assets_dir / f"scene_{idx:03d}_ai.jpg" @@ -382,8 +405,10 @@ def _generate_single_image(idx, scene, total_scenes, assets_dir, image_model, logger.info(f" [{_thread}] Scene {idx+1}/{total_scenes} ({mood})...") + provider = image_provider or _get_image_provider() best_score = 0 best_path = None + scoring_available = True for attempt in range(IMAGE_MAX_RETRIES): if _shutdown_event.is_set(): scene["ai_image"] = None @@ -391,69 +416,44 @@ def _generate_single_image(idx, scene, total_scenes, assets_dir, image_model, attempt_path = assets_dir / f"scene_{idx:03d}_ai_attempt{attempt}.jpg" if attempt > 0 else img_path - # Check for character portrait → Kontext Pro routing - _used_kontext = False + # Character portrait → reference-image generation (e.g. fal Kontext Pro) + _used_reference = False characters = scene.get("characters_mentioned", []) - if character_portraits and characters: + if character_portraits and characters and getattr(provider, "supports_reference_images", False): for c in characters: if c in character_portraits: portrait_path = character_portraits[c] if Path(portrait_path).exists(): try: - with open(portrait_path, "rb") as _pf: - ref_b64 = base64.b64encode(_pf.read()).decode() kontext_prompt = ( f"Transform this into: {subject}, {era_context}{composition_hint}" f"{era_positive}{palette_hint}{mood_light.get(mood, 'deep shadows')}. " f"Keep the character's face, build, and clothing identical. {cur_style}{era_negative}" ) - result = _fal_subscribe_with_retry("fal-ai/flux-pro/kontext", { - "image_url": f"data:image/jpeg;base64,{ref_b64}", - "prompt": kontext_prompt, - "aspect_ratio": "16:9", - "num_images": 1, - "safety_tolerance": "2", - }, label=f"{_thread} scene {idx+1} kontext") - _used_kontext = True + out = provider.generate_with_reference( + kontext_prompt, Path(portrait_path), width=1920, height=1080) + _place_image(out, attempt_path) + _used_reference = True except Exception as kontext_err: - logger.warning(f" [{_thread}] Kontext Pro failed, falling back: {kontext_err}") - _used_kontext = False + logger.warning(f" [{_thread}] Reference-image generation failed, falling back: {kontext_err}") + _used_reference = False break - if not _used_kontext: - if image_model == "recraft": - result = _fal_subscribe_with_retry("fal-ai/recraft/v3/text-to-image", { - "prompt": prompt, - "image_size": {"width": 2560, "height": 1440}, - "num_images": 1, - "style": "digital_illustration", - }, label=f"{_thread} scene {idx+1}") - else: - result = _fal_subscribe_with_retry("fal-ai/flux-pro/v1.1-ultra", { - "prompt": prompt, - "aspect_ratio": "16:9", - "num_images": 1, - "safety_tolerance": "2", - }, label=f"{_thread} scene {idx+1}") - - images = result.get("images", []) - if not images: - raise ValueError("fal.ai returned empty images list") - url = images[0]["url"] - download_file(url, attempt_path) + if not _used_reference: + out = provider.generate(prompt, style=image_model, width=1920, height=1080) + _place_image(out, attempt_path) logger.info(f" [{_thread}] {attempt_path.name} ({attempt_path.stat().st_size//1024}KB)") - # Log fal.ai cost (per image generated, including retries) - try: - from core.cost_tracker import get_active_run_id, log_cost - _rid = get_active_run_id() - if _rid: - log_cost(_rid, "images", "fal_ai", 1, "images") - except Exception: - pass + # Log image cost (per image generated, including retries) + _log_image_cost(provider) - # Quality gate via Claude Haiku vision + # Quality gate via Claude Haiku vision (None = scoring unavailable) _score = _score_image(attempt_path) + if _score is None: + scoring_available = False + best_path = attempt_path + logger.info(f" [{_thread}] Quality scoring unavailable — accepting image") + break logger.info(f" [{_thread}] Quality: {_score}/10 {'OK' if _score >= IMAGE_QUALITY_THRESHOLD else 'below threshold'}") @@ -487,7 +487,7 @@ def _generate_single_image(idx, scene, total_scenes, assets_dir, image_model, for att in assets_dir.glob(f"scene_{idx:03d}_ai_attempt*.jpg"): att.unlink(missing_ok=True) - if best_score < IMAGE_QUALITY_THRESHOLD: + if scoring_available and best_score < IMAGE_QUALITY_THRESHOLD: logger.warning(f" [{_thread}] Best quality {best_score}/10 — below {IMAGE_QUALITY_THRESHOLD} threshold but using anyway") # Safety net: upscale final image if still undersized after all retries @@ -501,7 +501,7 @@ def _generate_single_image(idx, scene, total_scenes, assets_dir, image_model, return (idx, scene, True) except Exception as e: - logger.error(f" [{_thread}] fal.ai failed for scene {idx+1}: {e}") + logger.error(f" [{_thread}] Image generation failed for scene {idx+1}: {e}") # Wikimedia fallback visual = scene.get("visual", {}) wiki_url = visual.get("url", "") if isinstance(visual, dict) else "" @@ -535,13 +535,19 @@ def _generate_single_image(idx, scene, total_scenes, assets_dir, image_model, return (idx, scene, False) -def _generate_character_portraits(visual_bible, scenes, assets_dir): - """Generate reference portraits for top characters using FLUX Pro Ultra. Returns {name: path}.""" - from pipeline.helpers import download_file +def _generate_character_portraits(visual_bible, scenes, assets_dir, image_provider=None): + """Generate reference portraits for top characters. Returns {name: path}. + + Only used when the image provider supports reference images (the built-in + fal provider does, via Kontext Pro); otherwise portraits would be unused. + """ char_descs = visual_bible.get("character_descriptions", {}) art_style = visual_bible.get("art_style", "") if not char_descs: return {} + provider = image_provider or _get_image_provider() + if not getattr(provider, "supports_reference_images", False): + return {} # Count character appearances to prioritize char_counts = {} @@ -562,29 +568,16 @@ def _generate_character_portraits(visual_bible, scenes, assets_dir): f"Portrait of {desc}, neutral studio background, " f"front-facing 3/4 view, {art_style}, no text, no watermarks" ) - result = _fal_subscribe_with_retry("fal-ai/flux-pro/v1.1-ultra", { - "prompt": portrait_prompt, - "aspect_ratio": "1:1", - "num_images": 1, - }, label=f"portrait_{slug}") - - images = result.get("images", []) - if images: - download_file(images[0]["url"], portrait_path) - try: - from core.cost_tracker import get_active_run_id, log_cost - _rid = get_active_run_id() - if _rid: - log_cost(_rid, "images", "fal_ai", 1, "images") - except Exception: - pass - score = _score_image(portrait_path) - if score >= 7: - portraits[char_name] = str(portrait_path) - logger.info(f"[Portraits] {char_name}: {score}/10") - else: - logger.warning(f"[Portraits] {char_name} scored {score}/10 — skipping") - portrait_path.unlink(missing_ok=True) + out = provider.generate(portrait_prompt, style="flux", width=1024, height=1024) + _place_image(out, portrait_path) + _log_image_cost(provider) + score = _score_image(portrait_path) + if score is None or score >= 7: + portraits[char_name] = str(portrait_path) + logger.info(f"[Portraits] {char_name}: {score if score is not None else 'unscored'}/10") + else: + logger.warning(f"[Portraits] {char_name} scored {score}/10 — skipping") + portrait_path.unlink(missing_ok=True) except Exception as e: logger.error(f"[Portraits] Failed for {char_name}: {e}") @@ -592,18 +585,15 @@ def _generate_character_portraits(visual_bible, scenes, assets_dir): def run_images(manifest): - try: - import fal_client # noqa: F401 - except ImportError: - logger.info("[Images] Installing fal-client...") - subprocess.run([sys.executable, "-m", "pip", "install", "fal-client"], check=True) + from providers.registry import get_provider_name + provider_name = get_provider_name("images") + if provider_name == "fal": + from providers.images.fal import FalProvider + if not FalProvider.has_credentials(): + logger.warning("[Images] WARNING: FAL_API_KEY not set — skipping image generation") + return manifest + image_provider = _get_image_provider() - FAL_KEY = os.getenv("FAL_API_KEY") - if not FAL_KEY: - logger.warning("[Images] WARNING: FAL_API_KEY not set — skipping image generation") - return manifest - - os.environ["FAL_KEY"] = FAL_KEY scenes = manifest.get("scenes", []) # Detect historical era for visual constraints @@ -613,7 +603,7 @@ def run_images(manifest): # Generate character reference portraits if visual bible is available visual_bible = manifest.get("visual_bible", {}) - character_portraits = _generate_character_portraits(visual_bible, scenes, ASSETS_DIR) if visual_bible else {} + character_portraits = _generate_character_portraits(visual_bible, scenes, ASSETS_DIR, image_provider) if visual_bible else {} if character_portraits: logger.info(f"[Images] Character portraits generated: {len(character_portraits)}") @@ -640,7 +630,10 @@ def run_images(manifest): "absurdity":"bright incongruous lighting, slightly surreal color palette, vivid saturated tones that feel dreamlike and impossible", } - logger.info(f"[Images] Using model: {IMAGE_MODEL} ({'Recraft v3' if IMAGE_MODEL == 'recraft' else 'Flux Pro Ultra'})") + if provider_name == "fal": + logger.info(f"[Images] Using model: {IMAGE_MODEL} ({'Recraft v3' if IMAGE_MODEL == 'recraft' else 'Flux Pro Ultra'})") + else: + logger.info(f"[Images] Using image provider: {provider_name} ({image_provider.name})") generated = 0 # Pre-filter cached scenes (synchronous) @@ -664,7 +657,7 @@ def run_images(manifest): fut = pool.submit( _generate_single_image, idx, scene, len(scenes), ASSETS_DIR, IMAGE_MODEL, MOOD_LIGHT, STYLE_RECRAFT, STYLE_FLUX, - character_portraits, visual_bible, era_constraints, + character_portraits, visual_bible, era_constraints, image_provider, ) futures[fut] = idx @@ -702,7 +695,7 @@ def run_images(manifest): raise Exception( f"[Images] ABORT: Only {scenes_with_images}/{total_scenes} scenes have images " f"({scenes_with_images/total_scenes*100:.0f}%). Pipeline would produce unwatchable video. " - f"Check fal.ai API key and quota, then retry with --from-stage 10" + f"Check {image_provider.name} API key and quota, then retry with --from-stage 10" ) # Write image attribution audit log @@ -719,7 +712,7 @@ def run_images(manifest): wiki_img = scene.get("wikimedia_url", "") or scene.get("footage_url", "") if ai_img and "ai.jpg" in str(ai_img): - entry["source"] = "fal.ai (AI generated)" + entry["source"] = f"{image_provider.name} (AI generated)" entry["license"] = "AI-generated, no copyright" elif wiki_img and "wikimedia" in str(wiki_img).lower(): entry["source"] = "Wikimedia Commons" diff --git a/pipeline/phase_post.py b/pipeline/phase_post.py index 7bd0e16..7e552be 100644 --- a/pipeline/phase_post.py +++ b/pipeline/phase_post.py @@ -62,7 +62,11 @@ def do_upload(): thumbnail_path=_thumbnail_path, ) - runner.run_stage(13, "YouTube Upload", do_upload) + runner.run_stage(13, "Upload", do_upload) + _on_youtube = _uploaded_to_youtube(ctx) + if not _on_youtube: + logger.info("[Pipeline] Upload provider is not YouTube — skipping YouTube analytics, " + "comment analysis, param observation and community posts") # ── Record topic as covered ─────────────────────────────────────────────── _record_topic(ctx) @@ -70,19 +74,22 @@ def do_upload(): # ── Save to Supabase ────────────────────────────────────────────────────── _save_to_supabase(ctx) - # ── Store param observation ─────────────────────────────────────────────── - _store_param_observation(ctx) + # ── Store param observation (needs YouTube retention data) ──────────────── + if _on_youtube: + _store_param_observation(ctx) - # ── Analytics agent ─────────────────────────────────────────────────────── - try: - a12 = load_agent(Path("12_analytics_agent.py")) - logger.info("[Pipeline] Running analytics agent...") - a12.run() - except Exception as analytics_err: - logger.warning(f"[Pipeline] Analytics agent warning: {analytics_err}") + # ── Analytics agent (reads YouTube stats) ──────────────────────────────── + if _on_youtube: + try: + a12 = load_agent(Path("12_analytics_agent.py")) + logger.info("[Pipeline] Running analytics agent...") + a12.run() + except Exception as analytics_err: + logger.warning(f"[Pipeline] Analytics agent warning: {analytics_err}") # ── Comment analysis ────────────────────────────────────────────────────── - _run_comment_analysis(ctx) + if _on_youtube: + _run_comment_analysis(ctx) # ── Localization (opt-in) ───────────────────────────────────────────────── if os.getenv("LOCALIZATION_ENABLED", "").lower() == "true": @@ -129,7 +136,8 @@ def do_upload(): pass # ── Community engagement ────────────────────────────────────────────────── - _run_community_engagement(ctx) + if _on_youtube: + _run_community_engagement(ctx) # ── Quality checks ──────────────────────────────────────────────────────── _run_quality_report(ctx) @@ -159,6 +167,25 @@ def do_upload(): # Helper functions (extracted from inline code) # ══════════════════════════════════════════════════════════════════════════════ +def _uploaded_to_youtube(ctx: PipelineContext) -> bool: + """True if stage 13 published to YouTube. + + Stage 13 results record the upload provider under "provider"; results + without it predate the provider system, when uploads always went to YouTube. + """ + res = ctx.state.get("stage_13") or {} + if not isinstance(res, dict): + return False + return res.get("provider", "youtube") == "youtube" + + +def _youtube_id(ctx: PipelineContext) -> str: + """Stage 13 video id if it is a YouTube id, else "".""" + res = ctx.state.get("stage_13") or {} + if not isinstance(res, dict) or not _uploaded_to_youtube(ctx): + return "" + return res.get("video_id", "") + def _run_predictive_scoring(ctx: PipelineContext) -> None: try: insights_file = BASE_DIR / "channel_insights.json" @@ -260,12 +287,11 @@ def _record_topic(ctx: PipelineContext) -> None: from server import topic_store angle_data = ctx.angle or ctx.state.get("stage_2", {}) or {} seo_data = ctx.seo or ctx.state.get("stage_6", {}) or {} - upload_res = ctx.state.get("stage_13", {}) or {} topic_store.record_topic( topic=ctx.topic, angle=angle_data.get("unique_angle", angle_data.get("chosen_angle", "")), title=seo_data.get("recommended_title", ctx.topic), - youtube_id=upload_res.get("video_id", ""), + youtube_id=_youtube_id(ctx), ) except Exception as ts_err: logger.warning(f"[Pipeline] topic_store warning: {ts_err}") @@ -302,8 +328,8 @@ def _save_to_supabase(ctx: PipelineContext) -> None: supabase_client.save_video( topic=ctx.topic, title=seo_data.get("recommended_title", ctx.topic), - youtube_url=upload_result.get("url", ""), - youtube_id=upload_result.get("video_id", ""), + youtube_url=upload_result.get("url", "") if _uploaded_to_youtube(ctx) else "", + youtube_id=_youtube_id(ctx), script_path=ctx.state.get("script_path", ""), video_path="", duration_seconds=audio_saved.get("total_duration_seconds", 0), diff --git a/pipeline/phase_prod.py b/pipeline/phase_prod.py index 2af5e46..30988c5 100644 --- a/pipeline/phase_prod.py +++ b/pipeline/phase_prod.py @@ -40,7 +40,7 @@ def run_production_phase(ctx: PipelineContext, runner: StageRunner) -> tuple: shorts_future, shorts_executor = _launch_shorts(ctx, runner) # ══════════════════════════════════════════════════════════════════════════ - # Wave 1: SEO(6) + Scenes(7) + Compliance in parallel + # Wave 1: Compliance, then SEO(6) + Scenes(7) in parallel # ══════════════════════════════════════════════════════════════════════════ _run_wave1(ctx, runner) @@ -154,8 +154,11 @@ def do_short_upload(): if candidate.exists(): thumb = str(candidate) break + from providers.registry import get_provider_name + if get_provider_name("upload") != "youtube": + return a11.upload_with_provider(video_path, _title, _description, _tags, thumb) return a11.upload_video(video_path, _title, _description, _tags, - thumbnail_path=thumb, privacy="public") + thumbnail_path=thumb, privacy=a11._youtube_privacy("public")) _short_upload_result = runner.run_short_stage("short_upload", "Short Upload", do_short_upload) @@ -262,32 +265,52 @@ def _run_compliance(ctx: PipelineContext, runner: StageRunner): return None +def _apply_compliance(ctx: PipelineContext, runner: StageRunner, compliance_result) -> None: + """Apply compliance result to ctx.script, enforce the RED gate, persist. + + Must run BEFORE Scene Breakdown (stage 7): scene-aware audio narrates each + scene's ``narration`` field from stage 7, so the safe wording has to be in + ctx.script before stage 7 splits it into scenes. + """ + # Fatal gate: RED compliance with no auto-fix + if compliance_result and compliance_result.get("risk") == "red": + if not compliance_result.get("safe_script"): + raise RuntimeError( + f"Compliance gate FAILED: {len(compliance_result.get('flags', []))} RED flags " + "could not be auto-fixed. Review flagged content before proceeding." + ) + + # Apply compliance modifications to script (must happen before Scenes + TTS) + if compliance_result and compliance_result.get("safe_script") and ctx.script: + ctx.script["full_script"] = compliance_result["safe_script"] + # Persist the patched script back into stage_4 so a resume (which + # reloads ctx.script from state["stage_4"]) never sees the unpatched text. + runner.mark(4, ctx.script) + runner.mark_metadata("compliance", { + "risk_level": compliance_result.get("risk", "red"), + "flag_count": len(compliance_result.get("flags", [])), + "safe_script_applied": True, + }) + logger.info("[Compliance] Patched script persisted to stage_4 before Scene Breakdown") + + def _run_wave1(ctx: PipelineContext, runner: StageRunner) -> None: - """Wave 1 DAG: SEO(6) + Scenes(7) + Compliance in parallel.""" + """Wave 1: Compliance first (patches script), then SEO(6) + Scenes(7) in parallel.""" a06 = ctx.agents["a06"] a07 = ctx.agents["a07"] - logger.info("\n[Pipeline] \u2500\u2500 Parallel DAG Wave 1: SEO + Scenes + Compliance \u2500\u2500") - with ThreadPoolExecutor(max_workers=3, thread_name_prefix="dag_w1") as dag_pool: + # Compliance runs first (single LLM call) so SEO/Scenes see the safe script. + logger.info("\n[Pipeline] \u2500\u2500 Wave 1a: Compliance \u2500\u2500") + compliance_result = _run_compliance(ctx, runner) + _apply_compliance(ctx, runner, compliance_result) + + logger.info("\n[Pipeline] \u2500\u2500 Parallel DAG Wave 1b: SEO + Scenes \u2500\u2500") + with ThreadPoolExecutor(max_workers=2, thread_name_prefix="dag_w1") as dag_pool: seo_future = dag_pool.submit(runner.run_stage, 6, "SEO", a06.run, ctx.script, ctx.verification, ctx.angle) scenes_future = dag_pool.submit(runner.run_stage, 7, "Scene Breakdown", a07.run, ctx.script, ctx.verification) - compliance_future = dag_pool.submit(_run_compliance, ctx, runner) ctx.seo = seo_future.result() ctx.scenes_data = scenes_future.result() - compliance_result = compliance_future.result() - - # Apply compliance modifications to script (must happen before TTS) - if compliance_result and compliance_result.get("safe_script") and ctx.script: - ctx.script["full_script"] = compliance_result["safe_script"] - - # Fatal gate: RED compliance with no auto-fix - if compliance_result and compliance_result.get("risk") == "red": - if not compliance_result.get("safe_script"): - raise RuntimeError( - f"Compliance gate FAILED: {len(compliance_result.get('flags', []))} RED flags " - "could not be auto-fixed. Review flagged content before proceeding." - ) _check_and_warn(check_seo(ctx.seo or {}), "SEO") _check_and_warn(check_scenes(ctx.scenes_data or {}), "Scenes") @@ -351,8 +374,14 @@ def _run_wave2(ctx: PipelineContext, runner: StageRunner) -> None: try: formatted = a_tts_format.run(ctx.script) if formatted and formatted.get("full_script"): - # Display text uses full_script (original spellings for captions) - ctx.display_script = {**ctx.script, "full_script": formatted["full_script"]} + # Display text uses full_script with any phonetic respellings + # reverted (original spellings for captions). Respellings are + # single whitespace-free tokens, so word counts stay aligned + # with the TTS text and audio.py's positional caption override + # can map respelled words back to the original spelling. + _display_text = _undo_respellings( + formatted["full_script"], _pronunciation_map(a_tts_format)) + ctx.display_script = {**ctx.script, "full_script": _display_text} # TTS text uses tts_script if available (phonetic respellings), # falls back to full_script (no pronunciation changes) tts_text = formatted.get("tts_script", formatted["full_script"]) @@ -371,6 +400,79 @@ def _run_wave2(ctx: PipelineContext, runner: StageRunner) -> None: # Wave 3: Audio + Footage + Thumbnail # ══════════════════════════════════════════════════════════════════════════════ +def _pronunciation_map(a_tts_format) -> dict: + """Original term -> phonetic respelling, from the loaded TTS format agent. + + Only whitespace-free respellings are kept so a respelling never changes + the word count (captions are aligned positionally). + """ + pmap = getattr(a_tts_format, "PRONUNCIATION_MAP", None) if a_tts_format else None + if not isinstance(pmap, dict): + return {} + return { + k: v for k, v in pmap.items() + if isinstance(k, str) and isinstance(v, str) and k and v + and not any(c.isspace() for c in k + v) + } + + +def _undo_respellings(text: str, pron_map: dict) -> str: + """Revert phonetic respellings in ``text`` back to the original terms.""" + if not text or not pron_map: + return text + for term, respelling in sorted(pron_map.items(), key=lambda kv: -len(kv[1])): + text = text.replace(respelling, term) + return text + + +def _respell_scenes_for_audio(scenes_data, tts_script, pron_map: dict): + """Return a copy of scenes_data whose ``narration`` carries the same + phonetic respellings the TTS format agent applied to the TTS script. + + Scene-aware audio (>=3 narrated scenes) speaks scene["narration"], not the + TTS script, so without this the respellings only reached the legacy path. + Mirrors the agent: only terms it actually respelled, first occurrence only + (across all scenes in order), case-insensitive. The input is not mutated — + captions/display, footage and manifest keep the original spelling. + """ + if not pron_map or not isinstance(scenes_data, dict) or not scenes_data.get("scenes"): + return scenes_data + tts_text = (tts_script or {}).get("full_script", "") if isinstance(tts_script, dict) else "" + active = {t: r for t, r in pron_map.items() if r in tts_text} + if not active: + return scenes_data + + import re as _re + new_scenes = [dict(s) if isinstance(s, dict) else s for s in scenes_data["scenes"]] + applied = 0 + for term, respelling in active.items(): + pattern = _re.compile(_re.escape(term), _re.IGNORECASE) + for sc in new_scenes: + if not isinstance(sc, dict): + continue + narration = sc.get("narration") or "" + if pattern.search(narration): + sc["narration"] = pattern.sub(respelling, narration, count=1) + applied += 1 + break # first occurrence only + if not applied: + return scenes_data + logger.info(f"[TTS Format] {applied} pronunciation respelling(s) applied to scene narration (audio only)") + return {**scenes_data, "scenes": new_scenes} + + +def _audio_scene_input(ctx: PipelineContext): + """Scenes passed to stage 8: respelled narration for TTS, originals elsewhere.""" + try: + return _respell_scenes_for_audio( + ctx.scenes_data, ctx.tts_script, + _pronunciation_map(ctx.agents.get("a_tts_format")), + ) + except Exception as e: + logger.warning(f"[TTS Format] Scene respelling skipped (non-fatal): {e}") + return ctx.scenes_data + + def _generate_thumbnail_task(ctx: PipelineContext): """Generate thumbnail (runs in thread pool or sequentially).""" from agents import thumbnail_agent @@ -388,7 +490,7 @@ def _run_wave3(ctx: PipelineContext, runner: StageRunner) -> None: if _need_audio and _need_footage: logger.info("\n[Pipeline] Running Audio + Footage + Thumbnail in parallel...") with ThreadPoolExecutor(max_workers=3, thread_name_prefix="dag_w3") as executor: - _audio_future = executor.submit(runner.run_stage, 8, "Audio Production", run_audio, ctx.tts_script, ctx.scenes_data, ctx.display_script) + _audio_future = executor.submit(runner.run_stage, 8, "Audio Production", run_audio, ctx.tts_script, _audio_scene_input(ctx), ctx.display_script) _footage_future = executor.submit(runner.run_stage, 9, "Footage Hunting", a09.run, ctx.scenes_data or {}) _thumb_future = None @@ -407,7 +509,7 @@ def _run_wave3(ctx: PipelineContext, runner: StageRunner) -> None: except Exception as e: logger.warning(f"[Pipeline] Thumbnail agent warning: {e}") else: - ctx.audio_data = runner.run_stage(8, "Audio Production", run_audio, ctx.tts_script, ctx.scenes_data, ctx.display_script) + ctx.audio_data = runner.run_stage(8, "Audio Production", run_audio, ctx.tts_script, _audio_scene_input(ctx), ctx.display_script) ctx.footage_data = runner.run_stage(9, "Footage Hunting", a09.run, ctx.scenes_data or {}) if ctx.seo and not ctx.state.get("thumbnail"): try: diff --git a/pipeline/phase_setup.py b/pipeline/phase_setup.py index 2b617f5..47dc6b5 100644 --- a/pipeline/phase_setup.py +++ b/pipeline/phase_setup.py @@ -285,8 +285,21 @@ def load_agents(ctx: PipelineContext) -> None: def run_credit_checks(ctx: PipelineContext) -> None: - """Anthropic + ElevenLabs credit gates. Replaces lines 267-314.""" - # Anthropic + """LLM (Anthropic only) + TTS provider credit gates.""" + from providers.registry import get_provider_name + + try: + _llm_name = get_provider_name("llm") + except Exception: + _llm_name = "anthropic" + if _llm_name == "anthropic": + _check_anthropic_credits() + else: + logger.info(f"[LLM] Provider '{_llm_name}' — skipping Anthropic credit check") + _check_tts_credits() + + +def _check_anthropic_credits() -> None: try: import anthropic as _anth _test_client = _anth.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) @@ -310,33 +323,34 @@ def run_credit_checks(ctx: PipelineContext) -> None: ) logger.warning(f"[Anthropic] Credit check warning: {e}") - # ElevenLabs - elevenlabs_key = os.getenv("ELEVENLABS_API_KEY") - if elevenlabs_key: - try: - import requests as _req - r = _req.get( - "https://api.elevenlabs.io/v1/user", - headers={"xi-api-key": elevenlabs_key}, - timeout=10, + +def _check_tts_credits() -> None: + """Ask the configured TTS provider for its quota (generic check_credits()). + + Providers raise on an invalid key (e.g. ElevenLabs 401) — that aborts the + run early. Anything else is a warning. limit <= 0 means "unknown / no + quota endpoint" and is not logged. + """ + label = "TTS" + try: + from providers.registry import get_provider + tts = get_provider("tts") + label = getattr(tts, "name", "TTS") + credits = tts.check_credits() or {} + limit = credits.get("limit", 0) or 0 + remaining = credits.get("remaining", 0) or 0 + if limit > 0: + unit = credits.get("unit", "characters") + unit = "chars" if unit == "characters" else unit + used_pct = ((limit - remaining) / limit) * 100 + logger.info( + f"[{label}] Credits: {remaining:,} {unit} remaining ({used_pct:.0f}% used)" + + (" — overage active" if remaining < 0 else "") ) - if r.status_code == 200: - sub = json.loads(r.text, strict=False).get("subscription", {}) - limit = sub.get("character_limit", 1) - used = sub.get("character_count", 0) - remaining = limit - used - used_pct = (used / limit) * 100 if limit > 0 else 0 - overage = remaining < 0 - logger.info( - f"[ElevenLabs] Credits: {remaining:,} chars remaining ({used_pct:.0f}% used)" - + (" — overage active" if overage else "") - ) - elif r.status_code == 401: - raise Exception("ElevenLabs API key invalid or quota exceeded (401)") - except Exception as e: - if "401" in str(e): - raise - logger.warning(f"[ElevenLabs] Credit check warning: {e}") + except Exception as e: + if "401" in str(e): + raise + logger.warning(f"[{label}] Credit check warning: {e}") def run_topic_dedup(ctx: PipelineContext) -> None: diff --git a/pipeline/shorts.py b/pipeline/shorts.py index 3a1a147..0639561 100644 --- a/pipeline/shorts.py +++ b/pipeline/shorts.py @@ -3,7 +3,6 @@ import json import re import time -import base64 import shutil import subprocess import hashlib @@ -13,23 +12,23 @@ from core.paths import MEDIA_DIR, ASSETS_DIR, CHUNKS_DIR, REMOTION_SRC, REMOTION_PUBLIC, OUTPUT_DIR, BASE_DIR from core.log import get_logger from pipeline.helpers import clean_script -from pipeline.images import _fal_subscribe_with_retry from pipeline.render import validate_video_ffprobe logger = get_logger(__name__) def run_short_audio(short_script_data): - import requests try: from mutagen.mp3 import MP3 except ImportError: subprocess.run([sys.executable, "-m", "pip", "install", "mutagen"], check=True) from mutagen.mp3 import MP3 - ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY") - from core.pipeline_config import NARRATOR_VOICE_ID, AUDIO_CHUNK_MAX_CHARS - VOICE_ID = NARRATOR_VOICE_ID + # TTS goes through the configured provider (providers.tts in obsidian.yaml) + from providers.registry import get_provider + from pipeline.audio import synthesize_chunk + tts = get_provider("tts") + from core.pipeline_config import AUDIO_CHUNK_MAX_CHARS MAX_CHARS = AUDIO_CHUNK_MAX_CHARS # Voice presets — build from overrides (never mutate originals, Fix 144) @@ -62,44 +61,6 @@ def split_chunks(text): chunks.append(current.strip()) return chunks - def generate_chunk(text, voice_settings=None, speed=None): - if voice_settings is None: - voice_settings = VOICE_BODY - if speed is None: - speed = VOICE_SPEED - url = f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE_ID}/with-timestamps" - headers = {"xi-api-key": ELEVENLABS_API_KEY, "Content-Type": "application/json"} - payload = { - "text": text, "model_id": "eleven_v3", - "voice_settings": voice_settings, - "speed": speed, - } - last_err = None - for attempt in range(5): - try: - r = requests.post(url, headers=headers, json=payload, timeout=120) - except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as ce: - wait = 30 * (attempt + 1) - logger.warning(f" Connection/timeout error, waiting {wait}s...") - time.sleep(wait) - last_err = ce - continue - if r.status_code == 200: - return json.loads(r.text, strict=False) - elif r.status_code == 429: - wait = 60 * (attempt + 1) - logger.warning(f" Rate limited, waiting {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - last_err = Exception("ElevenLabs rate limit (429)") - elif r.status_code in (500, 502, 503): - wait = 30 * (attempt + 1) - logger.warning(f" Server error ({r.status_code}), waiting {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - last_err = Exception(f"ElevenLabs server error ({r.status_code})") - else: - raise Exception(f"ElevenLabs {r.status_code}: {r.text[:200]}") - raise last_err or Exception("Failed after 5 attempts") - SHORT_CHUNKS_DIR = CHUNKS_DIR / "short" SHORT_CHUNKS_DIR.mkdir(parents=True, exist_ok=True) @@ -133,35 +94,8 @@ def generate_chunk(text, voice_settings=None, speed=None): vs = VOICE_HOOK if i == 0 else VOICE_BODY spd = HOOK_SPEED if i == 0 else VOICE_SPEED # hook slightly faster for punch logger.info(f"[Short Audio] Chunk {i+1}/{len(chunks)}: generating ({len(chunk)} chars)...") - data = generate_chunk(chunk, voice_settings=vs, speed=spd) - audio_bytes = base64.b64decode(data.get("audio_base64", "")) - chunk_path.write_bytes(audio_bytes) - - alignment = data.get("alignment") or {} - if not isinstance(alignment, dict): - alignment = {} - chars = alignment.get("characters", []) - starts = alignment.get("character_start_times_seconds", []) - ends = alignment.get("character_end_times_seconds", []) - - chunk_words = [] - word, word_start, last_end = "", None, 0.0 - for j, ch in enumerate(chars): - if ch in (" ", "\n"): - if word and word_start is not None: - end_idx = j - 1 - word_end = ends[end_idx] if 0 <= end_idx < len(ends) else word_start + max(0.15, len(word) * 0.08) - chunk_words.append({"word": word, "start": round(word_start, 3), - "end": round(word_end, 3)}) - word, word_start = "", None - else: - if word_start is None and j < len(starts): - word_start = starts[j] - word += ch - if j < len(ends): - last_end = ends[j] - if word and word_start is not None: - chunk_words.append({"word": word, "start": round(word_start, 3), "end": round(last_end, 3)}) + chunk_words = synthesize_chunk(tts, chunk, chunk_path, voice_settings=vs, + speed=spd, role="narrator") with open(chunk_ts, "w") as f: json.dump(chunk_words, f) @@ -260,18 +194,16 @@ def generate_chunk(text, voice_settings=None, speed=None): # ── Short pipeline: Images (portrait 9:16) ───────────────────────────────────── def run_short_images(short_storyboard_data): - try: - import fal_client # noqa: F401 - except ImportError: - subprocess.run([sys.executable, "-m", "pip", "install", "fal-client"], check=True) - - from pipeline.helpers import download_file - FAL_KEY = os.getenv("FAL_API_KEY") - if not FAL_KEY: - logger.warning("[Short Images] WARNING: FAL_API_KEY not set — skipping") - return short_storyboard_data + # Images go through the configured provider (providers.images in obsidian.yaml) + from providers.registry import get_provider, get_provider_name + from pipeline.images import _place_image + if get_provider_name("images") == "fal": + from providers.images.fal import FalProvider + if not FalProvider.has_credentials(): + logger.warning("[Short Images] WARNING: FAL_API_KEY not set — skipping") + return short_storyboard_data + image_provider = get_provider("images") - os.environ["FAL_KEY"] = FAL_KEY scenes = short_storyboard_data.get("scenes", []) IMAGE_MODEL = os.getenv("IMAGE_MODEL", "flux").lower() @@ -311,30 +243,14 @@ def run_short_images(short_storyboard_data): logger.info(f"[Short Images] Scene {i+1}/{len(scenes)} ({mood}): generating...") try: - if IMAGE_MODEL == "recraft": - result = _fal_subscribe_with_retry("fal-ai/recraft/v3/text-to-image", { - "prompt": prompt, - "image_size": {"width": 1440, "height": 2560}, - "num_images": 1, - "style": "digital_illustration", - }, label=f"Short scene {i+1}") - else: - result = _fal_subscribe_with_retry("fal-ai/flux-pro/v1.1-ultra", { - "prompt": prompt, - "image_size": {"width": 1440, "height": 2560}, - "num_images": 1, - "safety_tolerance": "2", - }, label=f"Short scene {i+1}") - images = result.get("images", []) - if not images: - raise ValueError("fal.ai returned empty images list") - url = images[0]["url"] - download_file(url, img_path) + # Portrait 9:16 (fal: Recraft 1440x2560, or Flux Ultra aspect_ratio 9:16) + out = image_provider.generate(prompt, style=IMAGE_MODEL, width=1440, height=2560) + _place_image(out, img_path) logger.info(f" ✓ {img_path.name} ({img_path.stat().st_size // 1024}KB)") scene["ai_image"] = str(img_path) time.sleep(0.5) except Exception as e: - logger.error(f" ✗ fal.ai failed for short image: {e}") + logger.error(f" ✗ {image_provider.name} failed for short image: {e}") # Fallback: try to reuse a long-form AI image if available fallback_long = ASSETS_DIR / f"scene_{i:03d}_ai.jpg" if fallback_long.exists(): diff --git a/providers/__init__.py b/providers/__init__.py index 7b745b5..37f07f7 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -1,17 +1,31 @@ """ -Providers — swappable backends for external services. +Providers — swappable backends for every external service the pipeline uses. -Each provider type has an abstract base class and one or more implementations. -The active provider is selected in obsidian.yaml and loaded via get_provider(). +Each provider type has an abstract base class in providers/base.py and one or +more built-in implementations. The active provider is selected in +obsidian.yaml (``providers..name``) and loaded via get_provider(). Any +class that extends the right base class can be used by giving its dotted path +(``my_pkg.my_module.MyClass``); ``providers..options`` is passed to its +constructor as keyword arguments. Provider types: - - llm: Text generation (Anthropic Claude, OpenAI, local) - - tts: Text-to-speech (ElevenLabs, OpenAI TTS) - - images: Image generation (fal.ai, Replicate) - - footage: Stock footage (Pexels, Pixabay) - - upload: Video upload (YouTube, local save) + - llm: Text generation (built-in: anthropic, openai) + - tts: Text-to-speech (built-in: elevenlabs, openai, epidemic_sound) + - images: Image generation (built-in: fal) + - footage: Stock footage (built-in: pexels) + - upload: Publishing the final video (built-in: local, youtube) + - music: Background music (built-in: local, epidemic_sound, or auto) + - sfx: Sound effects / ambience (built-in: local, epidemic_sound, or auto) + +Self-check: ``python -m providers``. Guide: docs/PROVIDERS.md. """ -from providers.registry import clear_cache, get_provider, list_providers +from providers.registry import ( + clear_cache, + get_provider, + get_provider_name, + is_builtin, + list_providers, +) -__all__ = ["get_provider", "list_providers", "clear_cache"] +__all__ = ["get_provider", "get_provider_name", "is_builtin", "list_providers", "clear_cache"] diff --git a/providers/__main__.py b/providers/__main__.py new file mode 100644 index 0000000..384506c --- /dev/null +++ b/providers/__main__.py @@ -0,0 +1,54 @@ +""" +Provider self-check: ``python -m providers`` + +Loads every provider configured in obsidian.yaml and reports whether it could +be imported and constructed. Useful when writing your own provider. It does +not make any paid API calls (credentials are only checked when a provider is +actually used). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Allow running from anywhere: make the project root importable +_ROOT = Path(__file__).resolve().parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + + +def main() -> int: + from providers.registry import _BASE_CLASSES, get_provider, get_provider_name, is_builtin + + failures = 0 + print(f"{'type':<8} {'provider':<34} {'class':<44} status") + print("-" * 100) + for ptype in _BASE_CLASSES: + try: + name = get_provider_name(ptype) + except Exception as e: # pragma: no cover - config errors + name = "?" + print(f"{ptype:<8} {name:<34} {'':<44} ERROR: {e}") + failures += 1 + continue + kind = "built-in" if is_builtin(ptype) else "custom" + try: + inst = get_provider(ptype, fresh=True) + cls = f"{type(inst).__module__}.{type(inst).__name__}" + label = getattr(inst, "name", "") + print(f"{ptype:<8} {name:<34} {cls:<44} ok ({kind}: {label})") + except Exception as e: + print(f"{ptype:<8} {name:<34} {'':<44} ERROR ({kind}): {e}") + failures += 1 + + print() + if failures: + print(f"{failures} provider(s) failed to load. See docs/PROVIDERS.md.") + return 1 + print("All configured providers loaded.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/providers/base.py b/providers/base.py index b57841d..ebfee77 100644 --- a/providers/base.py +++ b/providers/base.py @@ -1,14 +1,31 @@ """ Abstract base classes for all provider types. -To create a custom provider: -1. Subclass the relevant base class -2. Implement all abstract methods -3. Register it in obsidian.yaml under providers.* +Every external service the pipeline talks to (LLM, TTS, images, stock footage, +upload, music, SFX) goes through one of these interfaces, so you can bring +your own provider without touching pipeline code: + +1. Subclass the relevant base class (e.g. ``TTSProvider``) in any module that + is importable from the project root (e.g. ``my_providers/tts.py``). +2. Implement every ``@abstractmethod``. The non-abstract helpers + (``resolve_model``, ``resolve_voice``, ``generate_with_reference``, + ``sfx_for_scene`` ...) have sensible defaults and are optional. +3. Point obsidian.yaml at it:: + + providers: + tts: + name: my_providers.tts.MyTTS # dotted path to the class + options: # passed to MyTTS(**options) + voice: narrator-1 + +4. Run ``python -m providers`` to check that everything loads. + +See docs/PROVIDERS.md for the full contract of each type. """ from __future__ import annotations +import re from abc import ABC, abstractmethod from pathlib import Path from typing import Any @@ -59,6 +76,18 @@ def generate_with_search( def estimate_cost(self, input_tokens: int, output_tokens: int, model: str | None = None) -> float: """Estimate cost in USD for a given token count.""" + def resolve_model(self, tier: str) -> str | None: + """Map a pipeline quality tier to a model id for this provider. + + The pipeline asks for one of three tiers: ``"premium"`` (creative + writing), ``"full"`` (reasoning/analysis) or ``"light"`` (formatting, + classification). Return ``None`` to let ``generate()`` use its default. + + Default: look the tier up in ``self.models`` (a dict) if the provider + defines one, e.g. from an ``options: {models: {...}}`` block. + """ + return (getattr(self, "models", None) or {}).get(tier) + @property @abstractmethod def name(self) -> str: @@ -87,6 +116,10 @@ def synthesize( Returns: Tuple of (audio_file_path, word_timestamps). word_timestamps: list of {"word": str, "start": float, "end": float} + (seconds from the start of this clip). Return [] if the service + has no timing data: the pipeline then runs forced alignment + (Whisper, if installed) or spreads words evenly. + Any audio format ffmpeg can read is fine; non-MP3 is transcoded. """ @abstractmethod @@ -100,8 +133,20 @@ def list_voices(self) -> list[dict]: def check_credits(self) -> dict: """Check remaining credits/quota. - Returns {"remaining": int, "limit": int, "unit": str} + Returns {"remaining": int, "limit": int, "unit": str}. + Return -1 for remaining/limit when the service has no quota endpoint. + """ + + def resolve_voice(self, role: str) -> str | None: + """Map a narration role to a voice id for this provider. + + ``role`` is ``"narrator"`` (main voice) or ``"quote"`` (quoted + historical speech). Return ``None`` to let ``synthesize()`` use its + default voice. + + Default: look the role up in ``self.voices`` (a dict) if defined. """ + return (getattr(self, "voices", None) or {}).get(role) @property @abstractmethod @@ -131,8 +176,27 @@ def generate( seed: Random seed for reproducibility Returns: - Path to the generated image file. + Path to the generated image file (JPEG or PNG). The pipeline + copies it into place, so a temp file is fine. + """ + + #: Set to True if ``generate_with_reference`` is implemented. The pipeline + #: then uses character reference portraits for visual consistency. + supports_reference_images: bool = False + + def generate_with_reference( + self, + prompt: str, + reference_image: Path, + width: int = 1920, + height: int = 1080, + seed: int | None = None, + ) -> Path: + """Generate an image that keeps the subject of ``reference_image``. + + Optional. Only called when ``supports_reference_images`` is True. """ + raise NotImplementedError(f"{type(self).__name__} does not support reference images") @abstractmethod def estimate_cost(self) -> float: @@ -164,7 +228,9 @@ def search( max_results: Maximum results to return Returns: - List of {"url": str, "duration": int, "width": int, "height": int, "preview_url": str} + List of {"url": str, "duration": int, "width": int, "height": int, + "preview_url": str, "credit": str (optional attribution)} + Best match first; the pipeline uses the first result's ``url``. """ @abstractmethod @@ -201,9 +267,15 @@ def select_for_video(self, scenes: list[dict], total_duration: float) -> dict | None: """Select best track for a video. + ``scenes`` are the Remotion scenes (``mood``, ``start_time``, + ``end_time``, ``narrative_position`` ...). + Returns {"music_file": str, "music_start_offset": float, "track_id": str, "title": str, "bpm": int, "mood": str} - or None. + or None (the pipeline then falls back to the local library). + Only ``music_file`` is required. It may be a path relative to + ``remotion/public`` (e.g. ``"music/track.mp3"``) or an absolute path; + absolute paths are copied into ``remotion/public/music/``. """ def check_status(self) -> dict: @@ -235,6 +307,61 @@ def check_status(self) -> dict: """Check provider status.""" return {"status": "available"} + # ── Scene-level helpers used by the pipeline (optional overrides) ──────── + + def sfx_for_scene(self, scene: dict, dest_dir: Path) -> str | None: + """Pick a one-shot sound effect for a key scene. + + ``dest_dir`` is ``remotion/public/sfx``. Return a path relative to + ``remotion/public`` (e.g. ``"sfx/boom.mp3"``) or None. + + Default: ``search()`` for a mood-based keyword and ``download()`` the + first hit into ``dest_dir``. + """ + mood = (scene.get("mood") or "dramatic").lower() + keyword = scene.get("sfx_keyword") or f"{mood} cinematic impact" + return self._search_and_fetch(keyword, 5.0, dest_dir, "sfx") + + def ambient_for_scene(self, scene: dict, dest_dir: Path) -> str | None: + """Pick a looping ambient bed for a scene. + + ``dest_dir`` is ``remotion/public/ambience``. Return a path relative to + ``remotion/public`` (e.g. ``"ambience/wind.mp3"``) or None. + + Default: ``search()`` for ``" ambience"`` and ``download()`` the + first hit into ``dest_dir``. + """ + mood = (scene.get("mood") or "dark").lower() + keyword = scene.get("ambient_keyword") or f"{mood} ambience" + return self._search_and_fetch(keyword, 30.0, dest_dir, "amb") + + def _search_and_fetch(self, keyword: str, duration_max: float, + dest_dir: Path, prefix: str) -> str | None: + cache = self.__dict__.setdefault("_scene_audio_cache", {}) + key = (prefix, keyword) + if key in cache: + return cache[key] + results = self.search(keyword=keyword, duration_max=duration_max) + if not results: + cache[key] = None + return None + item_id = str(results[0].get("id", "")) + safe = re.sub(r"[^a-z0-9]+", "_", f"{keyword}_{item_id}".lower()).strip("_")[:60] + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + out = dest_dir / f"{prefix}_{safe}.mp3" + if not out.exists(): + got = Path(self.download(item_id, out) or out) + if got.exists() and got.resolve().parent != dest_dir.resolve(): + import shutil + out = dest_dir / got.name + shutil.copy2(got, out) + else: + out = got + rel = f"{dest_dir.name}/{out.name}" if out.exists() else None + cache[key] = rel + return rel + @property @abstractmethod def name(self) -> str: @@ -264,6 +391,8 @@ def upload( Returns: {"video_id": str, "url": str, "status": str} + ``video_id`` must be non-empty: the pipeline treats an empty id + as a failed upload. """ @property diff --git a/providers/footage/pexels.py b/providers/footage/pexels.py index 6c29330..3a58078 100644 --- a/providers/footage/pexels.py +++ b/providers/footage/pexels.py @@ -1,11 +1,13 @@ """ Pexels stock footage provider — default implementation. -Free API with generous limits. No attribution required for commercial use. +Free API with generous limits. Needs PEXELS_API_KEY; without it search() +returns [] and the footage hunter falls back to Wikimedia stills. """ from __future__ import annotations +import json import os from pathlib import Path @@ -17,8 +19,21 @@ class PexelsProvider(FootageProvider): API_BASE = "https://api.pexels.com/videos" - def __init__(self): + def __init__(self, min_width: int = 1280): self._api_key = os.getenv("PEXELS_API_KEY") + self._min_width = min_width + + def _pick_file(self, files: list[dict]) -> dict | None: + """Prefer the first HD rendition at least min_width wide (what the + footage hunter always used); otherwise the widest file if it is wide + enough.""" + hd = [f for f in files if f.get("quality") == "hd" and f.get("width", 0) >= self._min_width] + if hd: + return hd[0] + best = max(files, key=lambda f: f.get("width", 0) or 0, default=None) + if best and (best.get("width", 0) or 0) >= self._min_width: + return best + return None def search( self, @@ -28,41 +43,39 @@ def search( max_results: int = 5, ) -> list[dict]: import requests - if not self._api_key: + api_key = self._api_key or os.getenv("PEXELS_API_KEY") + if not api_key: return [] r = requests.get( f"{self.API_BASE}/search", - headers={"Authorization": self._api_key}, + headers={"Authorization": api_key}, params={ "query": query, "orientation": orientation, "per_page": max_results, - "size": "large", }, - timeout=15, + timeout=10, ) if r.status_code != 200: return [] results = [] - for video in r.json().get("videos", []): - duration = video.get("duration", 0) + for video in json.loads(r.text, strict=False).get("videos", []): + duration = video.get("duration", 0) or 0 if duration < min_duration: continue - - # Find best quality file - files = video.get("video_files", []) - best = max(files, key=lambda f: f.get("width", 0), default=None) + best = self._pick_file(video.get("video_files", []) or []) if not best: continue - + user = (video.get("user") or {}).get("name", "Pexels") results.append({ "url": best.get("link", ""), - "duration": duration, - "width": best.get("width", 0), - "height": best.get("height", 0), + "duration": duration or 10, + "width": best.get("width", 1920), + "height": best.get("height", 1080), "preview_url": video.get("image", ""), + "credit": f"Video by {user} on Pexels", }) return results[:max_results] diff --git a/providers/images/fal.py b/providers/images/fal.py index 3a19aac..11f7341 100644 --- a/providers/images/fal.py +++ b/providers/images/fal.py @@ -1,27 +1,116 @@ """ fal.ai image generation provider — default implementation. -Supports Recraft and Flux models via fal.ai's serverless GPU infrastructure. +Models (chosen with the ``style`` argument, which the pipeline sets from the +IMAGE_MODEL env var; default "flux"): + - "flux": fal-ai/flux-pro/v1.1-ultra (aspect ratio derived from width/height) + - "recraft": fal-ai/recraft/v3/text-to-image (digital_illustration) +Reference images (character consistency): fal-ai/flux-pro/kontext. + +Needs FAL_API_KEY (or FAL_KEY) in the environment; it is checked when an image +is generated, not when the provider is created. """ from __future__ import annotations +import base64 import os +import random +import tempfile +import time +from math import gcd from pathlib import Path +from core.log import get_logger from providers.base import ImageProvider +logger = get_logger(__name__) + +FLUX_ULTRA = "fal-ai/flux-pro/v1.1-ultra" +RECRAFT_V3 = "fal-ai/recraft/v3/text-to-image" +FLUX_KONTEXT = "fal-ai/flux-pro/kontext" + +# Aspect ratios accepted by flux-pro ultra / kontext +_FLUX_ASPECTS = {"21:9", "16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16", "9:21"} + + +def _fal_subscribe_with_retry(model: str, arguments: dict, label: str = "fal.ai", + max_attempts: int = 5, backoff_base: int = 2): + """Call fal_client.subscribe with exponential-backoff retries. + + Retries on connection errors, timeouts, and rate limits (up to max_attempts). + """ + import fal_client + + for attempt in range(1, max_attempts + 1): + try: + return fal_client.subscribe(model, arguments=arguments) + except Exception as e: + err_str = str(e).lower() + is_retryable = any(kw in err_str for kw in [ + "timeout", "timed out", "connection", "rate limit", "429", + "502", "503", "504", "overloaded", "temporarily", + ]) + if attempt == max_attempts or not is_retryable: + logger.error(f" [{label}] Failed after {attempt} attempt(s): {e}") + raise + _rng = random.Random() + wait = (backoff_base ** attempt) + _rng.uniform(0, backoff_base ** attempt * 0.5) + logger.warning(f" [{label}] Attempt {attempt}/{max_attempts} failed ({type(e).__name__}: {e}), retrying in {wait:.1f}s...") + time.sleep(wait) + + +def _aspect_ratio(width: int, height: int) -> str: + g = gcd(int(width), int(height)) or 1 + ratio = f"{int(width) // g}:{int(height) // g}" + if ratio in _FLUX_ASPECTS: + return ratio + # Closest supported ratio + target = width / height + return min(_FLUX_ASPECTS, key=lambda r: abs(int(r.split(":")[0]) / int(r.split(":")[1]) - target)) + class FalProvider(ImageProvider): """fal.ai image generation provider.""" - def __init__(self): - self._ensure_keys() + supports_reference_images = True + + def __init__(self, default_model: str | None = None): + # "flux" or "recraft"; the pipeline passes style= explicitly + self._default_model = (default_model or os.getenv("IMAGE_MODEL", "flux")).lower() + + @staticmethod + def has_credentials() -> bool: + return bool(os.getenv("FAL_API_KEY") or os.getenv("FAL_KEY")) def _ensure_keys(self): - """Verify fal.ai API keys are set.""" - if not os.getenv("FAL_KEY") and not os.getenv("FAL_API_KEY"): - raise RuntimeError("FAL_KEY or FAL_API_KEY not set") + """Map FAL_API_KEY → FAL_KEY (what fal_client reads). Raises if neither is set.""" + key = os.getenv("FAL_API_KEY") + if key: + os.environ["FAL_KEY"] = key + if not os.getenv("FAL_KEY"): + raise RuntimeError("FAL_KEY or FAL_API_KEY not set — cannot generate images with fal.ai") + try: + import fal_client # noqa: F401 + except ImportError: + import subprocess + import sys + logger.info("[fal] Installing fal-client...") + subprocess.run([sys.executable, "-m", "pip", "install", "fal-client"], check=True) + + def _download_first(self, result: dict, prompt: str) -> Path: + from pipeline.helpers import download_file + images = (result or {}).get("images") or [] + if not images: + raise ValueError("fal.ai returned empty images list") + url = images[0] if isinstance(images[0], str) else images[0].get("url", "") + if not url: + raise RuntimeError("fal.ai returned empty image URL") + suffix = ".png" if ".png" in url.split("?")[0] else ".jpg" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + path = Path(tmp.name) + download_file(url, path) + return path def generate( self, @@ -31,50 +120,60 @@ def generate( height: int = 1080, seed: int | None = None, ) -> Path: - from pipeline.images import _fal_subscribe_with_retry - from pipeline.helpers import download_file - from core.config import cfg - import tempfile - - model = style or cfg.models.image_provider # "recraft" or "flux" + self._ensure_keys() + model = (style or self._default_model or "flux").lower() if model == "recraft": - endpoint = cfg.models.fal_recraft + # 16:9 requests go out at 2560x1440 (Recraft's sharpest 16:9 size) + size = {"width": 2560, "height": 1440} if (width, height) == (1920, 1080) \ + else {"width": width, "height": height} + endpoint = RECRAFT_V3 args = { "prompt": prompt, - "style": "digital_illustration/hand_drawn", - "size": {"width": width, "height": height}, + "image_size": size, + "num_images": 1, + "style": "digital_illustration", } else: - endpoint = cfg.models.fal_flux + endpoint = FLUX_ULTRA args = { "prompt": prompt, - "image_size": {"width": width, "height": height}, + "aspect_ratio": _aspect_ratio(width, height), + "num_images": 1, + "safety_tolerance": "2", } - if seed is not None: args["seed"] = seed - result = _fal_subscribe_with_retry(endpoint, args) - - # Extract image URL - images = result.get("images") or result.get("output", {}).get("images", []) - if not images: - raise RuntimeError(f"fal.ai returned no images for prompt: {prompt[:50]}") - - url = images[0] if isinstance(images[0], str) else images[0].get("url", "") - if not url: - raise RuntimeError("fal.ai returned empty image URL") + result = _fal_subscribe_with_retry(endpoint, args, label=f"fal {model}") + return self._download_first(result, prompt) - # Download to temp file - suffix = ".png" if ".png" in url else ".jpg" - tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) - download_file(url, tmp.name) - return Path(tmp.name) + def generate_with_reference( + self, + prompt: str, + reference_image: Path, + width: int = 1920, + height: int = 1080, + seed: int | None = None, + ) -> Path: + self._ensure_keys() + with open(reference_image, "rb") as f: + ref_b64 = base64.b64encode(f.read()).decode() + args = { + "image_url": f"data:image/jpeg;base64,{ref_b64}", + "prompt": prompt, + "aspect_ratio": _aspect_ratio(width, height), + "num_images": 1, + "safety_tolerance": "2", + } + if seed is not None: + args["seed"] = seed + result = _fal_subscribe_with_retry(FLUX_KONTEXT, args, label="fal kontext") + return self._download_first(result, prompt) def estimate_cost(self) -> float: # fal.ai: ~$0.01-0.05 per image depending on model - return 0.03 + return 0.05 @property def name(self) -> str: diff --git a/providers/llm/anthropic.py b/providers/llm/anthropic.py index 5ed0da9..c1eaf84 100644 --- a/providers/llm/anthropic.py +++ b/providers/llm/anthropic.py @@ -1,7 +1,10 @@ """ Anthropic Claude LLM provider — default implementation. -Wraps the existing clients/claude_client.py for provider interface compatibility. +The pipeline's call_claude() talks to Anthropic directly when this provider is +configured; this class exposes the same behaviour through the LLMProvider +interface (it calls the internal Anthropic functions, never call_claude(), so +there is no recursion). """ from __future__ import annotations @@ -14,9 +17,12 @@ class AnthropicProvider(LLMProvider): """Claude LLM provider using the Anthropic API.""" - def __init__(self): - from clients.claude_client import SONNET + def __init__(self, models: dict | None = None): + from clients.claude_client import HAIKU, OPUS, SONNET self._default_model = SONNET + self.models = {"premium": OPUS, "full": SONNET, "light": HAIKU} + if models: + self.models.update(models) def generate( self, @@ -27,11 +33,11 @@ def generate( expect_json: bool = True, output_schema: dict | None = None, ) -> Any: - from clients.claude_client import call_claude - return call_claude( - system_prompt=system_prompt, - user_prompt=user_prompt, - model=model or self._default_model, + from clients.claude_client import _anthropic_call + return _anthropic_call( + system_prompt, + user_prompt, + model or self._default_model, max_tokens=max_tokens, expect_json=expect_json, output_schema=output_schema, @@ -45,11 +51,11 @@ def generate_with_search( max_tokens: int = 4000, output_schema: dict | None = None, ) -> str: - from clients.claude_client import call_claude_with_search - return call_claude_with_search( - system_prompt=system_prompt, - user_prompt=user_prompt, - model=model or self._default_model, + from clients.claude_client import _anthropic_call_with_search + return _anthropic_call_with_search( + system_prompt, + user_prompt, + model or self._default_model, max_tokens=max_tokens, output_schema=output_schema, ) diff --git a/providers/llm/openai.py b/providers/llm/openai.py index 5a9fac9..f27d7b4 100644 --- a/providers/llm/openai.py +++ b/providers/llm/openai.py @@ -1,29 +1,50 @@ """ -OpenAI GPT LLM provider — alternative implementation. - -Requires: pip install openai -Set OPENAI_API_KEY in .env - -This is a reference implementation showing how to add a new LLM provider. +OpenAI (and OpenAI-compatible) LLM provider. + +Requires: pip install openai. Set OPENAI_API_KEY in .env. + +obsidian.yaml: + providers: + llm: + name: openai + options: + default_model: gpt-4o + models: # pipeline tier -> model id + premium: gpt-4o + full: gpt-4o + light: gpt-4o-mini + # base_url: http://localhost:11434/v1 # any OpenAI-compatible server (e.g. Ollama) + # api_key_env: OPENAI_API_KEY # env var holding the key """ from __future__ import annotations +import json +import os from typing import Any from providers.base import LLMProvider class OpenAIProvider(LLMProvider): - """OpenAI GPT provider.""" + """OpenAI GPT provider (chat completions API).""" - def __init__(self, default_model: str = "gpt-4o"): + def __init__( + self, + default_model: str = "gpt-4o", + models: dict | None = None, + base_url: str | None = None, + api_key_env: str = "OPENAI_API_KEY", + ): self._default_model = default_model + self.models = dict(models or {}) + self._base_url = base_url + self._api_key_env = api_key_env self._client = None + self._schema_supported = True def _get_client(self): if self._client is None: - import os try: import openai except ImportError: @@ -31,9 +52,27 @@ def _get_client(self): "OpenAI provider requires the openai package. " "Install with: pip install openai" ) - self._client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + api_key = os.getenv(self._api_key_env) + if not api_key and not self._base_url: + raise RuntimeError(f"{self._api_key_env} not set — required for the openai LLM provider") + kwargs = {"api_key": api_key or "not-needed"} + if self._base_url: + kwargs["base_url"] = self._base_url + self._client = openai.OpenAI(**kwargs) return self._client + def _create(self, **kwargs): + """chat.completions.create with a fallback for models that only accept + max_completion_tokens (o-series / newer models).""" + client = self._get_client() + try: + return client.chat.completions.create(**kwargs) + except Exception as e: + if "max_completion_tokens" in str(e) and "max_tokens" in kwargs: + kwargs["max_completion_tokens"] = kwargs.pop("max_tokens") + return client.chat.completions.create(**kwargs) + raise + def generate( self, system_prompt: str, @@ -43,11 +82,12 @@ def generate( expect_json: bool = True, output_schema: dict | None = None, ) -> Any: - import json - client = self._get_client() model = model or self._default_model + if (expect_json or output_schema) and "json" not in system_prompt.lower(): + # json_object mode requires the word "json" somewhere in the messages + system_prompt = system_prompt + "\n\nRespond with valid JSON only." - kwargs = dict( + kwargs: dict[str, Any] = dict( model=model, max_tokens=max_tokens, messages=[ @@ -56,16 +96,34 @@ def generate( ], ) - if expect_json or output_schema: - kwargs["response_format"] = {"type": "json_object"} - - response = client.chat.completions.create(**kwargs) - raw = response.choices[0].message.content.strip() - + response = None + if output_schema is not None and self._schema_supported: + try: + response = self._create(**kwargs, response_format={ + "type": "json_schema", + "json_schema": {"name": "output", "schema": output_schema, "strict": False}, + }) + except Exception as e: + # Model/server without json_schema support: fall back to json_object + if "response_format" not in str(e) and "json_schema" not in str(e): + raise + self._schema_supported = False + if response is None: + if expect_json or output_schema is not None: + kwargs["response_format"] = {"type": "json_object"} + response = self._create(**kwargs) + + raw = (response.choices[0].message.content or "").strip() if not expect_json: return raw - - return json.loads(raw) + try: + return json.loads(raw, strict=False) + except (json.JSONDecodeError, ValueError): + from clients.claude_client import _parse_json_robust + parsed = _parse_json_robust(raw) + if parsed is None: + raise + return parsed def generate_with_search( self, @@ -75,8 +133,8 @@ def generate_with_search( max_tokens: int = 4000, output_schema: dict | None = None, ) -> str: - # OpenAI doesn't have native web search — fall back to regular generation - # with a note in the system prompt + # Chat completions has no built-in web search — fall back to regular + # generation with a note in the system prompt. enhanced_system = system_prompt + ( "\n\nNote: Web search is not available with this provider. " "Use your training knowledge to provide the best response." diff --git a/providers/registry.py b/providers/registry.py index 299ad20..72af2bc 100644 --- a/providers/registry.py +++ b/providers/registry.py @@ -8,11 +8,24 @@ img = get_provider("images") # Returns configured ImageProvider ftg = get_provider("footage") # Returns configured FootageProvider upl = get_provider("upload") # Returns configured UploadProvider + mus = get_provider("music") # Returns configured MusicProvider + sfx = get_provider("sfx") # Returns configured SFXProvider + +Configuration (obsidian.yaml): + providers: + tts: + name: elevenlabs # a built-in name ... + images: + name: my_pkg.my_module.MyImages # ... or your own class + options: {api_url: "http://..."} # passed as MyImages(**options) + +Run ``python -m providers`` to check that every configured provider loads. """ from __future__ import annotations import importlib +import os from typing import Any from providers.base import ( @@ -44,6 +57,7 @@ }, "upload": { "local": ("providers.upload.local", "LocalSaveProvider"), + "youtube": ("providers.upload.youtube", "YouTubeUploadProvider"), }, "music": { "epidemic_sound": ("providers.music.epidemic", "EpidemicMusicProvider"), @@ -88,6 +102,31 @@ def _load_class(module_path: str, class_name: str) -> type: return cls +def _load_custom_class(provider_type: str, dotted: str) -> type: + """Import a user-supplied ``module.path.ClassName`` with helpful errors.""" + module_path, _, class_name = dotted.rpartition(".") + if not module_path or not class_name: + raise RuntimeError( + f"Custom {provider_type} provider '{dotted}' must be 'module.path.ClassName'" + ) + try: + mod = importlib.import_module(module_path) + except ImportError as e: + raise RuntimeError( + f"Could not import module '{module_path}' for custom {provider_type} " + f"provider '{dotted}': {e}. The module must be importable from the " + f"project root (e.g. a file at {module_path.replace('.', '/')}.py next " + f"to run_pipeline.py, or an installed package)." + ) from e + try: + return getattr(mod, class_name) + except AttributeError as e: + raise RuntimeError( + f"Module '{module_path}' has no class '{class_name}' " + f"(configured as {provider_type} provider '{dotted}')" + ) from e + + def _resolve_provider_config(provider_type: str) -> tuple[str, dict]: """Read obsidian.yaml to find which provider and options to use. @@ -101,26 +140,63 @@ def _resolve_provider_config(provider_type: str) -> tuple[str, dict]: if section: name = section.get("name") or section.get("provider") options = section.get("options") or {} - if isinstance(options, dict): - pass - else: + if not isinstance(options, dict): try: options = options.to_dict() except AttributeError: options = {} if name: - return str(name), options + return str(name), dict(options) except Exception: pass return _DEFAULTS.get(provider_type, ""), {} +def _resolve_auto(provider_type: str, name: str) -> str: + """Resolve "auto" for music/sfx: epidemic_sound if its key is set, else local.""" + if name != "auto" or provider_type not in ("music", "sfx"): + return name + builtins = _BUILTIN_PROVIDERS.get(provider_type, {}) + if os.getenv("EPIDEMIC_SOUND_API_KEY") and "epidemic_sound" in builtins: + try: + _load_class(*builtins["epidemic_sound"]) + return "epidemic_sound" + except Exception: + return "local" + return "local" + + +def _check_type(provider_type: str) -> None: + if provider_type not in _BASE_CLASSES: + raise ValueError( + f"Unknown provider type '{provider_type}'. " + f"Must be one of: {', '.join(_BASE_CLASSES)}" + ) + + +def get_provider_name(provider_type: str) -> str: + """Return the configured provider name for a type, with "auto" resolved. + + Built-ins return their short name (e.g. "elevenlabs"); custom providers + return their dotted path (e.g. "my_pkg.tts.MyTTS"). + """ + _check_type(provider_type) + name, _ = _resolve_provider_config(provider_type) + return _resolve_auto(provider_type, name) + + +def is_builtin(provider_type: str) -> bool: + """True if the configured provider for this type is a built-in one.""" + return get_provider_name(provider_type) in _BUILTIN_PROVIDERS.get(provider_type, {}) + + def get_provider(provider_type: str, *, fresh: bool = False) -> Any: """Get a configured provider instance. Args: - provider_type: One of "llm", "tts", "images", "footage", "upload" + provider_type: One of "llm", "tts", "images", "footage", "upload", + "music", "sfx" fresh: If True, create a new instance instead of returning cached one Returns: @@ -130,61 +206,45 @@ def get_provider(provider_type: str, *, fresh: bool = False) -> Any: ValueError: If provider_type is unknown RuntimeError: If the provider can't be loaded """ - if provider_type not in _BASE_CLASSES: - raise ValueError( - f"Unknown provider type '{provider_type}'. " - f"Must be one of: {', '.join(_BASE_CLASSES)}" - ) + _check_type(provider_type) if not fresh and provider_type in _instances: return _instances[provider_type] name, options = _resolve_provider_config(provider_type) + name = _resolve_auto(provider_type, name) - # Resolve "auto" for music/sfx: try epidemic_sound if API key set, else local builtins = _BUILTIN_PROVIDERS.get(provider_type, {}) - if name == "auto" and provider_type in ("music", "sfx"): - import os - if os.getenv("EPIDEMIC_SOUND_API_KEY") and "epidemic_sound" in builtins: - try: - module_path, class_name = builtins["epidemic_sound"] - cls = _load_class(module_path, class_name) - name = "epidemic_sound" - except Exception: - name = "local" - else: - name = "local" - - # Check built-in providers first if name in builtins: - module_path, class_name = builtins[name] - cls = _load_class(module_path, class_name) + cls = _load_class(*builtins[name]) elif "." in name: # Custom provider: "my_package.my_module.MyProvider" - parts = name.rsplit(".", 1) - if len(parts) != 2: - raise RuntimeError( - f"Custom provider '{name}' must be 'module.path.ClassName'" - ) - cls = _load_class(parts[0], parts[1]) + cls = _load_custom_class(provider_type, name) else: raise RuntimeError( f"Unknown {provider_type} provider '{name}'. " - f"Built-in options: {', '.join(builtins)}" + f"Built-in options: {', '.join(builtins)} " + f"(or a custom 'module.path.ClassName')" ) # Validate it's the right type base = _BASE_CLASSES[provider_type] - if not issubclass(cls, base): + if not isinstance(cls, type) or not issubclass(cls, base): raise RuntimeError( - f"Provider {cls.__name__} does not extend {base.__name__}" + f"Provider {getattr(cls, '__name__', cls)!r} does not extend " + f"providers.base.{base.__name__}" ) # Instantiate with options - if options: - instance = cls(**options) - else: - instance = cls() + try: + instance = cls(**options) if options else cls() + except TypeError as e: + keys = ", ".join(sorted(options)) or "(none)" + raise RuntimeError( + f"Could not create {provider_type} provider {cls.__name__} with " + f"options [{keys}]: {e}. Check providers.{provider_type}.options in " + f"obsidian.yaml — each key is passed as a constructor keyword argument." + ) from e _instances[provider_type] = instance return instance diff --git a/providers/sfx/epidemic.py b/providers/sfx/epidemic.py index 25ace18..a84290d 100644 --- a/providers/sfx/epidemic.py +++ b/providers/sfx/epidemic.py @@ -43,6 +43,32 @@ def download(self, sfx_id: str, output_path: Path) -> Path: client = EpidemicSoundClient() return client.download_sfx(sfx_id, output_path) + # Scene-level selection: Epidemic API (cached per session by the SFX + # manager), falling back to the local files when the API returns nothing. + def sfx_for_scene(self, scene: dict, dest_dir: Path) -> str | None: + sfx = None + try: + from media.epidemic_sfx_manager import get_sfx_for_scene + sfx = get_sfx_for_scene(scene) or None + except Exception: + pass + if not sfx: + from providers.sfx.local import local_sfx_for_scene + sfx = local_sfx_for_scene(scene) + return sfx + + def ambient_for_scene(self, scene: dict, dest_dir: Path) -> str | None: + amb = None + try: + from media.epidemic_sfx_manager import get_ambient_for_scene + amb = get_ambient_for_scene(scene) or None + except Exception: + pass + if not amb: + from providers.sfx.local import local_ambient_for_scene + amb = local_ambient_for_scene(scene) + return amb + def check_status(self) -> dict: if not self._api_key: return {"status": "no_key"} diff --git a/providers/sfx/local.py b/providers/sfx/local.py index a02b8e6..6e44bcd 100644 --- a/providers/sfx/local.py +++ b/providers/sfx/local.py @@ -9,6 +9,28 @@ from providers.base import SFXProvider +def local_sfx_for_scene(scene: dict) -> str | None: + """Mood-mapped one-shot from the local SFX set ("sfx/") or None.""" + try: + from scripts.setup_sfx import get_sfx_file + return get_sfx_file(scene.get("mood", "dramatic")) or None + except Exception: + return None + + +def local_ambient_for_scene(scene: dict) -> str | None: + """Location/mood-aware ambient bed from the local set ("ambience/") or None.""" + try: + from scripts.setup_ambience import get_ambient_file + return get_ambient_file( + scene.get("mood", "dark"), + location=scene.get("location", ""), + visual_desc=scene.get("visual_description", ""), + ) or None + except Exception: + return None + + class LocalSFXProvider(SFXProvider): """Local sound effects provider using Pixabay downloads.""" @@ -30,6 +52,14 @@ def search(self, keyword: str, duration_max: float = 5.0, def download(self, sfx_id: str, output_path: Path) -> Path: raise NotImplementedError("Local SFX tracks are already on disk") + # Scene-level selection: the curated local files (scripts/setup_sfx.py, + # scripts/setup_ambience.py), already under remotion/public. + def sfx_for_scene(self, scene: dict, dest_dir: Path) -> str | None: + return local_sfx_for_scene(scene) + + def ambient_for_scene(self, scene: dict, dest_dir: Path) -> str | None: + return local_ambient_for_scene(scene) + @property def name(self) -> str: return "Local SFX" diff --git a/providers/tts/elevenlabs.py b/providers/tts/elevenlabs.py index a12636b..5a96d35 100644 --- a/providers/tts/elevenlabs.py +++ b/providers/tts/elevenlabs.py @@ -1,23 +1,133 @@ """ ElevenLabs TTS provider — default implementation. -Uses the ElevenLabs API for high-quality text-to-speech with word timestamps. +Uses the ElevenLabs ``/text-to-speech/{voice}/with-timestamps`` endpoint, which +returns MP3 audio plus character-level alignment that we fold into word +timestamps for captions. + +obsidian.yaml: + providers: + tts: + name: elevenlabs + # options: + # model: eleven_v3 # default: voice.model + # voices: {narrator: , quote: } # default: voice.narrator_id / voice.quote_id """ from __future__ import annotations +import base64 import json import os +import tempfile +import time from pathlib import Path +from core.log import get_logger from providers.base import TTSProvider +logger = get_logger(__name__) + +API_BASE = "https://api.elevenlabs.io/v1" + + +def alignment_to_words(data: dict) -> list[dict]: + """Fold ElevenLabs character alignment into word timestamps. + + Robust to missing/None/non-dict alignment, newline separators and + truncated timing arrays. + """ + alignment = data.get("alignment") or {} + if not isinstance(alignment, dict): + alignment = {} + chars = alignment.get("characters", []) or [] + starts = alignment.get("character_start_times_seconds", []) or [] + ends = alignment.get("character_end_times_seconds", []) or [] + + words: list[dict] = [] + word, word_start, last_end = "", None, 0.0 + for j, ch in enumerate(chars): + if ch in (" ", "\n"): + if word and word_start is not None: + end_idx = j - 1 + word_end = ends[end_idx] if 0 <= end_idx < len(ends) else word_start + max(0.15, len(word) * 0.08) + words.append({"word": word, "start": round(word_start, 3), "end": round(word_end, 3)}) + word, word_start = "", None + else: + if word_start is None and j < len(starts): + word_start = starts[j] + word += ch + if j < len(ends): + last_end = ends[j] + if word and word_start is not None: + words.append({"word": word, "start": round(word_start, 3), "end": round(last_end, 3)}) + return words + class ElevenLabsProvider(TTSProvider): """ElevenLabs text-to-speech provider.""" - def __init__(self): - self._api_key = os.getenv("ELEVENLABS_API_KEY") + MAX_ATTEMPTS = 5 + + def __init__(self, model: str | None = None, voices: dict | None = None, + api_key_env: str = "ELEVENLABS_API_KEY"): + self._model = model + self._voices = dict(voices or {}) + self._api_key_env = api_key_env + + # Read lazily so a key added after startup (or in tests) is picked up + @property + def _api_key(self) -> str | None: + return os.getenv(self._api_key_env) + + def _model_id(self) -> str: + if self._model: + return self._model + try: + from core.config import cfg + return cfg.voice.model or "eleven_v3" + except Exception: + return "eleven_v3" + + def resolve_voice(self, role: str) -> str | None: + if role in self._voices: + return self._voices[role] + try: + from core.pipeline_config import NARRATOR_VOICE_ID, QUOTE_VOICE_ID + except Exception: + return None + return QUOTE_VOICE_ID if role == "quote" else NARRATOR_VOICE_ID + + def _post_with_retry(self, url: str, payload: dict) -> dict: + """POST with retry/backoff: 429 → 60s·n, 5xx/timeouts → 30s·n, 5 attempts.""" + import requests + + headers = {"xi-api-key": self._api_key, "Content-Type": "application/json"} + last_err: Exception | None = None + for attempt in range(self.MAX_ATTEMPTS): + try: + r = requests.post(url, headers=headers, json=payload, timeout=120) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as ce: + wait = 30 * (attempt + 1) + logger.warning(f" Connection/timeout error, waiting {wait}s...") + time.sleep(wait) + last_err = ce + continue + if r.status_code == 200: + return json.loads(r.text, strict=False) + elif r.status_code == 429: + wait = 60 * (attempt + 1) + logger.warning(f" Rate limited, waiting {wait}s (attempt {attempt+1}/{self.MAX_ATTEMPTS})...") + time.sleep(wait) + last_err = Exception("ElevenLabs rate limit (429)") + elif r.status_code in (500, 502, 503): + wait = 30 * (attempt + 1) + logger.warning(f" Server error ({r.status_code}), waiting {wait}s (attempt {attempt+1}/{self.MAX_ATTEMPTS})...") + time.sleep(wait) + last_err = Exception(f"ElevenLabs server error ({r.status_code})") + else: + raise Exception(f"ElevenLabs {r.status_code}: {r.text[:200]}") + raise last_err or Exception(f"Failed after {self.MAX_ATTEMPTS} attempts") def synthesize( self, @@ -26,65 +136,37 @@ def synthesize( voice_settings: dict | None = None, speed: float = 1.0, ) -> tuple[Path, list[dict]]: - import requests - import tempfile - if not self._api_key: - raise RuntimeError("ELEVENLABS_API_KEY not set") + raise RuntimeError(f"{self._api_key_env} not set — cannot generate audio with ElevenLabs") - from core.config import cfg - voice_id = voice_id or cfg.voice.narrator_id - model = cfg.voice.model + voice_id = voice_id or self.resolve_voice("narrator") + if voice_settings is None: + from core.config import cfg + voice_settings = cfg.voice.body.to_dict() + # Pass voice_settings through unchanged (the scene-aware pipeline already + # puts its tuned speed in there); don't add one, so legacy/shorts payloads + # stay identical to what the pipeline sent before the provider refactor. + vs = dict(voice_settings) - url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/with-timestamps" - headers = {"xi-api-key": self._api_key, "Content-Type": "application/json"} payload = { "text": text, - "model_id": model, - "voice_settings": voice_settings or cfg.voice.body.to_dict(), + "model_id": self._model_id(), + "voice_settings": vs, + "speed": speed, } - if speed != 1.0: - payload["speed"] = speed - - r = requests.post(url, headers=headers, json=payload, timeout=120) - r.raise_for_status() - data = json.loads(r.text, strict=False) - - # Save audio - audio_bytes = __import__("base64").b64decode(data["audio_base64"]) - tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) - tmp.write(audio_bytes) - tmp.close() - - # Parse timestamps - timestamps = [] - alignment = data.get("alignment", {}) - chars = alignment.get("characters", []) - starts = alignment.get("character_start_times_seconds", []) - ends = alignment.get("character_end_times_seconds", []) - - if chars and starts and ends: - word = "" - word_start = 0.0 - for i, ch in enumerate(chars): - if ch == " " and word: - timestamps.append({"word": word, "start": word_start, "end": ends[i - 1]}) - word = "" - elif ch != " ": - if not word: - word_start = starts[i] - word += ch - if word: - timestamps.append({"word": word, "start": word_start, "end": ends[-1]}) - - return Path(tmp.name), timestamps + data = self._post_with_retry(f"{API_BASE}/text-to-speech/{voice_id}/with-timestamps", payload) + + audio_bytes = base64.b64decode(data.get("audio_base64", "") or "") + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: + tmp.write(audio_bytes) + return Path(tmp.name), alignment_to_words(data) def list_voices(self) -> list[dict]: import requests if not self._api_key: return [] r = requests.get( - "https://api.elevenlabs.io/v1/voices", + f"{API_BASE}/voices", headers={"xi-api-key": self._api_key}, timeout=10, ) @@ -97,14 +179,17 @@ def list_voices(self) -> list[dict]: ] def check_credits(self) -> dict: + """Character quota. Raises on 401 (invalid key / quota exceeded).""" import requests if not self._api_key: return {"remaining": 0, "limit": 0, "unit": "characters"} r = requests.get( - "https://api.elevenlabs.io/v1/user", + f"{API_BASE}/user", headers={"xi-api-key": self._api_key}, timeout=10, ) + if r.status_code == 401: + raise Exception("ElevenLabs API key invalid or quota exceeded (401)") if r.status_code != 200: return {"remaining": 0, "limit": 0, "unit": "characters"} sub = json.loads(r.text, strict=False).get("subscription", {}) diff --git a/providers/tts/epidemic.py b/providers/tts/epidemic.py index c4fab32..5f32db2 100644 --- a/providers/tts/epidemic.py +++ b/providers/tts/epidemic.py @@ -69,13 +69,14 @@ def synthesize( # Poll for completion deadline = time.time() + 60 status = result.get("status", "GENERATING") + status_data = result while time.time() < deadline and status == "GENERATING": time.sleep(3) status_data = client.get_voiceover_status(voiceover_id) status = status_data.get("status", "UNKNOWN") if status == "FAILED": - reason = result.get("failure_reason", "unknown") + reason = status_data.get("failure_reason", "unknown") raise RuntimeError(f"Voiceover generation failed: {reason}") if status != "DONE": raise RuntimeError(f"Voiceover generation timed out: {status}") diff --git a/providers/tts/openai_tts.py b/providers/tts/openai_tts.py index a2eafcd..1b73be9 100644 --- a/providers/tts/openai_tts.py +++ b/providers/tts/openai_tts.py @@ -1,92 +1,95 @@ """ -OpenAI TTS provider — default implementation. +OpenAI TTS provider. + +OpenAI's speech endpoint returns audio only (no timing data), so synthesize() +returns [] word timestamps and the pipeline derives them with forced +alignment (Whisper, if installed) or an even spread. + +ElevenLabs-specific settings (voice.narrator_id, voice.model, stability/style +voice_settings) are ignored — configure this provider with options instead: + + providers: + tts: + name: openai + options: + model: tts-1 # or tts-1-hd, gpt-4o-mini-tts + voice: onyx # narrator voice + quote_voice: echo # optional voice for quoted speech """ from __future__ import annotations import os +import tempfile from pathlib import Path + from providers.base import TTSProvider +_VOICES = [ + "alloy", "ash", "ballad", "coral", "echo", "fable", + "onyx", "nova", "sage", "shimmer", "verse", "marin", "cedar", +] + + class OpenAIProvider(TTSProvider): """OpenAI text-to-speech provider.""" - def __init__(self): - self._api_key = os.getenv("OPENAI_API_KEY") + API_URL = "https://api.openai.com/v1/audio/speech" + + def __init__(self, model: str = "tts-1", voice: str = "alloy", + quote_voice: str | None = None, api_key_env: str = "OPENAI_API_KEY"): + self._model = model + self._api_key_env = api_key_env + self.voices = {"narrator": voice, "quote": quote_voice or voice} + + @property + def _api_key(self) -> str | None: + return os.getenv(self._api_key_env) def synthesize( self, text: str, voice_id: str | None = None, - voice_settings: dict | None = None, + voice_settings: dict | None = None, # ElevenLabs-specific — ignored speed: float = 1.0, ) -> tuple[Path, list[dict]]: import requests - import tempfile - import warnings if not self._api_key: - raise RuntimeError("OPENAI_API_KEY not set") - - from core.config import cfg - - model = getattr(cfg.voice, "model", "tts-1") - voice = voice_id or getattr(cfg.voice, "narrator_id", "alloy") + raise RuntimeError(f"{self._api_key_env} not set — required for OpenAI TTS") - url = "https://api.openai.com/v1/audio/speech" - headers = { - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json" - } payload = { "input": text, - "model": model, - "voice": voice, - "response_format": "mp3" + "model": self._model, + "voice": voice_id or self.voices["narrator"], + "response_format": "mp3", } - if speed != 1.0: - payload["speed"] = speed - - r = requests.post(url, headers=headers, json=payload, timeout=120) - r.raise_for_status() - - tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) - tmp.write(r.content) - tmp.close() - - warnings.warn( - "OpenAI TTS does not provide word-level timestamps. " - "Downstream captions in Remotion will not sync.", - RuntimeWarning + if speed and speed != 1.0: + payload["speed"] = max(0.25, min(4.0, float(speed))) + + r = requests.post( + self.API_URL, + headers={"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}, + json=payload, + timeout=120, ) + r.raise_for_status() + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: + tmp.write(r.content) + # No timestamps from OpenAI — the pipeline aligns words itself. return Path(tmp.name), [] def list_voices(self) -> list[dict]: - """Returns the default OpenAI voices.""" - voices = [ - "alloy", "ash", "ballad", "coral", "echo", "fable", - "onyx", "nova", "sage", "shimmer", "verse", "marin", "cedar" - ] + """Returns the built-in OpenAI voices.""" return [ - { - "id": v, - "name": v.capitalize(), - "description": "OpenAI built-in voice" - } - for v in voices + {"id": v, "name": v.capitalize(), "description": "OpenAI built-in voice"} + for v in _VOICES ] def check_credits(self) -> dict: - """ - OpenAI API does not expose a simple endpoint for TTS character limits. - Billing is handled in USD across the entire organization. - """ - return { - "remaining": -1, - "limit": -1, - "unit": "USD (Check OpenAI Dashboard)" - } + """OpenAI has no simple TTS quota endpoint (billing is org-wide USD).""" + return {"remaining": -1, "limit": -1, "unit": "USD (check OpenAI dashboard)"} @property def name(self) -> str: diff --git a/providers/upload/youtube.py b/providers/upload/youtube.py new file mode 100644 index 0000000..34d4e7f --- /dev/null +++ b/providers/upload/youtube.py @@ -0,0 +1,58 @@ +""" +YouTube upload provider — publishes via the YouTube Data API. + +Wraps agents/11_youtube_uploader.upload_video (OAuth: client_secrets.json / +youtube_token.json, or GOOGLE_CLIENT_SECRETS_JSON / YOUTUBE_TOKEN_JSON env). +When this provider is selected, the uploader agent also runs the +YouTube-only follow-ups (sources comment, era playlist, endscreen/cards). + +obsidian.yaml: + providers: + upload: + name: youtube + options: + privacy: public # public | unlisted | private +""" + +from __future__ import annotations + +from pathlib import Path + +from providers.base import UploadProvider + + +def _load_uploader_agent(): + """Load agents/11_youtube_uploader.py (file name starts with a digit).""" + from pipeline.loader import load_agent + return load_agent(Path("11_youtube_uploader.py")) + + +class YouTubeUploadProvider(UploadProvider): + """Upload to YouTube with the channel's OAuth credentials.""" + + def __init__(self, privacy: str | None = None): + # None = use the caller's privacy (the pipeline publishes "public") + self.privacy = privacy + + def upload( + self, + video_path: Path, + title: str, + description: str, + tags: list[str], + thumbnail_path: Path | None = None, + privacy: str | None = None, + ) -> dict: + agent = _load_uploader_agent() + result = agent.upload_video( + str(video_path), title, description, tags, + str(thumbnail_path) if thumbnail_path else None, + privacy or self.privacy or "public", + ) + result = dict(result or {}) + result.setdefault("status", "uploaded") + return result + + @property + def name(self) -> str: + return "YouTube" diff --git a/scheduler.py b/scheduler.py index 19ce8fb..f811c56 100644 --- a/scheduler.py +++ b/scheduler.py @@ -60,6 +60,7 @@ def _md_escape(text: str) -> str: def _atomic_write_json(path: Path, data: dict): """Write JSON atomically via temp file + rename to prevent corruption.""" import tempfile + path = Path(path).resolve() # follow symlinks into the Docker ./data mount tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") try: with os.fdopen(tmp_fd, "w") as f: @@ -1179,6 +1180,8 @@ def _run_one_video_inner(): state = json.load(_sf) upload_data = state.get("stage_13") or {} video_id = upload_data.get("video_id", "") + if upload_data.get("provider", "youtube") != "youtube": + video_id = None # saved locally / custom uploader — no YouTube follow-ups seo_data = state.get("stage_6") or {} verification_data = state.get("stage_5") or {} @@ -1191,7 +1194,7 @@ def _run_one_video_inner(): print(f"[Scheduler] Pin comment failed (non-critical): {e}") # Post shorts teaser if a short was also uploaded - short_url = (state.get("short_upload") or {}).get("url", "") + short_url = (state.get("stage_short_upload") or state.get("short_upload") or {}).get("url", "") if short_url and video_id: try: title = seo_data.get("recommended_title", topic) @@ -1293,7 +1296,7 @@ def check_ab_titles(): upload = state.get("stage_13") or {} video_id = upload.get("video_id", "") - if not video_id: + if not video_id or upload.get("provider", "youtube") != "youtube": continue # Check upload age -- prefer uploaded_at from state, fall back to file mtime @@ -1633,7 +1636,26 @@ def wrapper(*args, **kwargs): print(f"[Scheduler] Post-upload task check error: {e}") time.sleep(60) +def _warn_if_youtube_upload_disabled(): + """Warn when YouTube credentials exist but uploads go elsewhere. + + Since uploads honour providers.upload.name (default: local), an existing + YouTube deployment that never set it would silently stop publishing. + """ + try: + from providers.registry import get_provider_name + has_token = bool(os.getenv("YOUTUBE_TOKEN_JSON")) or (Path(__file__).parent / "youtube_token.json").exists() + name = get_provider_name("upload") + if has_token and name != "youtube": + print(f"[Scheduler] WARNING: YouTube credentials found but providers.upload.name is " + f"'{name}' — videos will NOT be published to YouTube. Set " + f"providers.upload.name: youtube in obsidian.yaml to publish.") + except Exception: + pass + + if __name__ == "__main__": + _warn_if_youtube_upload_disabled() if "--discover" in sys.argv: discover_topics() elif "--once" in sys.argv: diff --git a/server/webhook_server.py b/server/webhook_server.py index b73140f..b0a750b 100644 --- a/server/webhook_server.py +++ b/server/webhook_server.py @@ -10,6 +10,7 @@ import json import re import hmac +import secrets import time as _time import threading import subprocess @@ -51,31 +52,62 @@ LESSONS_FILE = BASE_DIR / "lessons_learned.json" INSIGHTS_FILE = BASE_DIR / "channel_insights.json" PORT = int(os.getenv("PORT", 8080)) -TRIGGER_KEY = os.getenv("TRIGGER_KEY", "") +# Bind address. Defaults to loopback so a fresh local install is not exposed +# to the network; containers set HOST=0.0.0.0 explicitly. +HOST = os.getenv("HOST", "127.0.0.1").strip() or "127.0.0.1" +TRIGGER_KEY = os.getenv("TRIGGER_KEY", "").strip() + +# Paths written by the setup wizard (module-level so tests can redirect them) +ENV_PATH = BASE_DIR / ".env" +CONFIG_YAML_PATH = BASE_DIR / "obsidian.yaml" +PROFILES_DIR = BASE_DIR / "profiles" app = Flask(__name__) app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.urandom(32).hex() app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 # 1MB max request size +app.config.update( + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SECURE=os.getenv("COOKIE_SECURE", "").strip().lower() + in ("1", "true", "yes", "on"), +) _lock = threading.Lock() # ── Audit logging ──────────────────────────────────────────────────────────── _AUDIT_LOG_DIR = BASE_DIR / "outputs" / "logs" -_AUDIT_LOG_DIR.mkdir(parents=True, exist_ok=True) +try: + _AUDIT_LOG_DIR.mkdir(parents=True, exist_ok=True) +except OSError as _e: # e.g. root-owned bind mount in a non-root container + print(f"[Server] WARNING: cannot create {_AUDIT_LOG_DIR}: {_e}") _audit_logger = logging.getLogger("obsidian.audit") _audit_logger.setLevel(logging.INFO) _audit_logger.propagate = False -_audit_handler = logging.FileHandler(str(_AUDIT_LOG_DIR / "audit.log")) +try: + _audit_handler = logging.FileHandler(str(_AUDIT_LOG_DIR / "audit.log")) +except OSError as _e: + print(f"[Server] WARNING: audit log not writable ({_e}) — logging audit events to stderr") + _audit_handler = logging.StreamHandler(sys.stderr) _audit_handler.setFormatter(logging.Formatter("%(message)s")) _audit_logger.addHandler(_audit_handler) +_AUDIT_UNSAFE = re.compile(r"[\x00-\x1f\x7f]") + + +def _audit_clean(value) -> str: + """Neutralise CR/LF and other control chars so entries can't be forged.""" + return _AUDIT_UNSAFE.sub(" ", str(value)) + + def _audit(ip: str, action: str, details: str): """Write a structured line to the audit log.""" ts = datetime.now(timezone.utc).isoformat() - _audit_logger.info(f"{ts} | {ip} | {action} | {details}") + _audit_logger.info( + f"{ts} | {_audit_clean(ip)} | {_audit_clean(action)} | {_audit_clean(details)}" + ) # ── Rate limiting (in-memory) ──────────────────────────────────────────────── @@ -139,7 +171,8 @@ def wrapped(*args, **kwargs): re.IGNORECASE, ) -_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") +# All C0 control chars except TAB, including CR/LF (log-injection vectors), plus DEL +_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]") _HTML_TAGS = re.compile(r"<[^>]+>") @@ -148,8 +181,9 @@ def _validate_topic(topic: str): if not topic: return topic, None # empty topic handled downstream - # Strip HTML tags and control characters + # Strip HTML tags and control characters (line breaks become spaces) topic = _HTML_TAGS.sub("", topic) + topic = re.sub(r"[\r\n]+", " ", topic) topic = _CONTROL_CHARS.sub("", topic).strip() if len(topic) < 5: @@ -199,24 +233,112 @@ def mark_job_done(name: str): # ── Auth ────────────────────────────────────────────────────────────────────── -def _check_key(): - k = (request.headers.get("X-Trigger-Key") - or request.args.get("key")) - return (not TRIGGER_KEY) or k == TRIGGER_KEY +_LOOPBACK_ADDRS = frozenset({"127.0.0.1", "::1", "::ffff:127.0.0.1"}) +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) -def require_key(f): +_KEY_NOT_CONFIGURED_MSG = ( + "TRIGGER_KEY is not set, so the control API is disabled. Set TRIGGER_KEY " + "in .env (generate one with: python -c \"import secrets;" + "print(secrets.token_urlsafe(32))\") and restart, or run the setup wizard " + "from this machine (http://127.0.0.1:PORT) to generate one." +) + + +def _request_hostname() -> str: + """Hostname from the Host header, without port or IPv6 brackets.""" + host = (request.host or "").strip().lower() + if host.startswith("["): + return host[1:].split("]", 1)[0] + return host.rsplit(":", 1)[0] if host.count(":") == 1 else host + + +def _is_loopback() -> bool: + """True if the request comes from this machine AND was addressed to it. + + Checking the Host header as well as the peer address blocks DNS-rebinding: + a malicious site that re-resolves its domain to 127.0.0.1 still sends + ``Host: evil.example`` and is not trusted. + """ + if (request.remote_addr or "") not in _LOOPBACK_ADDRS: + return False + return _request_hostname() in _LOOPBACK_HOSTS + + +def _same_origin_json_post() -> bool: + """True for a JSON POST whose Origin (if sent) is this server. + + Browsers can send cross-site ``text/plain`` POSTs without a preflight; + requiring application/json forces a preflight, and the Origin check + rejects cross-site pages outright. + """ + if not request.is_json: + return False + origin = request.headers.get("Origin") + if not origin: + return True # non-browser client (curl, scripts) + from urllib.parse import urlparse + return urlparse(origin).netloc.lower() == (request.host or "").lower() + + +def _check_key(allow_query: bool = False) -> bool: + """Constant-time check of the caller's trigger key. + + Always False when TRIGGER_KEY is unset — the API is never open by default. + The ``?key=`` query param is only honoured where ``allow_query`` is set + (the SSE /stream endpoint, since EventSource can't send headers). + """ + if not TRIGGER_KEY: + return False + k = request.headers.get("X-Trigger-Key", "") + if not k and allow_query: + k = request.args.get("key", "") + if not k: + return False + return hmac.compare_digest(k.encode("utf-8"), TRIGGER_KEY.encode("utf-8")) + + +def _key_guard(f, *, allow_query: bool = False, first_run_loopback: bool = False): @wraps(f) def wrapped(*args, **kwargs): - if not _check_key(): + if not TRIGGER_KEY: + # Fresh local install: let the setup wizard run from loopback only, + # so it can generate and persist a TRIGGER_KEY. + if first_run_loopback and _is_loopback(): + return f(*args, **kwargs) + return jsonify({ + "error": "TRIGGER_KEY not configured", + "message": _KEY_NOT_CONFIGURED_MSG.replace("PORT", str(PORT)), + }), 503 + if not _check_key(allow_query=allow_query): + _audit(request.remote_addr or "unknown", "AUTH_FAILED", request.path) return jsonify({"error": "Unauthorized"}), 401 return f(*args, **kwargs) return wrapped +def require_key(f): + """Require a valid X-Trigger-Key header.""" + return _key_guard(f) + + +def require_key_or_query(f): + """Like require_key, but also accepts ?key= (SSE only).""" + return _key_guard(f, allow_query=True) + + +def require_key_or_first_run(f): + """Like require_key, but open to loopback while TRIGGER_KEY is unset.""" + return _key_guard(f, first_run_loopback=True) + + # ── Log helper ──────────────────────────────────────────────────────────────── _LOG_DIR = BASE_DIR / "outputs" / "logs" -_LOG_DIR.mkdir(parents=True, exist_ok=True) +try: + _LOG_DIR.mkdir(parents=True, exist_ok=True) +except OSError: + pass # already warned above; _log() tolerates write failures +_log_write_warned = False def _log(line: str): # Suppress repeated render progress lines — keep only latest @@ -238,7 +360,11 @@ def _log(line: str): with open(log_file, "a") as f: f.write(f"{datetime.now(timezone.utc).isoformat()} {line}\n") except Exception as e: - _log(f"[Server] Warning: failed to write pipeline log: {e}") + # Must not call _log() here (infinite recursion); warn once on stderr. + global _log_write_warned + if not _log_write_warned: + _log_write_warned = True + print(f"[Server] Warning: failed to write pipeline log: {e}", file=sys.stderr) # ── Pipeline runner ─────────────────────────────────────────────────────────── @@ -263,6 +389,8 @@ def _run_thread(topic: str, resume_from: int, topic_id: str = None): "started_at": datetime.now(timezone.utc).isoformat(), "finished_at": None, }) + # Clear any stale kill flag from a previous run + _state.pop("_killed", None) try: _proc = subprocess.Popen( @@ -433,7 +561,7 @@ def status(): @app.route("/stream") -@require_key +@require_key_or_query def stream(): """Server-Sent Events stream for real-time dashboard updates.""" def generate(): @@ -665,6 +793,10 @@ def kill(): with _lock: proc = _proc is_running = _state.get("running", False) + if proc and is_running: + # Signal _run_thread BEFORE terminating, so it can't observe the + # exit first and overwrite the "killed" status. + _state["_killed"] = True if proc and is_running: proc.terminate() try: @@ -675,7 +807,6 @@ def kill(): _state["running"] = False _state["last_status"] = "killed" _state["finished_at"] = datetime.now(timezone.utc).isoformat() - _state["_killed"] = True # Signal to _run_thread _log("[Server] Pipeline killed by user") return jsonify({"ok": True, "message": "Pipeline terminated"}) return jsonify({"error": "No pipeline running"}), 400 @@ -1746,7 +1877,7 @@ def api_optimizer_pause(): @app.route("/api/setup/status") -@require_key +@require_key_or_first_run def api_setup_status(): """Return setup status: which keys are configured, current profile, providers.""" keys_status = [] @@ -1766,7 +1897,7 @@ def api_setup_status(): pass # List available profiles - profiles_dir = BASE_DIR / "profiles" + profiles_dir = PROFILES_DIR available_profiles = [] if profiles_dir.exists(): for f in sorted(profiles_dir.glob("*.yaml")): @@ -1817,11 +1948,12 @@ def api_setup_status(): "providers": providers, "available_providers": available_providers, "setup_complete": required_configured, + "trigger_key_configured": bool(TRIGGER_KEY), }) @app.route("/api/setup/validate", methods=["POST"]) -@require_key +@require_key_or_first_run def api_setup_validate(): """Validate an API key by making a lightweight test call.""" data = request.get_json(silent=True) or {} @@ -1909,84 +2041,307 @@ def api_setup_validate(): return jsonify(result) +_SETUP_ALLOWED_KEYS = frozenset(entry["key"] for entry in _SETUP_API_KEYS) +_ENV_VALUE_FORBIDDEN = re.compile(r"[\r\n\x00]") +_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_CUSTOM_PROVIDER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$") +_AUTO_PROVIDER_TYPES = frozenset({"music", "sfx"}) + + +def _available_profile_names() -> set: + if not PROFILES_DIR.exists(): + return set() + return { + f.stem for f in PROFILES_DIR.glob("*.yaml") + if not f.name.startswith("_") + } + + +def _builtin_providers() -> dict: + try: + from providers.registry import list_providers + return list_providers() + except Exception: + return {} + + +def _validate_setup_payload(data: dict): + """Validate a /api/setup/save payload. + + Returns (keys, profile, providers, errors) with cleaned values. + """ + errors = [] + + # ── API keys: allow-listed names, single-line values ── + keys_in = data.get("keys") or {} + keys = {} + if not isinstance(keys_in, dict): + errors.append("'keys' must be an object") + else: + for k, v in keys_in.items(): + if k not in _SETUP_ALLOWED_KEYS: + errors.append(f"Unknown key: {str(k)[:64]!r}") + continue + if v is None: + continue + if not isinstance(v, str): + errors.append(f"Value for {k} must be a string") + continue + v = v.strip() + if _ENV_VALUE_FORBIDDEN.search(v): + errors.append(f"Value for {k} contains a line break or NUL") + continue + if v: + keys[k] = v + + # ── Profile: must be an existing profiles/.yaml ── + profile = data.get("profile") + if profile is not None and profile != "": + if (not isinstance(profile, str) + or not _PROFILE_NAME_RE.match(profile) + or profile.startswith("_") + or profile not in _available_profile_names()): + errors.append(f"Unknown profile: {str(profile)[:64]!r}") + profile = None + else: + profile = None + + # ── Providers: built-in name, 'auto' (music/sfx), or custom dotted path ── + providers_in = data.get("providers") or {} + providers = {} + if not isinstance(providers_in, dict): + errors.append("'providers' must be an object") + else: + builtins = _builtin_providers() + for ptype, pname in providers_in.items(): + if ptype not in builtins: + errors.append(f"Unknown provider type: {str(ptype)[:32]!r}") + continue + if pname is None or pname == "": + continue # unchanged + if not isinstance(pname, str): + errors.append(f"Provider name for {ptype} must be a string") + continue + pname = pname.strip() + if (pname in builtins.get(ptype, []) + or (pname == "auto" and ptype in _AUTO_PROVIDER_TYPES) + or _CUSTOM_PROVIDER_RE.match(pname)): + providers[ptype] = pname + else: + errors.append( + f"Invalid provider for {ptype}: {pname[:64]!r} " + "(use a built-in name or a dotted path like my_pkg.module.ClassName)" + ) + + return keys, profile, providers, errors + + +def _atomic_write_text(path: Path, content: str, default_mode: int = 0o644): + """Write via tmp + os.replace, keeping the file's mode (new files get + ``default_mode``). Falls back to an in-place write if the target can't be + replaced (e.g. it is a single-file bind mount).""" + try: + mode = path.stat().st_mode & 0o777 + except FileNotFoundError: + mode = default_mode + tmp = path.with_name(path.name + ".tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + with os.fdopen(fd, "w") as f: + f.write(content) + os.chmod(tmp, mode) + try: + os.replace(tmp, path) + except OSError: + os.unlink(tmp) + with open(path, "w") as f: + f.write(content) + + +def _update_env_file(path: Path, updates: dict): + """Set KEY=value lines in a .env file, preserving comments and order.""" + lines = [] + if path.exists(): + with open(path) as f: + lines = f.read().splitlines() + else: + lines = [ + "# Obsidian Engine — Environment Configuration", + "# Auto-saved by Setup Wizard", + "", + ] + remaining = dict(updates) + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + k = stripped.split("=", 1)[0].strip() + if k.startswith("export "): + k = k[len("export "):].strip() + if k in remaining: + lines[i] = f"{k}={remaining.pop(k)}" + for k, v in remaining.items(): + lines.append(f"{k}={v}") + _atomic_write_text(path, "\n".join(lines) + "\n", default_mode=0o600) + + +def _read_env_value(path: Path, key: str) -> str: + if not path.exists(): + return "" + with open(path) as f: + for line in f: + s = line.strip() + if s.startswith("#") or "=" not in s: + continue + k, v = s.split("=", 1) + if k.strip() == key: + return v.strip().strip('"').strip("'") + return "" + + +_YAML_KV_RE = re.compile(r"^(?P[ \t]*[A-Za-z0-9_]+:[ \t]*)(?P.*?)(?P[ \t]+#.*)?$") + + +def _indent_of(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + +def _is_content(line: str) -> bool: + s = line.strip() + return bool(s) and not s.startswith("#") + + +def _yaml_set_value(line: str, value: str) -> str: + m = _YAML_KV_RE.match(line) + if not m: + return line + return f"{m.group('prefix').rstrip()} {value}{m.group('comment') or ''}" + + +def _yaml_block_end(lines: list, start: int) -> int: + """Index just past the block owned by the key at lines[start].""" + base = _indent_of(lines[start]) + end = start + 1 + last_content = start + while end < len(lines): + if _is_content(lines[end]): + if _indent_of(lines[end]) <= base: + break + last_content = end + end += 1 + return last_content + 1 + + +def _yaml_set_profile(content: str, profile: str) -> str: + lines = content.split("\n") + for i, line in enumerate(lines): + if re.match(r"^profile:", line): + lines[i] = _yaml_set_value(line, profile) + return "\n".join(lines) + return content.rstrip("\n") + f"\nprofile: {profile}\n" + + +def _yaml_set_provider(content: str, ptype: str, pname: str) -> str: + """Set providers..name via a targeted line edit (keeps comments).""" + lines = content.split("\n") + pidx = next((i for i, ln in enumerate(lines) if re.match(r"^providers:[ \t]*(#.*)?$", ln)), None) + if pidx is None: + return content.rstrip("\n") + f"\nproviders:\n {ptype}:\n name: {pname}\n" + pend = _yaml_block_end(lines, pidx) + child_indent = next( + (_indent_of(lines[i]) for i in range(pidx + 1, pend) if _is_content(lines[i])), 2, + ) + type_re = re.compile(rf"^[ ]{{{child_indent}}}{re.escape(ptype)}:[ \t]*(#.*)?$") + tidx = next((i for i in range(pidx + 1, pend) if type_re.match(lines[i])), None) + if tidx is None: + pad = " " * child_indent + lines[pend:pend] = [f"{pad}{ptype}:", f"{pad}{pad}name: {pname}"] + return "\n".join(lines) + tend = _yaml_block_end(lines, tidx) + grand_indent = next( + (_indent_of(lines[i]) for i in range(tidx + 1, tend) if _is_content(lines[i])), + child_indent * 2, + ) + for key in ("name", "provider"): + name_re = re.compile(rf"^[ ]{{{grand_indent}}}{key}:") + for i in range(tidx + 1, tend): + if name_re.match(lines[i]): + lines[i] = _yaml_set_value(lines[i], pname) + return "\n".join(lines) + lines.insert(tidx + 1, f"{' ' * grand_indent}name: {pname}") + return "\n".join(lines) + + @app.route("/api/setup/save", methods=["POST"]) -@require_key +@require_key_or_first_run def api_setup_save(): """Save setup configuration to .env and obsidian.yaml.""" + global TRIGGER_KEY + if not _same_origin_json_post(): + return jsonify({"error": "Setup save requires a same-origin JSON request"}), 403 data = request.get_json(silent=True) or {} + if not isinstance(data, dict): + data = {} + ip = request.remote_addr or "unknown" saved = [] errors = [] - # Save API keys to .env - keys_to_save = data.get("keys", {}) - if keys_to_save: - env_path = BASE_DIR / ".env" + keys_to_save, profile, providers_config, errors = _validate_setup_payload(data) + if errors: + _audit(ip, "SETUP_REJECTED", "; ".join(errors)) + return jsonify({"saved": [], "errors": errors, "success": False}), 400 + + # First run (no TRIGGER_KEY yet): adopt one already in .env, or mint one. + generated_key = None + env_updates = dict(keys_to_save) + if not TRIGGER_KEY: + existing = _read_env_value(ENV_PATH, "TRIGGER_KEY") + if existing: + TRIGGER_KEY = existing + os.environ["TRIGGER_KEY"] = existing + else: + generated_key = secrets.token_urlsafe(32) + env_updates["TRIGGER_KEY"] = generated_key + + # Save API keys (and generated TRIGGER_KEY) to .env + if env_updates: try: - # Read existing .env - existing = {} - if env_path.exists(): - with open(env_path) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and "=" in line: - k, v = line.split("=", 1) - existing[k.strip()] = v.strip() - - # Update with new keys + _update_env_file(ENV_PATH, env_updates) for k, v in keys_to_save.items(): - if v and v.strip(): - existing[k] = v.strip() - # Also set in current process - os.environ[k] = v.strip() - saved.append(k) - - # Write .env - with open(env_path, "w") as f: - f.write("# Obsidian Engine — Environment Configuration\n") - f.write("# Auto-saved by Setup Wizard\n\n") - for k, v in sorted(existing.items()): - f.write(f"{k}={v}\n") - + os.environ[k] = v # also apply to the running process + saved.append(k) + if generated_key: + os.environ["TRIGGER_KEY"] = generated_key + TRIGGER_KEY = generated_key + _audit(ip, "TRIGGER_KEY_GENERATED", "first-run setup wizard") except Exception as e: + generated_key = None errors.append(f"Failed to save .env: {e}") - # Save profile to obsidian.yaml - profile = data.get("profile") - providers_config = data.get("providers") + # Save profile / providers to obsidian.yaml (targeted edits keep comments) if profile or providers_config: - yaml_path = BASE_DIR / "obsidian.yaml" try: - content = yaml_path.read_text() - + content = CONFIG_YAML_PATH.read_text() if profile: - import re - content = re.sub( - r'^profile:\s*\S+', - f'profile: {profile}', - content, - flags=re.MULTILINE, - ) + content = _yaml_set_profile(content, profile) saved.append(f"profile={profile}") - - if providers_config: - for ptype, pname in providers_config.items(): - import re - # Find the provider section and update the name - pattern = rf'( {ptype}:\n name:\s*)\S+' - replacement = rf'\g<1>{pname}' - content = re.sub(pattern, replacement, content) - saved.append(f"providers.{ptype}={pname}") - - yaml_path.write_text(content) - + for ptype, pname in providers_config.items(): + content = _yaml_set_provider(content, ptype, pname) + saved.append(f"providers.{ptype}={pname}") + _atomic_write_text(CONFIG_YAML_PATH, content) except Exception as e: errors.append(f"Failed to save obsidian.yaml: {e}") - return jsonify({ + _audit(ip, "SETUP_SAVED", ", ".join(saved) or "(nothing)") + resp = { "saved": saved, "errors": errors, "success": len(errors) == 0, - }) + } + if generated_key: + # Returned exactly once so the operator/dashboard can store it. + resp["trigger_key"] = generated_key + resp["trigger_key_generated"] = True + return jsonify(resp) def require_login(f): @@ -2019,15 +2374,62 @@ def dashboard_assets(filename): return send_from_directory(str(assets_dir), filename) +# ── Login rate limiting (in-memory, per IP) ────────────────────────────────── + +LOGIN_MAX_ATTEMPTS = 10 +LOGIN_WINDOW_SECONDS = 15 * 60 +_login_attempts = defaultdict(list) # ip -> [timestamps] + + +def _login_rate_limited(ip: str) -> bool: + """Record a login attempt; True if the IP exceeded the window budget.""" + with _rate_lock: + attempts = _cleanup_timestamps(_login_attempts[ip], LOGIN_WINDOW_SECONDS) + if len(attempts) >= LOGIN_MAX_ATTEMPTS: + _login_attempts[ip] = attempts + return True + attempts.append(_time.time()) + _login_attempts[ip] = attempts + return False + + +_NO_PASSWORD_HTML = """ +OBSIDIAN ARCHIVE + +

REMOTE ACCESS DISABLED

+

The dashboard can only be used from this machine because DASHBOARD_PASSWORD +is not set. Set DASHBOARD_PASSWORD (and TRIGGER_KEY) in .env and restart the +server to enable remote access.

+""" + + @app.route("/login", methods=["GET", "POST"]) def login(): if not DASHBOARD_PASSWORD: - return redirect("/") + if _is_loopback(): + return redirect("/") + # Don't bounce remote clients back to "/" (the dashboard redirects + # 401s to /login, which would loop forever). + return _NO_PASSWORD_HTML, 403, {"Content-Type": "text/html; charset=utf-8"} if request.method == "POST": + ip = request.remote_addr or "unknown" + if _login_rate_limited(ip): + _audit(ip, "LOGIN_RATE_LIMITED", + f">{LOGIN_MAX_ATTEMPTS} attempts in {LOGIN_WINDOW_SECONDS}s") + return ( + _LOGIN_HTML.replace("__ERROR__", "Too many attempts, try again later"), + 429, + {"Content-Type": "text/html"}, + ) pw = request.form.get("password", "") - if hmac.compare_digest(pw, DASHBOARD_PASSWORD): + if hmac.compare_digest(pw.encode("utf-8"), DASHBOARD_PASSWORD.encode("utf-8")): + session.clear() session['authenticated'] = True + with _rate_lock: + _login_attempts.pop(ip, None) + _audit(ip, "LOGIN_OK", "dashboard session") return redirect("/") + _audit(ip, "LOGIN_FAILED", "invalid password") return ( _LOGIN_HTML.replace("__ERROR__", "Invalid password"), 200, @@ -2046,32 +2448,73 @@ def logout(): return redirect("/login") +def _dashboard_key_for_request() -> str: + """TRIGGER_KEY to embed in the dashboard page, or "" if this viewer + isn't trusted with it (needs a password session or a loopback client).""" + if not TRIGGER_KEY: + return "" + if DASHBOARD_PASSWORD and session.get("authenticated"): + return TRIGGER_KEY + if _is_loopback(): + return TRIGGER_KEY + return "" + + +def _js_string_escape(value: str) -> str: + """Escape for embedding inside a quoted JS string in an HTML ') == 'a\\"b\\u0027c\\u003c/script\\u003e' + + +# ── Misc hardening ─────────────────────────────────────────────────────────── + +class TestMisc: + def test_audit_clean_strips_crlf(self): + assert "\n" not in ws._audit_clean("a\nb\rc") + assert "\r" not in ws._audit_clean("a\nb\rc") + + def test_topic_newlines_removed(self): + topic, err = ws._validate_topic("The Dark\nHistory\r\nof Things") + assert err is None + assert "\n" not in topic and "\r" not in topic + + def test_default_host_is_loopback(self): + # Module default when HOST is unset in the environment + if not os.getenv("HOST"): + assert ws.HOST == "127.0.0.1" + + def test_kill_sets_flag_before_terminate(self, client, with_key): + seen = {} + + class FakeProc: + def terminate(self): + seen["killed_flag"] = ws._state.get("_killed") + + def wait(self, timeout=None): + return 0 + + with ws._lock: + ws._state["running"] = True + old_proc = ws._proc + ws._proc = FakeProc() + try: + r = client.post("/kill", headers={"X-Trigger-Key": KEY}) + assert r.status_code == 200 + assert seen["killed_flag"] is True + assert ws._state["last_status"] == "killed" + finally: + ws._proc = old_proc + with ws._lock: + ws._state["running"] = False + ws._state.pop("_killed", None) + ws._state["last_status"] = "idle" + + def test_profile_loader_rejects_traversal(self): + from core.profile import _load_profile + prof = _load_profile("../obsidian") + doc = _load_profile("documentary") + assert prof == doc + + +# ── DNS rebinding / cross-site POST ────────────────────────────────────────── + +EVIL_BASE = "http://evil.example:8080" + + +class TestRebindingAndCrossSite: + def test_first_run_save_rejected_for_foreign_host(self, client, no_key, setup_files): + env_path, _ = setup_files + r = client.post("/api/setup/save", json={"keys": {"FAL_KEY": "x"}}, + environ_base=LOCAL, base_url=EVIL_BASE) + assert r.status_code == 503 + assert not env_path.exists() + assert ws.TRIGGER_KEY == "" + + def test_dashboard_key_not_injected_for_foreign_host(self, client, with_key, monkeypatch): + monkeypatch.setattr(ws, "DASHBOARD_PASSWORD", "") + r = client.get("/", environ_base=LOCAL, base_url=EVIL_BASE) + assert KEY not in r.get_data(as_text=True) + + @pytest.mark.parametrize("host", ["127.0.0.1:8080", "localhost", "[::1]:8080"]) + def test_loopback_hosts_still_trusted(self, client, with_key, monkeypatch, host): + monkeypatch.setattr(ws, "DASHBOARD_PASSWORD", "") + r = client.get("/", environ_base=LOCAL, base_url=f"http://{host}") + assert KEY in r.get_data(as_text=True) + + def test_text_plain_post_rejected(self, client, no_key, setup_files): + env_path, _ = setup_files + r = client.post("/api/setup/save", data="{}", content_type="text/plain", + environ_base=LOCAL) + assert r.status_code == 403 + assert not env_path.exists() + + def test_cross_origin_post_rejected(self, client, with_key, setup_files): + r = client.post("/api/setup/save", json={"keys": {"FAL_KEY": "x"}}, + headers={"X-Trigger-Key": KEY, "Origin": "https://evil.example"}, + environ_base=LOCAL) + assert r.status_code == 403 + + def test_same_origin_post_allowed(self, client, with_key, setup_files): + r = client.post("/api/setup/save", json={"keys": {"FAL_KEY": "x"}}, + headers={"X-Trigger-Key": KEY, "Origin": "http://localhost"}, + environ_base=LOCAL) + assert r.status_code == 200, r.get_json() diff --git a/tests/test_webhook_server.py b/tests/test_webhook_server.py index 16457dd..adf71c3 100644 --- a/tests/test_webhook_server.py +++ b/tests/test_webhook_server.py @@ -12,6 +12,15 @@ from server.webhook_server import app, _validate_topic, _state, _lock +TEST_KEY = "test-trigger-key" + + +@pytest.fixture(autouse=True) +def trigger_key(monkeypatch): + """Protected routes require a configured TRIGGER_KEY (never open by default).""" + monkeypatch.setattr("server.webhook_server.TRIGGER_KEY", TEST_KEY) + return TEST_KEY + @pytest.fixture def client(): @@ -50,7 +59,7 @@ def test_health_returns_200(self, client): assert r.data == b"OK" def test_status_returns_json(self, client): - r = client.get("/status", headers={"X-Trigger-Key": ""}) + r = client.get("/status", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "running" in data @@ -123,34 +132,34 @@ def test_dashboard_returns_html(self, client): assert b"OBSIDIAN" in r.data def test_costs_endpoint(self, client): - r = client.get("/costs", headers={"X-Trigger-Key": ""}) + r = client.get("/costs", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "costs" in data or "error" in data def test_music_endpoint(self, client): - r = client.get("/music", headers={"X-Trigger-Key": ""}) + r = client.get("/music", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "total_tracks" in data assert "tracks_by_mood" in data def test_trends_endpoint(self, client): - r = client.get("/trends", headers={"X-Trigger-Key": ""}) + r = client.get("/trends", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 def test_audit_endpoint(self, client): - r = client.get("/audit", headers={"X-Trigger-Key": ""}) + r = client.get("/audit", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "entries" in data def test_history_endpoint(self, client): - r = client.get("/history", headers={"X-Trigger-Key": ""}) + r = client.get("/history", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 def test_stream_endpoint_sse(self, client): - r = client.get("/stream", headers={"X-Trigger-Key": ""}) + r = client.get("/stream", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 assert "text/event-stream" in r.content_type @@ -160,11 +169,11 @@ def test_trigger_rejects_running(self, client): _state["topic"] = "test" r = client.post("/trigger", json={"topic": "Another topic"}, - headers={"X-Trigger-Key": ""}) + headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 409 def test_kill_no_pipeline(self, client): - r = client.post("/kill", headers={"X-Trigger-Key": ""}) + r = client.post("/kill", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 400 @@ -189,7 +198,7 @@ def test_dashboard_is_new_or_old(self, client): class TestApiPulse: def test_pulse_returns_all_keys(self, client): - r = client.get("/api/pulse", headers={"X-Trigger-Key": ""}) + r = client.get("/api/pulse", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() expected_keys = { @@ -202,13 +211,13 @@ def test_pulse_returns_all_keys(self, client): def test_pulse_exempt_from_rate_limit(self, client): """20 rapid calls should all succeed (no rate limit on pulse).""" for _ in range(20): - r = client.get("/api/pulse", headers={"X-Trigger-Key": ""}) + r = client.get("/api/pulse", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 def test_pulse_graceful_when_supabase_down(self, client): """queue_depth should fallback to 0 when Supabase is unreachable.""" with patch("server.webhook_server._get_queue_depth", side_effect=Exception("connection refused")): - r = client.get("/api/pulse", headers={"X-Trigger-Key": ""}) + r = client.get("/api/pulse", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert isinstance(data["queue_depth"], int) @@ -220,7 +229,7 @@ def test_pulse_thread_safe(self): def hit_pulse(): try: with app.test_client() as c: - r = c.get("/api/pulse", headers={"X-Trigger-Key": ""}) + r = c.get("/api/pulse", headers={"X-Trigger-Key": TEST_KEY}) data = r.get_json() if "status" not in data: errors.append("missing status key") @@ -237,14 +246,14 @@ def hit_pulse(): class TestApiDashboard: def test_summary_always_returns_5_signals(self, client): - r = client.get("/api/dashboard?sections=summary", headers={"X-Trigger-Key": ""}) + r = client.get("/api/dashboard?sections=summary", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "summary" in data assert len(data["summary"]["signals"]) == 5 def test_performance_excludes_other_sections(self, client): - r = client.get("/api/dashboard?sections=performance", headers={"X-Trigger-Key": ""}) + r = client.get("/api/dashboard?sections=performance", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "performance" in data @@ -252,21 +261,21 @@ def test_performance_excludes_other_sections(self, client): assert "config" not in data def test_multi_section_request(self, client): - r = client.get("/api/dashboard?sections=performance,audience", headers={"X-Trigger-Key": ""}) + r = client.get("/api/dashboard?sections=performance,audience", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "performance" in data assert "audience" in data def test_invalid_section_returns_empty(self, client): - r = client.get("/api/dashboard?sections=invalid", headers={"X-Trigger-Key": ""}) + r = client.get("/api/dashboard?sections=invalid", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert data == {} def test_missing_insights_returns_empty_sections(self, client): """When channel_insights.json doesn't exist, sections return empty data.""" - r = client.get("/api/dashboard?sections=performance", headers={"X-Trigger-Key": ""}) + r = client.get("/api/dashboard?sections=performance", headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 200 data = r.get_json() assert "performance" in data @@ -275,12 +284,12 @@ def test_missing_insights_returns_empty_sections(self, client): class TestCacheControl: def test_pulse_has_no_cache(self, client): - r = client.get("/api/pulse", headers={"X-Trigger-Key": ""}) + r = client.get("/api/pulse", headers={"X-Trigger-Key": TEST_KEY}) assert "no-cache" in r.headers.get("Cache-Control", "") assert "no-store" in r.headers.get("Cache-Control", "") def test_dashboard_api_has_no_cache(self, client): - r = client.get("/api/dashboard?sections=summary", headers={"X-Trigger-Key": ""}) + r = client.get("/api/dashboard?sections=summary", headers={"X-Trigger-Key": TEST_KEY}) assert "no-cache" in r.headers.get("Cache-Control", "") def test_error_response_has_no_cache(self, client): @@ -369,5 +378,5 @@ def test_oversized_post_returns_413(self, client): r = client.post("/trigger", data=large_body, content_type="application/json", - headers={"X-Trigger-Key": ""}) + headers={"X-Trigger-Key": TEST_KEY}) assert r.status_code == 413