# MaxEditor SaaS — Plan de Trabajo Detallado

**Última actualización:** 25 abril 2026
**Documento de arquitectura:** [SAAS_ARCHITECTURE.md](SAAS_ARCHITECTURE.md)

> **Filosofía de este plan:** 90% planificación, 10% ejecución. Cada fase tiene tareas
> verificables y entregables concretos. No se avanza a la siguiente sin completar la actual.
> Las fases 0–3 entregan la **versión oficina** (uso interno). Las fases 4–6 agregan
> el **SaaS público** sobre la misma base.

---

## Resumen ejecutivo

| Fase | Nombre | Entregable |
|---|---|---|
| **0** | Preparación + Refactor base | Backend con auth básica, paquete `media_engine` separado |
| **1** | Companion App MVP | `.exe` que abre browser, FFmpeg local, login funciona |
| **2** | Migración de FFmpeg al companion | Export, auto-frame, stabilization corren localmente |
| **3** | Empaquetado + Deploy oficina | Instalador firmado, server en HawkHost, oficina usándolo |
| **4** | Stripe + Suscripciones | Pagos integrados, trials, webhooks |
| **5** | Marketing site + Onboarding | Landing en altoque.tv, signup público |
| **6** | Hardening + Auto-update | Squirrel/NSIS, monitoring, observabilidad |

---

## ¿Qué tenemos hoy? (línea base)

### ✅ Lo que ya funciona
- Backend Django con ~50 endpoints REST funcionando
- Frontend React con editor completo, timeline, transcript-first workflow
- Procesamiento FFmpeg robusto en `services.py` (~12K LOC)
- Transcripción local con `faster-whisper` + `stable-ts`
- Auto-frame, stabilization, audio enhancement, export funcionando
- AI suggestions (depuration, thematic, refinement) operativas
- Modelos de datos sólidos: Project, VideoAsset, Clip, Transcript, ExportJob

### ❌ Lo que falta (visión general)
- Sistema de autenticación (no existe ninguno)
- Modelo User customizado y Subscription
- Empaquetado de FFmpeg/lógica de procesamiento como módulo portable
- Companion app entera (cero código)
- API client centralizado en frontend
- Páginas de login / signup
- Configuración de producción (PostgreSQL, uWSGI, nginx)
- Integración Stripe
- Sistema de versionado del companion
- Instalador Windows

### ⚠️ Lo que hay que **modificar**
- `VideoAsset.file` (FileField) → reemplazar por `local_path` + hash
- `media_serve.py` → eliminar (lo hace el companion)
- Endpoints FFmpeg → marcar como "deprecated" durante transición, luego remover
- `services.py` → extraer FFmpeg/ML a paquete `media_engine`
- `settings.py` → CORS, ALLOWED_HOSTS, DATABASES para producción
- `start-backend.ps1` → solo para dev local, agregar config de producción separada

---

## FASE 0 — Preparación y refactor base

**Objetivo:** Dejar el server listo para auth y separar el código de FFmpeg sin romper nada.

**Duración estimada:** 5–7 días

### 0.1 Setup de auth en Django

- [ ] Instalar `djangorestframework-simplejwt`
- [ ] Crear app `accounts` (`backend/apps/accounts/`)
- [ ] Modelo `User(AbstractUser)` con `email` como `USERNAME_FIELD`
- [ ] Modelo `Subscription` (sin Stripe aún — solo `status` y `plan`)
- [ ] Configurar `AUTH_USER_MODEL = 'accounts.User'` en settings
- [ ] **CRITICAL:** Migrar BD existente (los proyectos quedan huérfanos hasta asignar dueño)
- [ ] Comando de management: `python manage.py create_admin_user --email <e>` para bootstrap
- [ ] Endpoints:
  - `POST /api/auth/register`
  - `POST /api/auth/login` → retorna `{access, refresh}`
  - `POST /api/auth/refresh`
  - `POST /api/auth/logout`
  - `GET /api/auth/me`
- [ ] Tests unitarios de auth

**Entregable:** Login funciona vía curl/Postman. Endpoints existentes siguen sin auth (todavía).

### 0.2 Asignar dueño a proyectos existentes

- [ ] Agregar `Project.owner = ForeignKey(User, null=True)` (nullable inicialmente)
- [ ] Migración de datos: asignar todos los proyectos al primer usuario admin
- [ ] Hacer `owner` no-null en migración siguiente
- [ ] Filtrar querysets por `owner=request.user` en views (cuando agreguemos auth)

### 0.3 Extraer `media_engine` como paquete

- [ ] Crear `backend/media_engine/` con:
  ```
  media_engine/
    __init__.py
    ffmpeg/         # wrappers FFmpeg
      probe.py
      extract.py
      auto_frame.py
      stabilization.py
      export.py
      audio_enhancement.py
    ml/
      whisper.py
      stable_ts.py
    utils/
      paths.py
      progress.py    # SSE/callback genérico
    setup.py         # pip-installable
  ```
- [ ] Extraer funciones de `services.py` por dominio
- [ ] Mantener `services.py` como **fachada** que importa de `media_engine`
- [ ] **Tests:** correr toda la suite de tests existente, no debe romper nada
- [ ] Verificar que `pip install ./media_engine` funciona

**Entregable:** `media_engine` se puede instalar e importar de forma aislada. Server sigue funcionando idéntico.

### 0.4 API client centralizado en frontend

- [ ] Crear `frontend/src/api/client.js`
  ```js
  const baseURL = import.meta.env.VITE_API_BASE_URL || ''
  const companionURL = 'https://localhost:7432'
  // axios instance con interceptor para JWT
  // adapter que decide server vs companion según endpoint
  ```
- [ ] Migrar **una página piloto** (ej. `useProjectIntakeWorkflow.js`) al cliente nuevo
- [ ] Verificar que sigue funcionando con backend Django
- [ ] Migrar resto de llamadas progresivamente (paralelo a fases siguientes)

### 0.5 Configuración de producción base ✅ HECHO

Implementado **un único `settings.py` env-driven** (en lugar de duplicar
`settings_prod.py`) con switch `DJANGO_ENV=production` que activa
hardening + validación fail-fast. Es más mantenible que dos archivos
divergentes y es el patrón Django moderno.

- [x] `settings.py` env-driven con `DJANGO_ENV=development|production`
  - En `production` exige (fail-fast con `RuntimeError`):
    `SECRET_KEY` ≠ default, `ALLOWED_HOSTS` ≠ default, `BYOK_FERNET_KEY` presente.
  - `DEBUG = env("DEBUG") and not IS_PRODUCTION` → `DEBUG=True` en .env queda anulado en prod.
- [x] Variables de entorno: `SECRET_KEY`, `DATABASE_URL`, `ALLOWED_HOSTS`,
      `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS`, `BYOK_FERNET_KEY`,
      `DJANGO_USE_HTTPS`, `LOG_DIR`, `OPENAI_API_KEY`.
- [x] Soporte Postgres opcional vía `DATABASE_URL` (si vacío → SQLite local).
- [x] WhiteNoise integrado (sirve estáticos sin nginx, compresión + manifest).
- [x] Hardening completo cuando `DJANGO_USE_HTTPS=True`:
      HSTS 1 año + `INCLUDE_SUBDOMAINS`, `SECURE_REFERRER_POLICY=same-origin`,
      `X_FRAME_OPTIONS=DENY`, `SECURE_CONTENT_TYPE_NOSNIFF=True`,
      cookies `Secure`, `SECURE_PROXY_SSL_HEADER`, `SECURE_SSL_REDIRECT`.
- [x] Logging a archivo rotado opcional (`LOG_DIR` env var).
- [x] `backend/requirements-prod.txt` (psycopg2-binary, gunicorn, hereda base).
- [x] `backend/requirements.txt` ahora incluye `whitenoise` (necesario también para `manage.py check` en dev).
- [x] `.env.example` en raíz documentando todas las variables.
- [x] [`docs/DEPLOY.md`](DEPLOY.md) con guía completa: provisión, env vars,
      gunicorn + systemd, smoke checks, rotación de Fernet key, backups.

**Validado**:
- `manage.py check` (dev) → OK.
- `DJANGO_ENV=production manage.py check --deploy` con env válido →
  solo warnings esperados de Django (`SECRET_KEY` corto del test +
  `HSTS_PRELOAD` opt-in).
- `DJANGO_ENV=production` sin `SECRET_KEY/BYOK_FERNET_KEY` → aborta con
  mensaje accionable (incluye comando para generar Fernet key).


**🎯 Hito Fase 0 cumplido cuando:**
- ✅ Login con JWT funciona end-to-end vía API
- ✅ `media_engine` instalable como paquete
- ✅ Tests existentes pasan al 100%
- ✅ Frontend tiene `api/client.js` y al menos una página migrada

---

## FASE 1 — Companion App MVP

**Objetivo:** Tener un `.exe` que se ejecuta, abre el browser, y permite hacer login.
Aún no procesa videos — solo el esqueleto.

**Duración estimada:** 7–10 días

### 1.1 Estructura del proyecto companion ✅ HECHO (esqueleto)

- [x] Crear directorio nuevo en el repo: `companion/`
- [x] Estructura inicial creada (lo no marcado entra en fases siguientes):
  ```
  companion/
    pyproject.toml             ✅
    requirements.txt           ✅
    README.md                  ✅
    src/
      maxeditor_companion/
        __init__.py            ✅ (__version__ = "0.1.0")
        __main__.py            ✅ entry point
        config.py              ✅ env-driven (host, port, log_dir, token, origins)
        logging_setup.py       ✅ RotatingFileHandler
        security.py            ✅ middleware X-Companion-Token
        server.py              ✅ create_app(config)
        routes/
          __init__.py          ✅
          health.py            ✅ GET /health
          auth.py              ⏳ Fase 1.4
          file_dialog.py       ⏳ Fase 1.5.bis
        cert.py                ⏳ Fase 1.3
        crypto.py              ⏳ Fase 1.4 (DPAPI)
        tray.py                ⏳ Fase 1.5
        services/
          ffmpeg_installer.py  ⏳ Fase 2
    build.py                   ⏳ Fase 1.7 (PyInstaller)
  ```

### 1.2 Servidor FastAPI local ✅ HECHO

- [x] Endpoint `GET /health` — retorna `{status, service, version, session_token}` (público).
- [x] Middleware `CompanionTokenMiddleware`: rechaza requests sin `X-Companion-Token` correcto (compare_digest, constante de tiempo). Solo `/health` y preflight `OPTIONS` quedan exentos.
- [x] CORS abierto a `https://altoque.tv`, `http://127.0.0.1:5173`, `http://localhost:5173`. Configurable vía `COMPANION_ALLOWED_ORIGINS`.
- [x] Logging a `%LOCALAPPDATA%\MaxEditor\logs\companion.log` (RotatingFileHandler 10 MB × 5). Fallback a `./logs/` en non-Windows.
- [x] OpenAPI/docs/redoc desactivados (`openapi_url=None`) para no exponer surface en local.
- [x] Smoke test verificado:
  - `GET /health` sin token → 200 OK con metadata.
  - `GET /foo` sin token → 401 (no leak de existencia de rutas).
  - `GET /foo` con token correcto → 404 (Not Found real).

**Run en dev**:
```pwsh
cd companion
..\.venv\Scripts\python.exe -m maxeditor_companion
# Escucha en http://127.0.0.1:7432
# Token random impreso en stdout y log; o forzar con $env:COMPANION_TOKEN
```

### 1.3 Cert local autofirmado ✅ HECHO (sin install en cert store)

- [x] Al primer inicio: generar cert con `cryptography` (RSA-2048, SAN para `localhost`, `127.0.0.1`, `::1`).
- [x] Guardar en `%LOCALAPPDATA%\MaxEditor\cert.pem` y `cert-key.pem` (configurable con `COMPANION_CERT_DIR`).
- [x] uvicorn arranca con `ssl_keyfile` y `ssl_certfile`.
- [x] Renovación automática: si quedan <30 días de validez, regenera (`ensure_cert` en cada arranque).
- [x] Toggle `COMPANION_USE_SSL=0` para desactivar (útil en CI / curl rápido).
- [ ] **Pendiente para Fase 1.7**: instalar cert en cert store de Windows (vía `certutil` en el instalador firmado). Hasta entonces, el browser muestra warning de cert no confiable la primera vez.

**Archivos**: [companion/src/maxeditor_companion/cert.py](../companion/src/maxeditor_companion/cert.py)
**Smoke**: `curl -k https://127.0.0.1:7432/health` → 200 OK con SSL.

### 1.4 Almacenamiento cifrado (DPAPI) ✅ HECHO

- [x] `crypto.py` con `protect(data: bytes) -> bytes` y `unprotect(blob: bytes) -> bytes`.
  - Backend Windows: **DPAPI** (CryptProtectData / CryptUnprotectData) — cifrado per-user, OS-managed.
  - Backend non-Windows (dev/CI): Fernet con clave derivada de `COMPANION_FERNET_KEY` o de `USER + salt fijo`.
  - Lanza `CryptoError` en blob inválida.
- [x] `token_store.py`: `save_tokens / load_tokens / delete_tokens` (escritura atómica con `.tmp` + replace).
- [x] Endpoints en `routes/auth.py`:
  - `GET    /auth/token` → 200 con `{access, refresh}` o 404.
  - `POST   /auth/token` → 204 (body validado con Pydantic).
  - `DELETE /auth/token` → 204 si existía, 404 si no.
- [x] Todos protegidos con `X-Companion-Token` (middleware global).
- [x] **Verificación on-disk**: el archivo `auth.dat` (314 bytes) **no contiene** los tokens en plaintext.

**Smoke 7/7**: GET 404 → POST 204 → GET 200 → on-disk binario cifrado → DELETE 204 → GET 404 → DELETE idempotente 404.

### 1.5 Tray icon y ciclo de vida ✅ HECHO

- [x] [companion/src/maxeditor_companion/tray.py](../companion/src/maxeditor_companion/tray.py): `pystray.Icon` con menú "Abrir editor" / "Salir".
  - Icono dibujado en runtime con Pillow (círculo naranja + "M") — placeholder hasta tener asset oficial.
  - `pystray + Pillow` son **opcionales** (`pip install -e companion[tray]`). Si faltan → modo headless.
- [x] [companion/src/maxeditor_companion/server_runner.py](../companion/src/maxeditor_companion/server_runner.py): `CompanionServer` corre uvicorn en thread daemon, con `shutdown()` limpio (`server.should_exit=True` + `join(timeout=5)`).
- [x] "Abrir editor" → `webbrowser.open(COMPANION_EDITOR_URL)` (default `https://altoque.tv/maxeditor`).
- [x] "Salir" → `server.shutdown()` + `icon.stop()`.
- [x] Auto-abre browser **una sola vez** en first-run (marker `.browser-opened` en `cert_dir`). Skipeable con `COMPANION_AUTO_OPEN=0`.
- [x] Modo headless: SIGINT/SIGTERM → shutdown limpio. `COMPANION_HEADLESS=1` lo fuerza aún con pystray instalado.
- [x] Smoke verificado: server arranca/responde/se detiene; `tray_available()` correcto con/sin pystray; marker first-run respetado.

**Variables nuevas**: `COMPANION_HEADLESS`, `COMPANION_AUTO_OPEN`, `COMPANION_EDITOR_URL`.

### 1.5.bis Workspace local del usuario ✅ HECHO (backend)

- [x] Default sugerido: `%USERPROFILE%\MaxEditor\`.
- [x] Estructura auto-creada: `projects/`, `exports/`, `proxies/`, `cache/`.
- [x] Path guardado **cifrado con DPAPI** en `workspace.dat` (mismo backend que tokens).
- [x] Endpoint `GET /workspace` → `{path, is_default, exists, subfolders}`.
- [x] Endpoint `POST /workspace/change-folder`:
  - Con `path` explícito (modo programático del frontend).
  - Sin `path` → abre **folder picker nativo** con `tkinter.filedialog.askdirectory`.
- [x] Endpoint `POST /workspace/init-default` (onboarding rápido sin diálogo).
- [x] Endpoint `POST /file/open-folder` (abre Explorer en path).
- [x] Endpoint `POST /file/open-with-default-app` (reproductor predeterminado).
- [x] Endpoint `POST /file/reveal-in-explorer` (selecciona archivo en Explorer).
- [x] **Sandbox**: todos los endpoints `/file/*` validan que el path está dentro del workspace configurado (vía `Path.is_relative_to` post-`resolve()`). Path fuera → **403**.
- [ ] **UI** (Settings, post-export, click derecho proyecto): Fase 1.6 / Fase 2.

**Smoke 7/7 OK**: workspace vacío → 200 null; change-folder programático → 200 + subfolders en disco; rehidratación; sandbox 403 contra `C:\Windows\System32`; 404 para paths inexistentes; 204 abriendo Explorer real.

### 1.6 Integración con frontend ✅ HECHO (capa base)

- [x] **`frontend/src/api/companion.js`** nuevo:
  - `probeCompanion()` → `GET /health` con timeout 1.5s, devuelve `{baseUrl, version, sessionToken}` o `null`.
  - `createCompanionClient({baseUrl, sessionToken})` → métodos `getWorkspace`, `changeWorkspace`, `initDefaultWorkspace`, `openFolder`, `revealInExplorer`, `openWithDefaultApp`, `saveBackendTokens`, `getBackendTokens`, `clearBackendTokens`. Inyecta `X-Companion-Token` en cada call.
  - **Hook `useCompanionMode()`** con poll cada 30 s. Estados: `checking | online | offline`.
- [x] **Badge en topbar** (`components/CompanionStatusBadge.jsx`):
  - Punto verde + "Companion vX.Y.Z" cuando online.
  - Punto rojo + "Companion off" cuando offline (click → recheck).
  - Punto amarillo pulsante en checking.
- [x] **Settings → "Carpeta de trabajo (Companion)"**:
  - Muestra path, lista de subfolders con estado ok/falta.
  - Botones "Elegir carpeta…" (folder picker nativo), "Usar carpeta por defecto", "Cambiar carpeta…", "Abrir en Explorer".
  - Maneja `409 user_cancelled` cuando el usuario cierra el picker.
- [x] **LoginPage** advisory (no bloqueante):
  - Banner verde "✓ Companion vX detectada" si online.
  - Banner ámbar "⚠ Companion no detectada — modo web" si offline. La app sigue siendo usable en web puro.
- [x] **CORS verificado**: `/health` público (sin token) y `/workspace`/`/file/*` con preflight OPTIONS funcionan desde origen `http://localhost:5173` (Vite). Headers ACAO/ACAH/ACAM correctos.
- [x] **Variable env** `VITE_COMPANION_BASE_URL` (default `https://localhost:7432`) para apuntar a otro puerto en dev/CI.
- [x] **1.6.bis — Sync JWT + hard-block modal** ✅ HECHO:
  - **`CompanionContext.jsx`** nuevo: provider único que envuelve `useCompanionMode()` y se monta por encima de `<AuthProvider>` en `main.jsx`. Elimina los 3 polls duplicados que tenían App, Login y Settings.
  - Hook `useCompanion()` (lanza si no hay provider) + `useCompanionSafe()` (devuelve stub offline) para componentes fuera del árbol.
  - **Sync transparente del JWT**: `AuthContext.login()` invoca `companion.client.saveBackendTokens({access, refresh})` tras `setTokens()` (best-effort vía ref, NO bloquea login si companion offline). `logout()` llama `clearBackendTokens()` (404 silencioso = ok).
  - **`CompanionRequiredModal.jsx`** nuevo: modal hard-block reutilizable (`open`, `onClose`, `feature`, `downloadUrl`). Cierra solo cuando `companion.isOnline === true`. Botón "Reintentar conexión" llama `companion.recheck()`. Cierre con Escape. Diseño denso, sin border-radius marcado (preferencia user).
  - Smoke E2E validado: GET 404 → POST 204 → GET 200 (mismo body) → DELETE 204 → GET 404. Ciclo completo del relay funcional contra companion real.
- [ ] **Pendiente** (consumidores específicos del modal):
  - Wirear `<CompanionRequiredModal>` desde botones de export/render local cuando se ejecute Fase 2 (al hacer click en "Exportar" y companion offline → modal).

### 1.7 Empaquetado básico (sin instalador todavía) ✅ HECHO (script + cert install)

- [x] **Script `companion/build.py`** con PyInstaller `--onefile --windowed`:
  - `--hidden-import` explícitos para `pystray._win32`, `PIL._tkinter_finder`, `uvicorn.{logging,loops.auto,protocols.*,lifespan.on}`.
  - `--collect-data cryptography` (compat OpenSSL bundles).
  - `--icon=assets/icon.ico` cuando exista (fallback warning si falta).
  - Limpieza previa de `dist/`, `build/`, `*.spec`.
  - Reporta tamaño final del `.exe` y próximos pasos.
- [x] **`cert_store.py`** nuevo: instalación per-user del cert en `Cert:\CurrentUser\Root` vía `certutil -user -addstore Root`. Funciones:
  - `install_cert_user(cert_file, timeout=120)` — directa, bloqueante.
  - `uninstall_cert_user()` — para uninstaller, busca por CN.
  - `ensure_cert_installed(cert_file)` — idempotente con marker (mtime-based).
  - `ensure_cert_installed_async(cert_file)` — **lanza thread daemon**, no bloquea el server.
  - CLI: `python -m maxeditor_companion.cert_store {install|uninstall|ensure} [cert_path]`.
- [x] **Hook automático en arranque** (`__main__.py`): si HTTPS habilitado + Windows + `COMPANION_INSTALL_CERT≠0`, lanza la instalación en background. El server arranca inmediato; Windows muestra el diálogo de confirmación al usuario en first-run y, una vez aceptado, el browser confía silenciosamente.
- [x] **Hardening**: `_safe_exists()` envuelve `Path.exists()` para tolerar drives inválidos (red caída); `_no_window_flag()` (CREATE_NO_WINDOW) evita flash de consola al ejecutar certutil bajo `--windowed`.
- [x] **Smoke validados**:
  - `is_windows()` correcto.
  - `ensure_cert_installed_async(non-existent)` → None inmediato.
  - HTTPS arranca con cert generado y `/health` responde 200 con `COMPANION_INSTALL_CERT=0` (no se cuelga esperando diálogo).
  - Install/uninstall reales con cert temporal: install dispara diálogo Windows (esperado UX); uninstall limpia silenciosamente.
- [ ] **Pendiente** (requiere PC Windows con PyInstaller corriendo el build):
  - Ejecutar `python build.py` y validar tamaño + arranque del `.exe`.
  - Verificación cross-PC en Windows limpia (sin Python instalado).
  - Crear `assets/icon.ico` real (placeholder ahora).
  - Empaquetar como instalador (Fase 3, NSIS o Inno Setup).

**🎯 Hito Fase 1 cumplido cuando:**
- ⏳ `MaxEditorCompanion.exe` se ejecuta en una PC Windows limpia (build pendiente)
- ✅ Abre browser en altoque.tv automáticamente (Fase 1.5)
- ✅ Login funciona end-to-end (frontend → server → companion guarda JWT) — Fase 1.6.bis
- ✅ Tray icon presente, abre/cierra app correctamente (Fase 1.5)
- ✅ Sin FFmpeg todavía, pero el editor carga (la app web funciona en modo offline-companion)

### 1.8 GUI propia opcional (PyWebView shell) ✅ HECHO (capa base)

**Objetivo:** ofrecer un "look de aplicación nativa" además del modo browser. El editor React se reusa **sin tocar una sola línea**; solo cambia el contenedor que lo renderiza.

- [x] **`companion/src/maxeditor_companion/gui.py`** nuevo:
  - `is_available()` → True si `pywebview` está instalado.
  - `run_window(server, config)` → bloqueante en main thread (mismo patrón que tray). Crea ventana `1440×900` (mín `1100×680`), título `MaxEditor — host:port`, fondo `#101115`.
  - Backend `edgechromium` (Edge WebView2) en Windows; auto-detect en otros OS.
  - Hook `window.events.closing` → `server.shutdown()` para que cerrar la ventana cierre la app.
- [x] **`__main__.py`**: prioridad `gui > tray > headless`. Activable con `COMPANION_GUI=1`. Si pywebview no está instalado, fallback automático con warning explícito (no error).
- [x] **Sin browser first-run en modo GUI**: cuando la ventana propia es la UI, no abrimos otra pestaña en el browser default.
- [x] **`pyproject.toml`**: nuevo extra `[gui]` con `pywebview>=5.0,<6`. Instalación: `pip install -e ".[gui]"` o `[tray,gui,build]` para todo junto.
- [x] **`build.py`**: hidden imports añadidos (`webview`, `webview.platforms.edgechromium`, `webview.platforms.winforms`, `clr_loader`) + `--collect-data webview`. El `.exe` final pesa ~5–10 MB más cuando se compila con esta dependencia.
- [x] **Smoke validados**:
  - `gui.is_available()` → False sin pywebview, True con pywebview.
  - `COMPANION_GUI=1` sin pywebview → fallback a tray + warning visible en log; `/health` responde 200 OK.
  - Imports de `webview` + `gui` OK con pywebview 5.x instalado en el venv.
- [ ] **Pendiente** (requiere display + interacción humana):
  - Smoke real abriendo la ventana en escritorio (no automatizable en CI).
  - Decisión de producto: ¿GUI por defecto o opt-in? Hoy es opt-in (`COMPANION_GUI=1`). Para que sea default cuando exista pywebview, solo hace falta cambiar el default de la env.
  - Minimize-to-tray (cerrar [X] esconde a tray en vez de matar el server) → considerar Fase 1.8.bis si surge la necesidad.
  - Decidir runtime de WebView2 en el instalador (Fase 3.1): bundlear el bootstrapper de Microsoft (~2 MB) o asumirlo presente en Win10 22H2+ / Win11.

**Modelo de UI definitivo (resumen para Fase 3.1):**

| Modo | Cómo se activa | Tamaño extra | Ventana | Cuándo usar |
|---|---|---|---|---|
| Browser default | (default) | 0 MB | Pestaña Chrome/Edge/etc. | Usuario quiere DevTools, múltiples instancias, UI familiar |
| GUI propia (PyWebView) | `COMPANION_GUI=1` | +5–10 MB | Ventana embedded WebView2 | Usuario quiere "look de app nativa", taskbar item propio |

Fase 6 podría introducir un **wrapper Tauri** (más liviano, ~10–20 MB total) si surge la necesidad de móvil/macOS o se quiere bajar tamaño del bundle.

---

## FASE 2 — Migración de FFmpeg al companion

**Objetivo:** Que las operaciones de video corran en la PC del usuario, no en el server.

**Duración estimada:** 10–14 días

### 2.1 Instalador automático de FFmpeg

**Discovery (probe) ✅ HECHO** (Fase 2.1.a):

- [x] **`companion/src/maxeditor_companion/ffmpeg_probe.py`** nuevo:
  - `probe_ffmpeg(workspace_dir, use_cache=True) -> FFmpegProbeResult`.
  - Búsqueda por orden: env override `COMPANION_FFMPEG_PATH` → `shutil.which("ffmpeg")` → rutas comunes Windows (workspace `bin/`, `~/MaxEditor/bin/`, scoop, winget links, chocolatey, `C:\ffmpeg\bin\`, `C:\Program Files\ffmpeg\bin\`).
  - Parsea `-version` y extrae el sub-set de encoders/decoders relevantes: x264/x265, h264_nvenc/qsv/amf, hevc_*, vp9, av1, aac, opus, mp3, prores_ks. Frontend usa esto para habilitar opciones de export hw-accel.
  - **Caché en memoria con TTL 60 s** + `invalidate_cache()` para forzar re-probe.
  - `_no_window_flag()` (CREATE_NO_WINDOW) evita flash de consola bajo `--windowed`.
  - `_safe_exists()` envuelve `Path.is_file()` por OSError en drives Windows inválidos.
- [x] **`companion/src/maxeditor_companion/routes/system.py`** nuevo:
  - `GET /system/probe-ffmpeg?refresh=0|1` → siempre 200 con `{available, path, version, version_full, encoders, decoders, source, error, probed_at}`.
  - `source` describe origen: `PATH`, `env_override`, `common_path:<dir>`.
  - Si workspace está configurado, lo pasa como hint para buscar en `<workspace>/bin/`.
- [x] **Wired** en `server.py` con prefijo `/system`.
- [x] **Smoke 4/4 OK**:
  - 401 sin token (middleware corta antes del router).
  - 200 con token: detectó FFmpeg 7.1.1 (gyan.dev build), 14 encoders + 8 decoders, source=PATH.
  - `?refresh=1` re-ejecuta el probe (no usa caché).
  - Override `COMPANION_FFMPEG_PATH=C:\nonexistent` → log warning + fallback a PATH (cumple contrato: env solo gana si el archivo existe).

**Auto-instalación (descargar binario) ✅ HECHO** (Fase 2.1.b):

- [x] **`companion/src/maxeditor_companion/services/ffmpeg_installer.py`** nuevo:
  - `install_ffmpeg(workspace_dir, source_key, dry_run)` → generator de eventos JSON.
  - Fuente preconfigurada `gyan_essentials` (https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip + sidecar `.sha256`).
  - Descarga **streaming** con `urllib.request` (sin nuevas deps), chunks de 256 KiB.
  - Verifica SHA-256 contra el sidecar publicado por gyan; mismatch → `error` con `stage="verify"`.
  - Extrae solo `ffmpeg.exe` y `ffprobe.exe` del zip (descarta los demás binarios y docs); protección zip-slip.
  - Destino: `<workspace>/bin/` — coincide con la lista de discovery del probe, así que tras instalar el `?refresh=1` lo encuentra solo.
  - Throttling de eventos `download` cada ≥250 ms para no saturar SSE.
  - Modo `dry_run=True` simula la secuencia completa sin tocar red ni disco (smokes/dev offline).
- [x] **`POST /system/install-ffmpeg?source=gyan_essentials&dry_run=0|1`** (en `routes/system.py`):
  - Devuelve `text/event-stream` con eventos `starting → download* → verify → extract → done` (o `error`).
  - 401 sin token, 400 source desconocido, 409 si workspace no configurado.
  - Generator síncrono corrido en thread + `anyio.create_memory_object_stream` para no bloquear el event loop.
- [x] **Smokes 3/3 OK**: SSE dry_run completo (8 eventos), 401 sin token, 400 source inválido.
- [ ] UI frontend: modal "Descargando FFmpeg..." con barra de progreso vía EventSource (Fase 2.5 cuando pulamos UX del companion).

### 2.2 Endpoints FFmpeg locales

Implementar uno por uno, **siempre con el mismo patrón:**
- Server crea Job (BD) con status `queued`
- Frontend pide a companion ejecutar el job
- Companion ejecuta y reporta progreso vía SSE al frontend
- Frontend reporta status final al server (`PUT /api/.../status`)

Orden recomendado (de más simple a más complejo):

- [x] **`POST /ffmpeg/probe`** — primer endpoint, sin background ✅ HECHO (Fase 2.2.a):
  - `services/ffprobe_runner.py` con `probe_file(workspace, requested_path) → ProbeFileResult`. Reusa `find_binaries()` del módulo de discovery (asume `ffprobe` sibling de `ffmpeg`).
  - `resolve_inside_workspace(workspace, requested)`: sandbox anti-traversal vía `Path.resolve()` + `relative_to(workspace)`. Paths relativos se anclan en `<workspace>/projects/`.
  - Ejecuta `ffprobe -v error -print_format json -show_format -show_streams`. Timeout 30 s. CREATE_NO_WINDOW.
  - Devuelve `{path, size_bytes, summary, raw?}`. `summary`: duration_s, bit_rate, format_name, width, height, fps (parsea fracción `30000/1001`), video_codec, audio_codec, audio_channels, audio_sample_rate. `raw` solo si `include_raw=true`.
  - `routes/ffmpeg.py` nuevo router. Body Pydantic `ProbeRequest{path, include_raw}`. Mapeo de excepciones → 400 (traversal) / 404 (no existe) / 422 (ffprobe falla) / 503 (no hay ffprobe).
  - **Smokes 5/5 OK**: 401 sin token, 200 con sample.mp4 (1280x720@25 h264/aac), 400 traversal `../../etc/passwd`, 404 archivo inexistente, 200 con `include_raw=true` (2 streams en raw).
- [x] **`POST /file/pick`, `POST /file/pick-folder`, `POST /file/save-dialog`** ✅ HECHO (Fase 2.2.b):
  - `dialogs.py` extendido con `pick_file()` (multi opcional) y `save_file_dialog()`. `tkinter.filedialog`, sin nuevas deps.
  - `routes/file.py` nuevo. Body Pydantic con `title`, `initial_path` (defaultea a `<workspace>/projects/`), `filetypes: [{label, pattern}]`.
  - `CompanionConfig` ahora expone `headless` (bool, leído de `COMPANION_HEADLESS`). Diálogos devuelven 503 en headless.
  - **Smokes 4/4 OK**: 401 sin token + 503 en headless para los 3 endpoints.
- [x] **`POST /ffmpeg/extract-part`** ✅ HECHO (Fase 2.2.c — primer endpoint con SSE de progreso real):
  - `services/ffmpeg_extract.py` con `extract_part()` generator que parsea `-progress pipe:1` (clave `out_time_us`) y emite eventos `starting → progress* → done|error`.
  - Modo rápido `-c copy` (corta alineado a keyframes, ~755× speed) o `reencode=True` (libx264 veryfast crf20, corte preciso al ms).
  - Comando: `ffmpeg -hide_banner -nostdin -loglevel error [-ss -to -i | -i -ss -to] [-c copy -avoid_negative_ts make_zero | libx264] -progress pipe:1 -y <dst>`.
  - Cancelación: `GeneratorExit` (cliente cerró stream) → `proc.kill()`.
  - Sandbox: src y dst dentro del workspace (reusa `resolve_inside_workspace`). Cap defensivo: 6h de duración.
  - Endpoint SSE `POST /ffmpeg/extract-part` con mismo patrón anyio thread + memory_object_stream que `/system/install-ffmpeg`.
  - **Smokes 5/5 OK**: 401 sin token, corte real -c copy (out 34KB en 78ms), validación end<=start, traversal en dst blocked, reencode preciso (2.0s exactos pedidos = 2.0s out).
- [x] **`POST /ffmpeg/append-video`** ✅ HECHO (Fase 2.2.d):
  - `services/ffmpeg_append.py` con `append_video()` generator. **Primer endpoint que acepta path FUERA del workspace** (el sandbox se traslada al destino).
  - Validaciones source: absoluto, existe, es archivo, extensión en whitelist (`.mp4 .mov .mkv .webm .avi .m4v .m2ts .mts .ts`), tamaño 0 < n ≤ 8 GB.
  - Validación project_id: regex `[A-Za-z0-9_-]{1,64}`.
  - Destino: `<workspace>/projects/<project_id>/imports/<YYYYMMDD_HHMMSS>_<safe_name>` (sanitizado anti-path-traversal).
  - Copia atómica: `<dst>.partial` con SHA-256 al vuelo, throttling 250 ms; al final `replace()`. Cleanup de `.partial` en `finally` si falla.
  - Probe del archivo destino tras copia → si no tiene stream de video, borra el destino y reporta error.
  - Eventos: `starting (total_bytes) → progress* (copied_bytes/fraction/mb_per_s) → probing → done (sha256 + summary) | error`.
  - **Smokes 6/6 OK**: 401 sin token, project_id inválido, extensión .srt rechazada, src inexistente, src relativo rechazado, import real con summary completo del probe.
- [ ] `POST /ffmpeg/auto-frame` — usa OpenCV + torch
- [x] **`POST /ffmpeg/clip-stabilization`** ✅ HECHO (Fase 2.5.b):
  estabilización vidstab dos pasadas para un clip individual.
  - `services/ffmpeg_stabilize.py` + endpoint en `routes/ffmpeg.py`.
  - **3 modos** (`suave` / `estandar` / `intenso`) replicados byte
    a byte desde `_STABILIZATION_MODES` en
    `backend/apps/editor/services.py` (shakiness, accuracy, stepsize,
    mincontrast, smoothing, zoom, optzoom, zoomspeed, optalgo,
    maxshift, maxangle, detect_width). Default `estandar`.
  - **Pass 1** (`vidstabdetect`): `scale=detect_width:-2:flags=lanczos`
    + `vidstabdetect=...:result='<trf>'` → `<dst>.trf` con la
    trayectoria. Output dirigido a `os.devnull` para no colisionar
    con `-progress pipe:1`.
  - **Pass 2** (`vidstabtransform`): lee el `.trf` con
    `interpol=bicubic`, aplica `unsharp=5:5:0.6:3:3:0.0`, encoda con
    `libx264 -preset veryfast -crf 20`, audio `copy`.
  - Sandbox: `src` y `dst` vía `resolve_inside_workspace`; ambos
    deben tener extensión de video permitida; `src != dst`.
  - Probe ffprobe previo para sacar `duration_s` (necesaria para
    `fraction`); rechaza `duration_s <= 0`.
  - Eventos SSE: `starting (mode, mode_label, settings, duration_s)`
    → `detecting` → `progress(pass="detect", fraction=0..0.5)*`
    → `transforming(transforms_size_bytes)` →
    `progress(pass="transform", fraction=0.5..1.0)*` →
    `done(dst, size_bytes, elapsed_s)` | `error(stage)` |
    `cancelled`. Throttle 500 ms.
  - `GeneratorExit` mata el ffmpeg activo + borra `.trf` + borra
    `dst` parcial (cleanup atómico ante cancelación).
  - El `.trf` intermedio siempre se borra al final (no aporta valor
    al cliente, solo era estado intermedio).
  - `GET /ffmpeg/clip-stabilization/modes` expone los 3 presets +
    sus parámetros para que la UI pueda mostrarlos.
  - Frontend `companion.js`: `listStabilizationModes()` y
    `stabilizeClip({src, dst, mode}, onEvent)`.
  - **Smokes 5/5** (`companion/scripts/smoke_clip_stabilization.py`):
    401 sin token, modo inválido (`stage=validation`), src fuera de
    workspace (`stage=validation`), `GET modes` devuelve los 3
    presets, real estabilización sobre clip sintético `testsrc2`
    desestabilizado con `rotate=0.04*sin(t)` → `done size=120639B`
    con secuencia `starting → detecting → progress → transforming →
    progress → done` y `.trf` limpiado.
- [x] **`POST /ffmpeg/sequence-clip-preparation`** ✅ HECHO (Fase 2.5.e):
  prepara mezzanines all-intra por bloque para una secuencia (lista
  de clips lógicos del timeline). Permite editar en cualquier frame
  sin re-encode posterior.
  - `services/ffmpeg_sequence_clip_prep.py` + endpoint en
    `routes/ffmpeg.py`. Pipeline por clip:
    `libx264 -preset ultrafast -crf 15 -x264-params keyint=1:
    min-keyint=1:bframes=0:no-scenecut=1 -pix_fmt yuv420p
    -af asetpts=PTS-STARTPTS,aresample=async=1:first_pts=0
    -c:a aac -b:a 192k -movflags +faststart -progress pipe:1`.
  - Por clip: `block_id`, `label`, `src` (workspace-relative),
    `in_ms`, `out_ms`. `output_dir` workspace-relative.
    `dst = <safe_label>__<block_id[:12]>.mp4` dentro de `output_dir`.
  - Validaciones: `MAX_CLIPS=1024`, `MAX_CLIP_DURATION_S=21600`,
    `out_ms > in_ms`, `src` debe existir e incluirse vía
    `resolve_inside_workspace`.
  - Cancelación cooperativa vía `JobHandle` (race-fix tras
    `Popen`); por clip: `clip_starting → clip_progress* →
    (clip_done | clip_error)`; al terminar la secuencia:
    `done {processed, succeeded, failed, total_clips,
    output_dir, elapsed_s}` o `cancelled {job_id, processed,
    succeeded, failed, total_clips, output_dir, elapsed_s,
    reason: external_cancel}`. `clip_error` no aborta la
    secuencia (continúa con los siguientes); cancel sí.
  - **Smokes 6/6** (`companion/scripts/smoke_sequence_clip_preparation.py`):
    401 sin token, clips vacíos → 422, `out_ms<=in_ms` →
    `stage=validation`, src no existe → `stage=validation`,
    real 3 clips de 2s con verificación all-intra vía ffprobe
    (`pict_type==I` en primeros 30 frames), cancel mid-clip
    sobre clip 1280x720/60s con `cancel_after_s=1.0` →
    `cancelled reason=external_cancel`.
- [x] `POST /ffmpeg/sequence-media-preparation` — ver descripción detallada en Fase 2.8 más abajo.
- [x] **`POST /ffmpeg/sequence-media-preparation`** ✅ HECHO (Fase 2.8):
  genera **master mezzanine all-intra + proxy** para una secuencia a
  partir de una lista de rangos `{src, in_ms, out_ms}` del workspace.
  Replica el worker `_start_sequence_media_preparation_worker` de
  `backend/apps/editor/views.py` en la companion.
  - `services/ffmpeg_sequence_media_prep.py`:
    `prepare_sequence_media(workspace, *, clips, master_dst, proxy_dst,
    fps, audio_rate, audio_channels, audio_layout, proxy_height,
    job_handle)`. Pass 1 master = `setpts=PTS-STARTPTS,fps,format=yuv420p`
    + `asetpts=PTS-STARTPTS,aresample=...:async=1:first_pts=0,aformat`
    + `concat=n=N:v=1:a=1` (o `null/anull` si N=1) → `libx264 veryfast
    crf15 keyint=1:bframes=0` + `aac 192k` + `+faststart`. Pass 2 proxy
    = `scale=H:H:force_original_aspect_ratio=decrease:flags=lanczos`
    → `libx264 veryfast crf20` + `aac 128k` + `+faststart`.
  - **Defaults**: `fps=30.0`, `audio_rate=48000`, `audio_channels=2`,
    `audio_layout=stereo`, `proxy_height=960`. `MAX_CLIPS=256`,
    `MAX_RANGE_DURATION_S=21600`. Validaciones: `master_dst != proxy_dst`,
    ambos `.mp4`, `out_ms > in_ms`, src debe existir y tener extensión
    de video permitida.
  - Endpoint `POST /ffmpeg/sequence-media-preparation` en
    `routes/ffmpeg.py` con patrón estándar (memory-stream(64) +
    `to_thread.run_sync` + `JobHandle` + inyección de `job_id`).
  - Eventos SSE: `starting (master_dst, proxy_dst, master_duration_s,
    fps, audio_rate, audio_channels, audio_layout, proxy_height,
    total_clips, clips:[]) → master_starting → progress(pass="master",
    fraction=0..0.5)* → master_done(size_bytes, elapsed_s) →
    proxy_starting → progress(pass="proxy", fraction=0.5..1.0)* →
    proxy_done(size_bytes, elapsed_s) → done(master_size_bytes,
    proxy_size_bytes, master_duration_s, elapsed_s)` |
    `cancelled (job_id, stage="master"|"proxy", elapsed_s,
    reason="external_cancel")` | `error(stage)`.
  - **Hardening Windows triada (preventivo)**: `_run_pass` interno
    pasa `stdin=subprocess.DEVNULL`, cierra `proc.stdout`/`proc.stderr`
    y hace `proc.wait(timeout=2)` en `finally` para liberar handles
    entre las dos pasadas. El smoke drena el stdout del companion en
    background.
  - Cancelación: el master parcial se borra al cancelar mid-master
    (verificado en smoke); el proxy parcial se borra mid-proxy.
  - **Smokes 6/6** (`companion/scripts/smoke_sequence_media_preparation.py`,
    puerto 7495): 401 sin token, clips vacíos → 422,
    `out_ms<=in_ms` → validation, `master_dst==proxy_dst` →
    validation, real 2 rangos del source 640x360 → master 1.7MB +
    proxy 413KB con `master_duration=4.0s` (`elapsed=0.55s`),
    cancel mid-master sobre 1280x720/40s → `cancelled stage=master
    partial_clean=True`.
  - **Regresión completa**: 45/45 PASS (5 stab + 5 audio + 8 cancel
    + 3 cross + 6 seqprep + 6 seqaudio + 6 seqstab + 6 seqmedia).
- [x] **`POST /ffmpeg/sequence-stabilization`** ✅ HECHO (Fase 2.7):
  wrapper per-secuencia sobre `stabilize_clip` (vidstab dos pasadas)
  que estabiliza serialmente cada clip de la secuencia con un `mode`
  compartido (`suave`/`estandar`/`intenso`) y respeta `JobHandle` para
  cancelación cooperativa.
  - `services/ffmpeg_sequence_stabilization.py`:
    `stabilize_sequence(workspace, *, clips, mode, job_handle)` valida
    clips (`block_id`/`src`/`dst`, src existe, dst no duplicado,
    sandbox vía `resolve_inside_workspace`). Emite `starting
    (total_clips, mode, mode_label, settings, clips:[])` → N×
    `clip_starting → clip_progress (pass=detect|transform)* →
    (clip_done | clip_error)` (clip_error no aborta) → `done` o
    `cancelled` con `{processed, succeeded, failed, total_clips,
    elapsed_s}`. Re-mapea los eventos `progress` internos del clip
    a `clip_progress` con `pass`/`fraction`/`speed`/`out_time_s`,
    suprimiendo los `detecting`/`transforming` para no inflar SSE.
  - Endpoint `POST /ffmpeg/sequence-stabilization` en
    `routes/ffmpeg.py` con el patrón estándar
    (`anyio.create_memory_object_stream(64)` + producer en
    `to_thread.run_sync` + cancel vía `JobHandle`, inyección de
    `job_id` en `starting` y errores).
  - **Hardening Windows triada (preventivo)**: el `_run_pass` interno
    de `ffmpeg_stabilize.py` ahora pasa `stdin=subprocess.DEVNULL` al
    `Popen` y en el `finally` cierra explícitamente
    `proc.stdout`/`proc.stderr` y hace `proc.wait(timeout=2)` para
    liberar handles antes de la siguiente pasada serial. El smoke
    también drena el stdout del companion en background.
  - **Smokes 6/6** (`companion/scripts/smoke_sequence_stabilization.py`,
    puerto 7494): 401 sin token, clips vacíos → 422, modo inválido
    → validation, dst duplicado → validation, real 3 clips shaky
    320x240/2s estabilizados serial con
    `clip_progress_total=6 passes=['detect','transform']`
    (`elapsed=1.21s`), cancel mid-batch sobre 2 clips
    1280x720/8s con modo `intenso`
    (`processed=0/2, reason=external_cancel`).
  - **Regresión completa**: 39/39 PASS (5 stab + 5 audio + 8 cancel
    + 3 cross + 6 seqprep + 6 seqaudio + 6 seqstab).
- [x] **`POST /ffmpeg/audio-enhance`** ✅ HECHO (Fase 2.5.c):
  cadena de mejora de voz/lectura sobre **un único archivo**
  (audio o video con audio). El llamador concatena la secuencia
  primero (vía `/ffmpeg/run-command` con `concat`) y luego pasa el
  archivo resultante por este endpoint.
  - `services/ffmpeg_audio_enhance.py` + endpoint en `routes/ffmpeg.py`.
  - **Cadena de filtros** replicada byte a byte de
    `_build_sequence_audio_enhancement_filter_chain` en
    `backend/apps/editor/views.py`:
    `highpass → anlmdn → equalizer (de-esser) → acompressor →
    volume (voice boost) → dynaudnorm (leveling) → alimiter →
    loudnorm → aresample → aformat`.
  - 16 settings configurables (booleans + amounts 0..1) replicados
    de `_normalize_sequence_audio_enhancement_settings` con los
    mismos defaults (highpass 80Hz on, voice_boost 0.35 on,
    compression 0.55 on, limiter on, loudnorm -16 LUFS).
  - **Detección de filtros opcionales** vía `ffmpeg -filters`
    (cache a nivel de módulo): si `anlmdn`/`equalizer`/
    `acompressor`/`dynaudnorm`/`alimiter`/`loudnorm` no están
    en el ffmpeg local, se omiten silenciosamente y aparecen
    en `done.skipped_filters` para que la UI lo muestre.
  - **Output codec** según extensión de dst: `.wav` →
    `pcm_s16le -vn`, `.m4a/.aac` → `aac 192k -vn`, `.mp3` →
    `libmp3lame q2 -vn`, `.flac` → `flac -vn`, `.ogg/.opus` →
    `libopus 128k -vn`, video → `-c:v copy -c:a aac 192k`.
  - Sandbox: `src` y `dst` vía `resolve_inside_workspace`;
    extensiones permitidas en ambos extremos; `src != dst`.
  - Probe ffprobe previo para `duration_s` (necesaria para
    `fraction`); rechaza `duration_s <= 0`.
  - Eventos SSE: `starting (settings, applied_filters,
    skipped_filters, filter_chain, audio_rate, audio_layout,
    output_kind, duration_s)` → `progress (out_time_s, fraction,
    speed)*` → `done (dst, size_bytes, elapsed_s,
    applied_filters, skipped_filters)` | `error (stage)` |
    `cancelled`. Throttle 500 ms.
  - `GeneratorExit` mata el ffmpeg activo + borra `dst` parcial.
  - `GET /ffmpeg/audio-enhance/defaults` expone defaults +
    `audio_rate=44100`/`audio_layout=stereo`.
  - Frontend `companion.js`: `getAudioEnhanceDefaults()` y
    `enhanceAudio({src, dst, settings, audioRate, audioLayout},
    onEvent)`.
  - **Smokes 5/5** (`companion/scripts/smoke_audio_enhance.py`):
    401 sin token, src fuera workspace (`stage=validation`),
    dst `.txt` (`stage=validation`), `GET defaults` con 16
    settings y `target_lufs=-16`, mejora real sobre WAV
    sintético (tono+ruido) → `done size=529278B applied=[
    highpass, anlmdn, acompressor, volume, alimiter, loudnorm,
    aresample, aformat]`.
- [x] **`POST /ffmpeg/sequence-audio-enhancement`** ✅ HECHO (Fase 2.6):
  wrapper per-secuencia sobre `enhance_audio` que aplica la cadena de
  voz/lectura a cada clip de la secuencia y respeta `JobHandle` para
  cancelación cooperativa.
  - `services/ffmpeg_sequence_audio_enhance.py`:
    `enhance_sequence_audio(workspace, *, clips, settings,
    audio_rate, audio_layout, job_handle)` valida clips
    (block_id/src/dst, src existe, dst no duplicado, sandbox vía
    `resolve_inside_workspace`). Emite `starting (total_clips,
    settings, applied_filters, skipped_filters, audio_rate,
    audio_layout, clips:[])`, luego N× `clip_starting →
    clip_progress* → (clip_done | clip_error)` (clip_error no
    aborta), final `done` o `cancelled` con
    `{processed, succeeded, failed, total_clips, elapsed_s}`.
  - Endpoint `POST /ffmpeg/sequence-audio-enhancement` en
    `routes/ffmpeg.py` con patrón estándar
    (`anyio.create_memory_object_stream(64)` + producer en
    `to_thread.run_sync` + cancel vía `JobHandle`).
  - **Hardening Windows**: `subprocess.Popen` y `subprocess.run` de
    ffmpeg/ffprobe ahora pasan `stdin=subprocess.DEVNULL`; el
    `finally` de `enhance_audio` cierra explícitamente
    `proc.stdout`/`proc.stderr` y hace `proc.wait(timeout=2)` para
    liberar handles antes de la siguiente iteración serial.
  - **Smokes 6/6** (`companion/scripts/smoke_sequence_audio_enhancement.py`):
    401 sin token, clips vacíos → 422, dst duplicado → validation,
    src no existe → validation, real 3 clips procesados serial
    (`elapsed=0.46s`), cancel mid-batch sobre 2 clips long
    (`processed=1/2, succeeded=1, reason=external_cancel`). El
    smoke drena el stdout del companion en background para evitar
    saturar el pipe (~64KB en Windows) y bloquear el worker thread.
- [x] **`POST /ffmpeg/run-command`** ✅ HECHO (Fase 2.5.a): primitivo
  genérico para que el backend Django delegue la ejecución local de
  cualquier comando FFmpeg sin portar el filtergraph.
  - `services/ffmpeg_runner.py` + endpoint en `routes/ffmpeg.py`.
  - Sandbox estricto: `inputs[]` y `outputs[]` declarados aparte,
    args usan placeholders `{INPUT_n}` / `{OUTPUT_n}`. NO parseamos
    args para detectar paths (imposible con filtergraphs). Cada
    placeholder se resuelve vía `resolve_inside_workspace`.
  - Rechazo previo de NUL/CR/LF en args/inputs/outputs (anti-injection).
  - Caps: `MAX_ARGS=512`, `MAX_INPUTS=64`, `MAX_OUTPUTS=16`,
    `MAX_ARG_LEN=4096`, `HARD_TIMEOUT_S=6h`.
  - Inyecta `-progress pipe:1 -nostats -hide_banner -nostdin -y`.
  - Eventos SSE: `starting → progress(out_time_s, fraction, speed,
    fps, bitrate)* → done | error | cancelled`. Throttle 700 ms.
  - `GeneratorExit` mata el proceso ffmpeg → no exports huérfanos.
  - `log_relpath` opcional escribe argv + stderr al workspace para
    debugging post-mortem.
  - Frontend `companion.js`: `runFFmpegCommand({args, inputs,
    outputs, totalDurationMs, logRelpath}, onEvent)`.
  - **Smokes 5/5**: 401 sin token, input fuera de workspace
    (`stage=validation`), NUL en output (`stage=validation`),
    placeholder OOB (`stage=validation`), real wav→mp3 con
    `total_duration_ms=7068` → `progress fraction=1.0 speed=290x`
    → `done elapsed=63ms outputs=[{size_bytes:50845}]`.
- [ ] `POST /ffmpeg/export` — adaptar el backend para construir el
  comando + lista de assets y dispatchear a `/ffmpeg/run-command`.
- [ ] `POST /ffmpeg/export-batch`
- [x] **`POST /ffmpeg/cancel`** ✅ HECHO (Fase 2.5.d): primitivo de
  cancelación de jobs SSE en curso. Diseño centralizado en un
  registry para que cualquier endpoint pueda integrarse de forma
  incremental.
  - `services/job_registry.py`: `JobHandle (job_id, cancel_event,
    kill_callback)`, `create_job() / get_job() / remove_job() /
    cancel_job(job_id) / list_active_jobs()`. Thread-safe con
    `threading.Lock`.
  - El servicio que ejecuta el trabajo asigna
    `handle.kill_callback = proc.kill` después de `Popen`. Al
    invocar `cancel_job(job_id)`: setea el evento + dispara el
    callback. El thread productor descubre el flag al volver del
    bloqueo de stdout (porque ffmpeg murió por SIGKILL) y emite
    `phase=cancelled` en vez de `done`/`error`.
  - **Endpoints**:
    - `POST /ffmpeg/cancel {job_id}` → 200 `{cancelled, job_id}` |
      404 si el job no existe / ya terminó.
    - `GET /ffmpeg/jobs` → `{jobs: [job_id, ...]}` (debug).
  - **Inyección de `job_id` en eventos SSE**: el endpoint genera el
    `job_id` ANTES del producer, lo inyecta en el primer evento
    `starting` (y en cualquier `error`) para que el cliente pueda
    cancelar luego. El servicio NO conoce el endpoint, solo el
    `job_handle`.
  - **Integración piloto**: `POST /ffmpeg/audio-enhance` (parámetro
    `job_handle: Optional[JobHandle]`). El servicio emite
    `{phase: "cancelled", job_id, elapsed_s, reason: "external_cancel"}`
    si el flag se setea durante la ejecución; borra el `dst` parcial.
  - **Frontend `companion.js`**: `cancelJob(jobId)` y
    `listActiveJobs()`. La UI guarda el `job_id` del `starting` y
    lo usa para el botón de cancelar.
  - **Smokes 8/8** (`companion/scripts/smoke_cancel_job.py`):
    cancel job_id desconocido → 404; `/jobs` vacío al inicio;
    starting.job_id capturado; `/jobs` lo incluye mientras corre;
    POST `/cancel` devuelve `cancelled=true`; llega `phase=cancelled`
    con `reason=external_cancel`; dst parcial limpio; `/jobs` ya
    no lo incluye tras terminar.
  - **Pendiente** (incremental, no bloqueante): integrar el mismo
    patrón en `install-ffmpeg`, `install-ml-feature`. El endpoint
    `/transcribe/audio` ya está migrado (ver Fase 2.5.d-extension-2
    más abajo). Los 4 endpoints SSE de ffmpeg ya están migrados (ver
    Fase 2.5.d-extension más abajo). Cada endpoint pendiente
    necesita ~10 líneas: importar `create_job/remove_job`, pasar
    `job_handle` al servicio, inyectar `job_id` en `starting`,
    `remove_job` en `finally`. El servicio necesita aceptar
    `job_handle: Optional[JobHandle]` y asignar
    `handle.kill_callback = proc.kill`.
- [x] **Fase 2.5.d-extension — cancel cross-endpoint** ✅ HECHO:
  migrado el patrón `job_handle` a los 4 endpoints SSE restantes
  de ffmpeg: `clip-stabilization`, `run-command`, `extract-part`,
  `append-video`. Cada uno acepta `job_handle: Optional[JobHandle]`,
  inyecta `job_id` en `starting`, llama `remove_job` en `finally`,
  y emite `{phase:"cancelled", reason:"external_cancel"}` con limpieza
  de artefactos parciales (dst, .trf, .partial).
  - **Race-fix**: tras asignar `kill_callback = proc.kill`, los
    servicios verifican `is_cancelled()` y disparan `proc.kill()`
    inmediatamente — necesario porque `cancel_job` puede invocarse
    entre `Popen()` y la asignación del callback (típico en stabilize:
    cancel entre pass 1 y pass 2).
  - **Append-video**: como es copia pura (no `Popen` ffmpeg),
    `_copy_with_progress` chequea `is_cancelled()` al tope de cada
    iteración del loop de `read/write` y emite evento interno
    `_cancelled` que el orchestrator traduce a `phase=cancelled`.
  - **Smokes 3/3** (`companion/scripts/smoke_cancel_cross_endpoint.py`,
    puerto 7491): clip-stabilization (cancel mid-detect), run-command
    (cancel mid-reencode con libx264 preset slow), extract-part
    (cancel mid-extract con reencode). Total batería: **21/21 PASS**
    (audio 5/5 + stab 5/5 + cancel-base 8/8 + cross 3/3).
- [x] **Fase 2.5.d-extension-2 — cancel en `/transcribe/audio`** ✅ HECHO:
  integrado el patrón `JobHandle` en el endpoint de transcripción
  local (faster-whisper). Cancelación cooperativa: el servicio chequea
  `job_handle.is_cancelled()` en 3 puntos (post-`starting`,
  post-`loading_model`, en cada iteración del loop de segmentos
  antes y después de yield). No hay forma limpia de matar
  faster-whisper a mitad de un segmento (no expone API de cancel ni
  hereda de `Popen`); el cancel coopera entre segmentos, que es lo
  máximo posible sin parchar el binding C de ctranslate2.
  - Servicio `transcribe.py`: nuevo parámetro
    `job_handle: Optional[JobHandle] = None`; helper local
    `_is_cancelled()`; emite `{phase: "cancelled", stage:
    "starting"|"loading_model"|"transcribe_iter", elapsed_s,
    segments_emitted?, reason: "external_cancel"}` y abandona el
    loop sin emitir `done`.
  - Endpoint `routes/transcribe.py`: usa `create_job()` antes del
    producer; pasa `job_handle=job` a `transcribe_audio`; inyecta
    `job_id` en eventos `starting`/`error`/`cancelled`; llama
    `remove_job(job.job_id)` en `finally`.
  - **Smokes 3/3** (`companion/scripts/smoke_cancel_transcribe.py`,
    puerto 7496): cancel job_id desconocido → 404; starting captura
    `job_id`; cancel temprano (tras `starting`) → llega `phase=
    cancelled stage=loading_model reason=external_cancel job_id_match=True`
    con `phases=['starting', 'loading_model', 'cancelled']`. El
    smoke skipea con PASS si faster-whisper no está instalado
    (no es regresión: el feature pack es opcional).
  - **Pendiente** del cancel cross-endpoint: ✅ RESUELTO en
    Fase 2.5.d-extension-3 (cancel en endpoints de install).
  - **Regresión completa**: 48/48 PASS (5 stab + 5 audio + 8 cancel
    + 3 cross + 3 cancel-tx + 6 seqprep + 6 seqaudio + 6 seqstab +
    6 seqmedia).
- [x] **Fase 2.5.d-extension-3 — cancel en `/system/install-ffmpeg` y
  `/system/install-ml-feature`** ✅ HECHO: cierra el cross-endpoint
  pattern para los dos endpoints SSE de install. Ambos servicios
  ahora aceptan `job_handle: Optional[JobHandle] = None` y
  cooperan con el `JobRegistry`.
  - `services/ml_installer.py` (`install_feature`): chequea
    `is_cancelled()` post-`starting`; en el `Popen` de pip pasa
    `stdin=subprocess.DEVNULL` y registra `job_handle.kill_callback
    = proc.kill` (con race-fix inmediato si ya estaba cancelado);
    en el loop de líneas chequea cancel tras cada throttle-emit y
    si dispara hace `proc.kill()` y marca `cancelled_mid`; el
    `finally` limpia `kill_callback`, cierra `proc.stdout`, hace
    `proc.wait(timeout=2)`. Tras el finally emite `{phase:
    "cancelled", stage: "starting"|"pip_install", elapsed_s,
    reason: "external_cancel"}` y retorna sin `done`/`verify`.
  - `services/ffmpeg_installer.py` (`install_ffmpeg`): chequea
    `is_cancelled()` post-`starting` y post-`verify`; pasa
    `is_cancelled` callback a `_stream_download` que lo evalúa
    entre cada `resp.read(CHUNK_SIZE)` y, si dispara, yield-ea
    sentinel `{phase: "_cancelled"}` en lugar de seguir leyendo;
    el caller intercepta el sentinel, set `cancelled_mid=True` y
    emite `{phase: "cancelled", stage: "download"|"verify",
    elapsed_s, reason: "external_cancel"}` retornando antes del
    extract. El `finally: shutil.rmtree(tmp_dir)` ya existente
    limpia el zip parcial.
  - `routes/system.py`: ambos endpoints usan `create_job()` antes
    del producer; pasan `job_handle=job` al servicio; inyectan
    `job_id` en `starting`/`error`/`cancelled`; `remove_job` en
    `finally`. Buffer SSE intacto (8 ffmpeg, 16 ml-feature).
  - **Smokes 7/7** (`companion/scripts/smoke_cancel_install.py`,
    puerto 7497): direct-call con `JobHandle` pre-cancelled valida
    el checkpoint after-`starting` para ambos servicios (`stage=
    starting reason=external_cancel`); 404 sanity en `/ffmpeg/
    cancel`; flujo dry-run normal verifica que `job_id` aparece
    en `starting`. Cancel-via-HTTP en dry-run es race acceptable
    (dry-run es síncrono e instantáneo); el direct-call cubre la
    semántica real.
  - **Regresión completa**: 55/55 PASS (5 stab + 5 audio + 8 cancel
    + 3 cross + 3 cancel-tx + 6 seqprep + 6 seqaudio + 6 seqstab +
    6 seqmedia + 7 cancel-install).
- [x] **`POST /transcribe/audio`** ✅ HECHO (Fase 2.4.b): local Whisper
  vía faster-whisper + reutiliza el ML installer.
  - `services/transcribe.py` + `routes/transcribe.py` (router prefijo
    `/transcribe`). Validación previa (model whitelist, ext whitelist,
    sandbox). Probe lazy del feature pack — si falta, emite
    `error stage=ml_dependency_missing` con `feature=transcription`
    para que el frontend dispare modal de install.
  - Eventos SSE: `starting → loading_model → transcribing → segment* → done`.
    `segment` por cada fragmento normalizado (start/end ms + word
    timestamps + confidence) — UI puede ir mostrando texto en vivo.
  - Mismos parámetros que el backend Django (`beam_size=8, best_of=5,
    vad_filter, condition_on_previous_text`) y misma normalización
    `_normalize_segment` para drop-in compatibility.
  - Frontend `companion.js`: `transcribeAudio({audioPath, model,
    language, device, beamSize, bestOf, vadFilter, wordTimestamps},
    onEvent)`.
  - **Smokes 5/5**: 401 sin token, audio inexistente (validation),
    model inválido (whitelist), ext inválida (validation),
    transcripción real `tiny` sobre `speech.wav` 311 KB → 2 segmentos
    en ~5 s con `language="en"` autodetectado.
- [x] **`GET /preview/stream/{path}`** ✅ HECHO (Fase 2.2.e):
  - `routes/preview.py` con Range single-range + suffix range, 206/200/416/404/400.
  - Streaming chunked 1 MiB para video grande (no carga en memoria).
  - Sandbox vía `resolve_inside_workspace` reusado del módulo de probe.
  - **Token vía query param** (`?token=...`) habilitado SOLO para
    `/preview/stream/` mediante `QUERY_TOKEN_PATH_PREFIXES` en
    `security.py`. Necesario porque `<video src>` no puede enviar
    headers custom. Header `X-Companion-Token` sigue funcionando.
  - Headers: `Accept-Ranges: bytes`, `Content-Range`, `Content-Length`,
    `Cache-Control: no-store`, `Content-Type` por mimetype.
  - Helper `client.previewStreamUrl(path)` en `frontend/src/api/companion.js`
    construye la URL completa lista para `<video src={...}>`.
  - **Smokes 8/8**: 401 sin token, 200 full con header, 200 full con
    query token, 206 range 0-1023, 206 suffix -512 (54458-54969/54970),
    416 OOB con `Content-Range: bytes */54970`, 404 inexistente,
    400 traversal absoluto bloqueado.

### 2.3 Refactor de `services.py` a `media_engine`

Por cada endpoint migrado:
- [ ] Lógica reutilizable extraída a `media_engine/`
- [ ] Companion importa de `media_engine`
- [ ] Server elimina endpoint correspondiente (o lo deja con stub que retorna 410 Gone)
- [ ] Frontend `api/client.js` redirige a companion en lugar de server
- [ ] Tests de regresión: el output debe ser idéntico al del server pre-migración

### 2.3.bis Frontend ↔ Companion: capa de cliente ✅ HECHO (Fase 2.x.frontend-1)

Capa reutilizable que cubre todos los endpoints del companion ya implementados,
para que el resto de la UI (modales de import, install, export, etc.) pueda
usarlos sin volver a implementar fetch / parseo SSE.

- [x] **`frontend/src/api/companion.js`** extendido con:
  - Helper `_streamSSE(path, body, onEvent, {signal})` que hace `POST` con header
    `X-Companion-Token` y parsea wire-format SSE manualmente (`EventSource`
    nativo no soporta headers custom). Cancelable vía `AbortSignal`.
  - Métodos: `probeFFmpeg()`, `installFFmpeg({source, dryRun}, onEvent)`,
    `probeFile(path, includeRaw)`, `extractPart({src, dst, startS, endS, reencode}, onEvent)`,
    `appendVideo({srcPath, projectId}, onEvent)`,
    `pickFile(opts)`, `pickFolder(opts)`, `saveFileDialog(opts)`.
  - Escape hatch `streamSSE` exportado para futuros endpoints.
- [x] **`frontend/src/companion/useCompanionFFmpeg.js`** hook ergonómico:
  - Estado reactivo `{ ready, busy, progress, error, lastResult }`.
  - `cancel()` propaga AbortSignal al stream activo.
  - Convierte eventos `phase=error` en excepciones JS con `.stage` y `.event`.
- [x] **`frontend/src/components/CompanionFFmpegPanel.jsx`** demo enchufable:
  - 4 acciones: Probe / Install (dry+real) / Importar video (pick → appendVideo)
    / Extraer 0–1s. Barra de progreso, summary del probe, sha256.
  - Sin dependencias externas; chrome denso casi recto.
  - **No está montado** todavía: el componente padre decide dónde colgarlo
    (Settings, modal de import, etc.).
- [x] **`CompanionFFmpegPanel` montado en `SettingsPage`** como sección
      "Companion · diagnóstico FFmpeg" (debajo de `CompanionWorkspaceSection`).
      Permite testear los 4 endpoints sin tocar consola.
- [ ] Convertir el botón "Importar video" del Dashboard
      (`useProjectIntakeWorkflow.js`) para usar `useCompanionFFmpeg().pickFile +
      appendVideo` cuando `companion.isOnline === true`.

### 2.4 Heavy ML deps en companion

- [x] Decisión: instalación on-demand vía `pip install` (torch ~2 GB,
  opencv ~80 MB, mediapipe ~80 MB no van en el instalador base).
- [x] **`services/ml_installer.py`** (Fase 2.4.a):
  - Catálogo cerrado `FEATURE_PACKS` (whitelist) con dos packs:
    `transcription` (faster-whisper) y `auto_frame`
    (opencv-python-headless + mediapipe).
  - `probe_feature(name)` → corre subprocess aislado
    (`sys.executable -c`) que intenta importar los módulos y reporta
    versiones via `importlib.metadata`. Timeout 30 s.
  - `install_feature(name, *, dry_run, extra_index_url)` generator:
    `starting → resolving → pip_install* → verify → done | error`.
    Usa `python -m pip install --no-input --progress-bar off`.
    Throttle 250 ms en eventos `pip_install`. Hard timeout 30 min.
    Si el cliente cierra la conexión SSE (`GeneratorExit`), mata el
    proceso `pip` para no dejar descargas huérfanas.
  - Endpoints: `GET /system/ml-features`,
    `GET /system/probe-ml-feature?feature=...`,
    `POST /system/install-ml-feature?feature=...&dry_run=...`.
  - Frontend `companion.js`: `listMlFeatures()`, `probeMlFeature()`,
    `installMlFeature({feature, dryRun, extraIndexUrl}, onEvent)`.
  - **Smokes 5/5**: list, probe `transcription` (available, faster-whisper
    1.1.1 ya en venv), probe inválido → 400, install 401 sin token,
    install dry-run `auto_frame` SSE completo (starting→resolving→
    pip_install×3→verify→done). Lint 0.
- [ ] Wiring UI: cuando `/transcribe/audio` o `/ffmpeg/auto-frame`
  detectan que el feature no está disponible, mostrar modal
  "Descargando módulo de IA (~2 GB)…" reutilizando estos endpoints.

### 2.5 Path management

- [ ] Server guarda `local_path` relativo a `%USERPROFILE%\MaxEditor\videos\<project_id>\`
- [x] **`GET /workspace/projects/{project_id}/imports`** ✅ HECHO
  (Fase 2.5.e): lista archivos de video importados a
  `<workspace>/projects/<id>/imports/` (convención del endpoint
  `/ffmpeg/append-video`).
  - Validación `project_id` con regex `[A-Za-z0-9_-]{1,64}` →
    400 `invalid_project_id`.
  - Si el directorio no existe: 200 con `exists=false files=[]`
    (no es error: el proyecto puede no haber tenido imports).
  - Filtra por extensión usando la misma whitelist
    `IMPORT_VIDEO_EXTENSIONS` que `ffmpeg_append`
    (`.mp4 .mov .mkv .webm .avi .m4v .m2ts .mts .ts`); ignora
    `.srt`, `.json`, etc.
  - Cada entry: `name`, `relpath` (ej. `projects/<id>/imports/<file>`),
    `abspath`, `size_bytes`, `mtime_s`. Orden alfabético por nombre.
- [x] **`POST /workspace/probe-paths`** ✅ HECHO (Fase 2.5.e):
  verifica un batch de hasta 256 rutas (existencia + tipo +
  inside-workspace + size + mtime). Útil para detectar archivos
  faltantes (usuario cambió de PC, movió workspace, borró
  imports).
  - No abre/lee contenido — solo `stat()`. Las rutas inválidas
    (NUL, demasiado largas, resolve fail) devuelven `error=...`
    sin tirar 500.
  - Las rutas fuera del workspace devuelven `inside_workspace=false`
    pero NO son error (el caller decide si las acepta).
  - Race-safe: si un archivo es borrado entre `exists()` y `stat()`,
    se reporta missing en lugar de 500.
- [x] **`POST /workspace/projects/{project_id}/imports/diff`** ✅ HECHO
  (Fase 2.5.f): single round-trip que compara el set `keep` (lo que
  el server cree que tiene) contra el contenido real de
  `<workspace>/projects/<id>/imports/`. Devuelve:
  - `on_disk`: archivos de video presentes (alfabético).
  - `missing`: relpaths de `keep` ausentes en disco (incluye los
    que apuntan fuera de `imports/` — fuera de scope).
  - `orphans`: archivos en disco no referenciados por `keep`
    (imports abortados, sequences borradas).
  - Acepta separadores `/` y `\` en `keep`. Soporta hasta 4096
    entradas por request. Cubre los dos casos de path management
    (faltantes + huérfanos) en una sola llamada para que la UI
    pueda pintar badges sin N round-trips.
- [x] **Smokes 8/8** (`companion/scripts/smoke_workspace_paths.py`,
  puerto 7498): los 5 originales (validación, ausencia, listado,
  probe-paths mix, 401) + diff project_id inválido → 400 + diff
  escenario completo (2 archivos en disco, 2 keep válidos, 1 keep
  fuera de scope → on_disk=2 missing=2 orphans=1) + diff proyecto
  inexistente → exists=false missing=1 orphans=0.
- [x] **`POST /workspace/projects/{project_id}/imports/delete`** ✅ HECHO
  (Fase 2.5.g): borra archivos de import desde el companion sin
  tocar Django, con sandbox triple. Body `{names: list[str]}` (1..512,
  solo nombres simples). Cada nombre se valida contra
  `_SAFE_IMPORT_NAME_RE = ^[A-Za-z0-9._-]+$` + extension whitelist
  (`IMPORT_VIDEO_EXTENSIONS`) + anti-traversal `is_relative_to(ws_resolved)`
  antes de tocar disco. Devuelve `{deleted_count, bytes_freed,
  results: [{name, deleted, size_bytes?, error?}]}`. Códigos de error
  per-row: `invalid_name | invalid_extension | not_found |
  not_a_file | unlink_failed`. `bytes_freed` se calcula con stat()
  antes del unlink (best-effort). Permite a la UI vaciar imports
  huérfanos identificados por `/imports/diff` sin sobre-permisos.
- [x] **Smokes 11/11** (mismo `smoke_workspace_paths.py`): los 8
  anteriores + delete project_id inválido → 400 + delete escenario
  completo (seed `20260428_999999_orphan.mp4` 4096B; pide 5 nombres
  con 1 válido + not_found + `../escape.mp4` + `notes.srt` +
  `bad\x00name.mp4` → deleted=1, bytes_freed=4096, errores
  `[not_found, invalid_name, invalid_extension, invalid_name]`) +
  delete sin token → 401.
- [x] **Regresión completa**: 66/66 PASS (5 stab + 5 audio + 8 cancel
  + 3 cross + 3 cancel-tx + 6 seqprep + 6 seqaudio + 6 seqstab +
  6 seqmedia + 7 cancel-install + 11 workspace-paths).
- [ ] UI: si `probe-paths` reporta `exists=false` para un import
  registrado en el server, mostrar badge "archivo faltante" y
  ofrecer re-localizar.
- [x] **`POST /workspace/projects/{project_id}/imports/relocate`** ✅ HECHO
  (Fase 2.5.h): re-asocia un import faltante apuntando a un archivo
  nuevo en disco. Body `{name, source?, overwrite?}`. `name` es el
  nombre canónico que el server espera (mismo `_SAFE_IMPORT_NAME_RE`
  + `IMPORT_VIDEO_EXTENSIONS` que delete). `source` puede vivir EN
  CUALQUIER PARTE del disco (ese es el caso de uso); si viene
  vacío y la companion NO está en headless, se abre el file picker
  nativo (`dialogs.pick_file`) — en headless devuelve
  `error="cancelled"` para mantener smokes deterministas. La copia
  va siempre al slot canónico `<workspace>/projects/<id>/imports/<name>`,
  validado con `is_relative_to(ws_resolved)`. Implementación atómica:
  `shutil.copy2(source, tmp)` + `os.replace(tmp, dest)` (preserva
  mtime de la fuente, que es coherente con cómo `/imports` reporta
  metadata). `overwrite=False` (default) devuelve
  `error="already_exists"` sin tocar el destino. Códigos de error:
  `invalid_name | invalid_extension | cancelled | source_missing |
  source_not_a_file | already_exists | copy_failed: <msg>`.
- [x] **Smokes 14/14** (mismo `smoke_workspace_paths.py`): los 11
  anteriores + relocate sin token → 401 + relocate project_id
  inválido → 400 + relocate escenario completo (fuente real fuera
  del workspace `smoke_relocate_external/real_video.mp4` 8192B,
  copia exitosa, already_exists sin overwrite, overwrite=True
  reemplaza con 4096B nuevos, source_missing, invalid_name por
  traversal, invalid_extension `.srt`, headless cancelled cuando
  `source=None`).
- [x] **Regresión completa**: 69/69 PASS (5 stab + 5 audio + 8 cancel
  + 3 cross + 3 cancel-tx + 6 seqprep + 6 seqaudio + 6 seqstab +
  6 seqmedia + 7 cancel-install + 14 workspace-paths).
- [x] **Cliente JS path-management** ✅ HECHO (Fase 2.5.i):
  `frontend/src/api/companion.js` expone `listProjectImports(projectId)`,
  `probePaths(paths)`, `importsDiff(projectId, keep)`,
  `deleteProjectImports(projectId, names)` y
  `relocateProjectImport(projectId, {name, source?, overwrite?})`. Los 5
  endpoints del companion ya son consumibles desde React; solo falta el
  componente UI que los conecte.
- [x] **UI chip COMPLETO** ✅ HECHO (Fase 2.5.j + 2.5.k):
  `frontend/src/components/ProjectImportsHealthChip.jsx` se monta en
  cada `project-card-meta` del listado del Dashboard. Cuando la
  companion está online: (1) consulta `/api/editor/projects/<id>/import-manifest/`
  para obtener nombres canónicos esperados; (2) llama a
  `companion.listProjectImports(projectId)` para ver qué hay en disco;
  (3) corre `companion.importsDiff(projectId, expected)` para detectar
  faltantes/huérfanos. Estados visuales:
  - missing > 0  → chip ROJO `"N faltante(s)"` + botón "Re-localizar"
    (usa el primer missing como nombre canónico, abre file picker).
  - orphans > 0  → chip ÁMBAR `"N huérfano(s)"`.
  - todo OK      → chip neutro `"N imports ok"`.
  - sin imports  → silencioso.
  - error red    → chip rojo `"imports: error"` (click = retry).
  CSS minimal en `app.css` (`.project-card-imports-chip` +
  `.is-loading|.is-error|.is-warn|.is-ready` + `.project-card-imports-relocate`).
  Endpoint Django nuevo: `ProjectImportManifestView` en
  `apps/editor/views.py`, ruta `projects/<int:pk>/import-manifest/`.
  Devuelve `{project_id, imports: [{video_id, name, order_index,
  duration_ms, fingerprint, is_active_source}, ...]}` filtrado por
  `source_role="part"`.
- [x] **Smoke automatizado del manifest endpoint** ✅ HECHO (Fase 2.5.l):
  `backend/tests/smoke_project_import_manifest.py` — 6 casos verde:
  401 sin auth, 404 inexistente, lista vacía, 3 parts ordenadas por
  `order_index` con metadata completa, exclusión de `source_name=""`,
  exclusión de `source_role="composite"`. Crea/limpia con prefijo
  `_smoke_import_manifest_` para aislamiento. Run:
  `python -X utf8 backend/tests/smoke_project_import_manifest.py`.

**🎯 Hito Fase 2 cumplido cuando:**
- ✅ Editar un proyecto end-to-end (importar, transcribir, editar, exportar) sin que el server toque ningún archivo de video
- ✅ Server logs no muestran ninguna llamada a FFmpeg
- ✅ Performance igual o mejor que la versión actual (FFmpeg local es más rápido al evitar I/O de red)
- ✅ Todas las features actuales funcionan idénticas

---
- ✅ Editar un proyecto end-to-end (importar, transcribir, editar, exportar) sin que el server toque ningún archivo de video
- ✅ Server logs no muestran ninguna llamada a FFmpeg
- ✅ Performance igual o mejor que la versión actual (FFmpeg local es más rápido al evitar I/O de red)
- ✅ Todas las features actuales funcionan idénticas

---

## FASE 3 — Empaquetado + Deploy oficina

**Objetivo:** La oficina (tus empleados) usando el sistema en producción.

**Duración estimada:** 5–7 días

### 3.1 Instalador Windows

- [x] **Inno Setup script** ✅ HECHO (Fase 3.1.a):
  `installer/maxeditor.iss` + `installer/POST_INSTALL_README.txt` +
  `installer/README.md` con guía de build.
  - `DefaultDirName={autopf}\MaxEditor` con
    `PrivilegesRequired=lowest` + `PrivilegesRequiredOverridesAllowed=dialog`:
    el wizard ofrece per-machine (UAC + Program Files) o per-user
    (LocalAppData, sin UAC). La companion funciona sin admin porque
    el cert se instala en CurrentUser\Root al primer arranque
    (módulo `cert_store.install_cert_user()`), no desde el installer.
  - Shortcut Desktop (task `desktopicon`, default checked) + Start Menu.
  - Auto-start opcional via task `autostart` (checked default) →
    escribe `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
  - Lanza la companion al final del wizard (postinstall + skipifsilent).
  - Uninstaller automático: `taskkill /F /IM MaxEditorCompanion.exe`
    antes de borrar archivos. Limpia logs/cache pero NO toca user data
    (workspace, tokens, configs, certs).
  - `AppId` GUID fijo entre versiones para reemplazo in-place.
  - Compatible Win10 22H2+ / Win11 (`MinVersion=10.0.19041`).
- [ ] **Pendiente para cierre Fase 3.1** (requiere PC con build tools):
  - Correr `python companion/build.py` para generar
    `companion/dist/MaxEditorCompanion.exe`.
  - Compilar `installer/maxeditor.iss` con ISCC →
    `installer/Output/MaxEditor_Setup_v0.1.0.exe`.
  - Test en VM Windows 10 + 11 limpia (per-user y per-machine).
  - **Code-signing** (cert EV o DigiCert/Sectigo) — sin firma,
    SmartScreen muestra warning "app no reconocida" en cada install.
  - Decisión WebView2: bundlear bootstrapper (~2 MB) o mantener
    supuesto Win10 22H2+ (default actual).

### 3.2 Versionado + endpoint version-check

- [x] **`companion/__init__.py` ya expone `__version__ = "0.1.0"`** y la
  endpoint pública del companion `/health` ya lo retorna en `version`
  (junto al `session_token` que el frontend usa al primer round-trip).
  No se necesita un módulo separado `version.py`.
- [x] **Endpoint server `GET /api/core/companion/version-check?current=X.Y.Z`** ✅
  HECHO (Fase 3.2.b): `apps/core/views.py::CompanionVersionCheckView`
  (permission `AllowAny`, sin auth — el companion lo consulta antes
  de cualquier login). Retorna `{latest, current, min_required, url,
  update_available, required}`. Constantes `COMPANION_LATEST_VERSION`,
  `COMPANION_DOWNLOAD_URL`, `COMPANION_MIN_REQUIRED_VERSION`
  configurables vía `settings.py` (default `0.1.0`). Parser semver
  tolerante: acepta `MAJOR.MINOR.PATCH` con extras `-`/`+` (las
  descarta), y si `current` es inválido o vacío cae a `(0,0,0)` →
  forzando `required=true` (defensa segura). Wired en
  `apps/core/urls.py` como `companion/version-check/`.
- [x] **Hook React `useCompanionVersionCheck(currentVersion)`** ✅
  HECHO (Fase 3.2.c): `frontend/src/api/companion.js` exporta el hook
  que consume el endpoint con `fetch` directo (no `api.get` porque es
  público) y devuelve `{checking, latest, url, updateAvailable,
  required, minRequired, error, recheck}`. Re-consulta cada 6h por
  default (versionado se mueve lento). Solo dispara la consulta si
  `currentVersion` es truthy — i.e. companion ya online.
- [x] **Smoke 6/6** (`backend/tests/smoke_companion_version_check.py`):
  sin current → required; current=0.0.1 → required; current==latest
  → sin update; current=99.0.0 → sin update; garbage → required
  (fallback 0.0.0); sin auth → 200 (endpoint público).
- [x] **Companion al iniciar consulta version-check + notifica en tray** ✅
  HECHO (Fase 3.2.d): `tray.fetch_version_check(current)` consulta
  `/api/core/companion/version-check/` con `urllib.request` (timeout 4s,
  User-Agent identificable, fail-silent — el tray nunca rompe por
  esto). Backend URL configurable via `COMPANION_BACKEND_URL` (default
  `https://altoque.tv/maxeditor`). Al arrancar el tray dispara un
  thread daemon que llama `_check_updates_async`: si
  `update_available=true` muestra `pystray.Icon.notify()` con título
  `"MaxEditor — actualización requerida/disponible"` y mensaje "Nueva
  versión X.Y.Z. Usá el menú del tray para descargar.". Override
  con `COMPANION_AUTO_VERSION_CHECK=0` para silenciar (tests/dev).
- [x] **Item de menú "Buscar actualizaciones" (manual)** ✅ HECHO
  (Fase 3.2.e): `MenuItem("Buscar actualizaciones", on_check_updates)`
  re-consulta el endpoint y notifica el resultado: si hay update
  abre el browser al `url` devuelto; si no, notifica "Estás en la
  última versión (X.Y.Z)"; si no hay red notifica "No se pudo
  conectar al servidor para chequear.". Tooltip del icon ahora
  incluye la versión: `"MaxEditor Companion 0.1.0 — host:port"`.

### 3.2.5 Validación local end-to-end (PRE-DEPLOY)

**Filosofía**: el `.exe` instalado debe funcionar 100% contra Django local
antes de tocar HawkHost. Iteración offline = barata; bugs en producción =
caros.

- [x] **Django sirve el bundle React** ✅ HECHO (Fase 3.2.5.a):
  `backend/config/settings.py` agrega `STATICFILES_DIRS = [frontend/dist]`
  cuando el dir existe; `backend/config/urls.py` agrega catch-all SPA
  que sirve `frontend/dist/index.html` para rutas no-API/admin/media/static.
  WhiteNoise ya estaba en `MIDDLEWARE`. Resultado: tras `npm run build` +
  `collectstatic`, abrir `http://127.0.0.1:8000/` carga el editor React
  exactamente como lo verá la companion instalada.
- [x] **Build-time URL injection en companion** ✅ HECHO (Fase 3.2.5.b):
  Nuevo módulo `companion/src/maxeditor_companion/_build_target.py`
  expone `BUILD_EDITOR_URL` y `BUILD_BACKEND_URL` (ambos `None` por
  default = producción). `tray.py` los lee al import-time como
  fallback antes que `https://altoque.tv/maxeditor`. Permite que
  PyInstaller bundle un `.exe` apuntando a localhost sin tocar runtime
  env vars del usuario final.
- [x] **Script `tools/build_local_distribution.ps1`** ✅ HECHO (Fase 3.2.5.c):
  One-shot: `npm run build` → `collectstatic` → inyecta URLs locales
  en `_build_target.py` → `python companion/build.py` → restaura
  `_build_target.py` → invoca `ISCC.exe`. Flags: `-SkipFrontend`,
  `-SkipCollectStatic`, `-SkipCompanion`, `-SkipInstaller`, override
  `-LocalBackendUrl` / `-LocalEditorUrl`. Restauración del archivo
  ocurre en `finally` (idempotente aunque PyInstaller falle).
- [x] **Checklist `docs/LOCAL_INSTALL_TEST.md`** ✅ HECHO (Fase 3.2.5.d):
  Guía paso-a-paso: pre-requisitos, build, instalación, 7 secciones de
  verificación funcional (tray, editor abre, login, companion bridge,
  edit end-to-end, persistencia, uninstall preserva workspace),
  troubleshooting de los fallos comunes.
- [x] **Ejecutar el script + smoke completo** ✅ HECHO (Fase 3.2.5.e):
  PyInstaller instalado en venv. Build ejecutado: `.exe` de 45 MB en
  `companion/dist/MaxEditorCompanion.exe` apuntando a
  `http://127.0.0.1:8000` y arrancando en modo GUI. Cert HTTPS
  auto-instalado en `CurrentUser\Root` al primer arranque (idempotente).
  Inno Setup queda como fase posterior (opcional, no bloquea uso local).
- [x] **Bug fixing del primer smoke** ✅ HECHO (Fase 3.2.5.f):
  3 bugs reales aparecieron y fueron arreglados:
  (1) `companion/__main__.py` usaba relative imports → PyInstaller los
  rompía (`attempted relative import with no known parent package`).
  Fix: convertir a absolutos `from maxeditor_companion.x import ...` +
  `--paths={src}` en el comando PyInstaller.
  (2) Bundle de 2.89 GB porque `services/transcribe.py` arrastraba
  torch + faster_whisper. Fix: `EXCLUDE_MODULES` en `companion/build.py`
  (torch, whisper, transformers, scipy, etc.) → 43 MB.
  (3) PowerShell backticks en string doble en `build_local_distribution.ps1`
  abortaban el script. Fix: eliminar backticks.
  Detalles en `/memories/repo/companion-build-local-fixes.md`.
- [x] **GUI por default en build local** ✅ HECHO (Fase 3.2.5.g):
  Nuevo flag `BUILD_GUI_DEFAULT` en `_build_target.py`. El script de
  build local lo setea a `True` (junto a las URLs locales) → el `.exe`
  abre la ventana WebView2 directamente sin requerir
  `COMPANION_GUI=1`. Dev en venv sigue arrancando en tray (default
  `False`). Env var `COMPANION_GUI` mantiene prioridad si está seteada.

### 3.2.6 Backend hardening: workers daemon-thread frágiles ante reloads

**Síntoma**: Django runserver (autoreload) o un crash del thread deja
proyectos atascados en `status="processing"` para siempre, porque
`PROJECT_BACKGROUND_WORKERS` (in-memory set) y los threads daemon no
sobreviven al reload pero la fila en DB sí.

- [x] **Management command `recover_stale_processing_projects`** ✅ HECHO
  (Fase 3.2.6.a): `backend/apps/editor/management/commands/recover_stale_processing_projects.py`.
  Marca como `error` los projects con `status="processing"` y
  `updated_at < now - stale_after`. Limpia ghost flags
  in-memory en `PROJECT_BACKGROUND_WORKERS` para los 3 workers
  (media-preparation, transcription, export). Flags:
  `--stale-after <segundos>` (default 1800), `--dry-run`,
  `--project-id <int>`. Idempotente: si nada está stale, no toca DB.
  Uso típico: `python manage.py recover_stale_processing_projects`.
- [x] **Smoke automatizado** ✅ HECHO (Fase 3.2.6.b):
  `backend/tests/smoke_recover_stale_projects.py` (6/6 PASS):
  fresh-NO-tocado, stale-recuperado, ready-stale-no-tocado,
  --dry-run, --project-id, ghost-flag-limpiado.
- [ ] **Cron** que lo ejecute cada 30min en HawkHost (línea exacta
  documentada en [`docs/HAWKHOST_DEPLOY.md`](HAWKHOST_DEPLOY.md) §7;
  se activa al desplegar).

### 3.3 Deploy a HawkHost

**Checklist completo en [`docs/HAWKHOST_DEPLOY.md`](HAWKHOST_DEPLOY.md)**
(10 pasos: PostgreSQL, venv, .env, migrations + collectstatic,
Passenger/gunicorn, AutoSSL, cron, smoke prod, página descarga,
rollback plan).

- [ ] Setup PostgreSQL en cPanel
- [ ] Migrar BD desde SQLite (export → import con `dumpdata`/`loaddata`)
- [ ] Configurar dominio `altoque.tv/maxeditor`:
  - Apache/nginx reverse proxy a uWSGI (o cPanel "Setup Python App")
  - Servir frontend estático (build de Vite) en `/maxeditor/`
  - API en `/maxeditor/api/`
- [ ] HTTPS con Let's Encrypt (cPanel AutoSSL)
- [ ] CORS + CSRF: confirmar que requests del companion funcionan
- [ ] Variables de entorno via cPanel
- [ ] Cron jobs: `recover_stale_processing_projects` cada 30min +
  `clearsessions` diaria.
- [ ] Smoke prod: rebuild `.exe` apuntando a `https://altoque.tv/maxeditor`
  y probar end-to-end en PC limpia.

### 3.4 Crear cuentas de empleados ✅ HECHO

- [x] Comando management `create_user` con flags
  `--email --password --plan --status --max-devices --staff` y
  password autogenerada si no se pasa. Idempotente sobre el mismo email.
- [x] Smoke `backend/tests/smoke_create_user.py` (7/7 PASS):
  password explícita, password generada, plan office default,
  trial→uses_shared_keys=False, update idempotente, --staff,
  email inválido.
- [x] Documentado en
  [`docs/OFFICE_ONBOARDING.md`](OFFICE_ONBOARDING.md): alta, reset
  password, suspensión, liberar dispositivos, troubleshooting.

### 3.5 Página de descarga

- [ ] Ruta `altoque.tv/maxeditor/download` con:
  - Botón grande "Descargar para Windows"
  - Versión actual + changelog
  - Instrucciones de instalación
  - Link a soporte

### 3.6 QA antes de release

- [ ] Test en 3 máquinas físicas distintas de la oficina
- [ ] Probar flujo completo: descarga → install → login → editar → exportar
- [ ] Test de upgrade: instalar v0.1.0, lanzar, instalar v0.1.1 sobre encima
- [ ] Test de offline: ¿qué pasa si el server cae mid-edit? (debe seguir funcionando con cache local)

**🎯 Hito Fase 3 cumplido cuando:**
- ✅ Empleados de la oficina usando MaxEditor en producción
- ✅ Cero llamadas al `runserver` local de Django
- ✅ Updates de UI llegan automáticamente al hacer git pull en HawkHost
- ✅ Updates del companion notifican al usuario y permiten descarga manual

---

## FASE 3.5 — Vista de administración custom (opcional para v1, obligatoria v2)

**Objetivo:** Reemplazar el Django Admin (que ya cubre v1 oficina) por una vista integrada en React `/admin/users`, accesible solo a `is_staff=True`.

**Cuándo hacerlo:**
- En v1 oficina: **opcional**. Django Admin (`/admin/`) ya permite crear usuarios, asignar plan, ver dispositivos y revocarlos. Suficiente para 5–10 empleados.
- En v2 SaaS público: **obligatorio**. Soporte técnico necesita herramientas rápidas sin meterse al Django Admin.

**Duración estimada:** 3–4 días

### 3.5.1 Backend — endpoints admin

- [ ] Permission class `IsStaffUser` para `/api/admin/*`
- [ ] `GET /api/admin/users/` — lista paginada + filtros (status, plan, último login, búsqueda por email)
- [ ] `POST /api/admin/users/` — crear usuario (envía email de bienvenida con password temporal en v2)
- [ ] `PATCH /api/admin/users/<id>/` — editar (plan, status, max_devices, is_active)
- [ ] `DELETE /api/admin/users/<id>/` — soft-delete (is_active=False; nunca borrar para preservar historial)
- [ ] `POST /api/admin/users/<id>/reset-password/` — fuerza cambio en próximo login
- [ ] `POST /api/admin/users/<id>/impersonate/` — devuelve JWT con claim `impersonator_id` (auditoría)
- [ ] `GET /api/admin/users/<id>/devices/` + `DELETE /api/admin/devices/<id>/` — revocar
- [ ] `GET /api/admin/users/<id>/usage/` — proyectos, exports, minutos transcritos en mes actual
- [ ] `GET /api/admin/audit-log/` — últimos eventos: logins, cambios de plan, revocaciones

### 3.5.2 Frontend — `/admin/users`

- [ ] Ruta protegida con check `user.is_admin`
- [ ] Tabla densa con columnas: email, plan, status, dispositivos, último login, acciones
- [ ] Modal "Crear usuario" (email + password generado o manual + plan)
- [ ] Panel lateral por usuario: detalles + dispositivos + uso + botones (suspender, resetear pass, impersonate, cambiar plan)
- [ ] Vista "Audit log" con filtros por tipo de evento y usuario
- [ ] Botón "Salir de impersonación" persistente en topbar cuando está impersonando

### 3.5.3 Auditoría mínima

- [ ] Modelo `AuditEvent(actor, target_user, action, payload, ip, created_at)`
- [ ] Middleware o signals que registran: login exitoso, login fallido, cambio de plan, revocación de device, impersonate
- [ ] Retención: 90 días (purga automática vía management command + cron)

**🎯 Hito Fase 3.5 cumplido cuando:**
- ✅ Admin puede gestionar usuarios sin tocar Django Admin
- ✅ Soporte puede impersonar para reproducir bugs de un cliente
- ✅ Hay audit log de todas las acciones administrativas

---

## FASE 4 — Stripe + Suscripciones (SaaS público inicial)

**Objetivo:** Aceptar pagos. Los empleados de oficina siguen con `plan=lifetime` sin tocar.

**Duración estimada:** 5–7 días

### 4.1 Setup de Stripe

- [ ] Cuenta Stripe + productos: `Pro Mensual`, `Pro Anual`, `Lifetime`
- [ ] Variables: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID_PRO_MONTHLY`, etc.
- [ ] Endpoints:
  - `POST /api/billing/create-checkout-session` → URL de Stripe Checkout
  - `GET /api/billing/portal` → URL del portal de gestión
  - `POST /api/billing/webhook` → recibe eventos: `checkout.session.completed`, `subscription.updated`, `subscription.deleted`
- [ ] Lógica: webhook actualiza `Subscription.status` y `current_period_end`

### 4.2 Trial de 7 días sin tarjeta

- [ ] Al registrar usuario nuevo: crear `Subscription(plan='trial', current_period_end=now+7d, status='active')`
- [ ] Cuando expira: status → `past_due`
- [ ] JWT refresh detecta `past_due` y bloquea acceso al editor

### 4.3 UI de billing

- [ ] Página `BillingPage.jsx`:
  - Estado actual de la suscripción
  - Botón "Upgrade to Pro" → Checkout de Stripe
  - Botón "Manage subscription" → Portal de Stripe
- [ ] Modal "Tu trial expiró" con CTA de upgrade

### 4.4 Restricciones de plan

- [ ] Decorator `@require_active_subscription` en endpoints
- [ ] (Opcional) Rate limits diferentes por plan
- [ ] Documentar qué se permite en cada plan

**🎯 Hito Fase 4 cumplido cuando:**
- ✅ Un usuario puede registrarse, usar trial, pagar, y seguir usando
- ✅ Suscripción cancelada → acceso bloqueado en próximo refresh
- ✅ Webhooks robustos (idempotentes, manejan retries de Stripe)

---

## FASE 5 — Marketing site + Onboarding público

**Objetivo:** Cualquier persona puede llegar a altoque.tv y volverse cliente.

**Duración estimada:** 5–10 días

### 5.1 Landing page

- [ ] `altoque.tv/maxeditor/` (sin login) muestra landing
- [ ] Secciones: hero + problema + solución + features + pricing + testimonios + FAQ
- [ ] Botón principal: "Empezar trial gratis 7 días"
- [ ] CTA secundario: "Ver demo"

### 5.2 Onboarding flow

- [ ] Después de signup:
  - Pantalla de bienvenida
  - Botón "Descargar MaxEditor para Windows"
  - Después de download: "Ejecútalo y vuelve a esta tab"
  - Detección automática del companion → próximo paso
  - Tutorial guiado (importar primer video, primera edición)

### 5.3 Email transaccional

- [ ] Welcome email
- [ ] Trial ending in 2 days
- [ ] Trial expired
- [ ] Payment failed
- [ ] Subscription canceled
- [ ] Stack: SendGrid o Mailgun (cPanel admite ambos)

### 5.4 Soporte

- [ ] Form de contacto en altoque.tv/contact
- [ ] FAQ
- [ ] Discord o canal de soporte

**🎯 Hito Fase 5 cumplido cuando:**
- ✅ Un usuario externo puede registrarse, instalar, pagar y usar sin contactarte
- ✅ Funnel medible: visitors → signups → activations → conversions

---

## ESCALABILIDAD — Decisiones a tomar desde el día 1 (objetivo 100K usuarios)

**Objetivo del producto:** llegar a ~100,000 cuentas (5K activos simultáneos pico).

**Premisa que hace esto factible:** el server solo sirve UI + JSON; FFmpeg/ML corren en el cliente. El cuello de botella NO será CPU/bandwidth, será DB y observabilidad.

### Decisiones críticas (afectan código que hacemos AHORA)

| # | Decisión | Estado actual | Acción |
|---|---|---|---|
| 1 | **Postgres en producción desde día 1** | ❌ SQLite local | Configurar en HawkHost (Etapa D). Nunca lanzar v1 con SQLite. |
| 2 | **Índices en queries calientes** | ⚠️ Falta `Project.owner` index | Añadir `db_index=True` y `Meta.indexes` en próxima migración |
| 3 | **Paginación obligatoria en listas** | ⚠️ Endpoints actuales sin paginar | Añadir DRF `PageNumberPagination` por defecto (ahora) |
| 4 | **JWT stateless** | ✅ Hecho | — |
| 5 | **UUIDs públicos en URLs** (no IDs autoincrementales) | ❌ Usamos PK numéricas | Añadir campo `public_id = UUIDField(unique=True, default=uuid4)` en Project, User. Exponer solo este al frontend en v2. |
| 6 | **Soft delete (is_active)** | ✅ Account/Device lo tienen; ⚠️ Project no | Añadir `is_archived` a Project en próxima ronda |
| 7 | **Logging estructurado JSON con request_id** | ❌ Logging plano | Configurar `python-json-logger` antes de v2 |
| 8 | **Idempotency keys en operaciones críticas** | ❌ | Stripe webhooks + creación de proyectos costosos. Antes de v2 |

### Decisiones para v2 (antes de abrir signup público)

| # | Decisión | Esfuerzo |
|---|---|---|
| 9 | **Rate limiting** (`django-ratelimit` o nginx limit_req) | 1 día |
| 10 | **Sentry** en server + companion (errores) | medio día |
| 11 | **CDN Cloudflare** delante del frontend estático | 2 horas |
| 12 | **Redis cache** para queries comunes (`Account` por user_id, `Subscription` status) | 1 día |
| 13 | **Webhook Stripe idempotente** (tabla `ProcessedWebhookEvent` con event_id unique) | 1 día |
| 14 | **Captcha en signup** (hCaptcha gratis) | 2 horas |
| 15 | **Health endpoints separados** (`/healthz` liveness, `/readyz` con DB ping) | 1 hora |

### Decisiones para escala >10K activos (futuro, NO pre-optimizar)

- DB read replicas (cuando lecturas >70% del tráfico)
- Mover de HawkHost a VPS dimensionado (DigitalOcean/Hetzner ~$20–40/mes)
- Particionar tablas grandes por mes (`AuditEvent`, `UsageRecord`)
- Background workers dedicados (Celery + Redis + supervisor)
- Multi-región solo si hay clientes con SLA estricto

### Cosas que NO hacer (anti-patrones de over-engineering)

- ❌ Microservicios (monolito Django llega a 100K sin problema)
- ❌ Kubernetes (un VPS con systemd + nginx basta hasta 5K activos)
- ❌ NoSQL (Postgres + JSONB cubre todos los casos)
- ❌ GraphQL (REST con paginación es más simple y suficiente)
- ❌ Custom auth from scratch (simplejwt es probado en producción)

### Métricas a monitorear desde día 1 (v2)

- p50/p95/p99 latencia de endpoints
- Errores por endpoint y por versión del cliente
- Login fallidos por IP/usuario (anti-brute-force)
- Suscripciones nuevas/canceladas/past_due
- Tamaño promedio del payload de proyecto (detectar drift)

**🎯 Hito Escalabilidad cumplido cuando:**
- ✅ Producción corre en Postgres con índices definidos
- ✅ Toda lista paginada por defecto
- ✅ Sentry capturando errores en server + companion
- ✅ Backups automáticos con test de restore mensual
- ✅ Rate limiting activo en endpoints sensibles

---

## ENDURECIMIENTO INCREMENTAL — Estado actual y pendientes

### ✅ Hecho en este checkpoint (Fase 3 hardening)
- `IsAuthenticated` aplicado a endpoints críticos:
  - `ProjectListCreateView`, `ProjectDetailView`
  - `ProjectBootstrapView`, `EditorBootstrapView`
  - `OverviewView`, `WorkspaceSettingsView`
- Filtrado por owner (`Project.owner = request.user`) en list/detail/overview/bootstrap
- Admin/staff sigue viendo todo
- `HealthCheckView` explícitamente `AllowAny`
- Paginación por defecto DRF (`apps.accounts.pagination.DefaultPagination`, page_size=50, max=200)
- `ProjectListCreateView` usa `pagination_class = None` para no romper el dashboard que espera array plano
- `apps.accounts.permissions.IsOwnerOrAdmin` disponible para futuras vistas
- Frontend: `installAuthFetch()` parchea `window.fetch` para añadir `Authorization: Bearer` automáticamente a todas las llamadas `/api/...` (excepto `/api/auth/`), con refresh transparente en 401 — sin migrar cientos de `fetch()` directos del DashboardPage

### ⏳ Pendiente (siguiente iteración antes de Stage D / Fase 4)
- [ ] Aplicar `IsAuthenticated` y filtro de propiedad a las **inner views** de `/api/editor/projects/<pk>/...` (transcribe, depurate, suggest, export, etc.). Hoy son AllowAny — un usuario autenticado podría tocar proyectos ajenos si conoce el ID.
  - Patrón sugerido: helper `_user_project_or_404(request, pk)` que reemplace `get_object_or_404(Project, pk=pk)` con check de owner
- [ ] Endpoints de streaming (`video-preview/`, `timeline-media/`, `exports/<pk>/download/`, `exports/<pk>/stream/`) → necesitan estrategia de auth para `<video src>`:
  - Opción A: token corto en query (`?t=<signed>`)
  - Opción B: cookie de sesión paralela
  - Por ahora siguen públicos por defecto del DRF
- [ ] Migrar a Postgres antes de >5 usuarios concurrentes (SQLite no aguanta escritura concurrente)
- [ ] Activar `IsAuthenticated` como `DEFAULT_PERMISSION_CLASSES` global y dejar `AllowAny` solo en lo público (requiere completar el ítem 1)
- [ ] Verificar que las list views nuevas que se añadan asuman envelope paginado en frontend (`{count, results, next, previous}`)

---

## MULTI-TENANCY — Aislamiento por usuario (Fases A, B, C, D)

**Contexto del problema (descubierto antes de añadir un segundo usuario):** la app aislaba `Project` por owner pero `WorkspaceSettings` era un **singleton global** y `DepurationPromptVersion` no tenía `owner`. Esto significaba que cualquier usuario nuevo:
- Habría visto y modificado los settings del admin (prompt activo, modelo IA, opciones de export, transcripción, OpenAI key)
- Habría visto y activado los prompts creados por otros usuarios
- Habría podido tocar proyectos ajenos llamando directamente a inner endpoints con un ID adivinado

### ✅ Fase A — `WorkspaceSettings` por usuario (HECHO)

**Modelo (`apps/core/models.py`):**
- Añadido `WorkspaceSettings.owner = OneToOneField(User, null=True)`
- `singleton_key` ya no es `unique`; ahora se usa solo para identificar la fila legacy compartida (`singleton_key=1`, owner asignado al admin por la migración 0011)

**Migraciones:**
- `0010_multitenant_owner` — schema (añade `owner` a `WorkspaceSettings` y `DepurationPromptVersion`, relaja `singleton_key`)
- `0011_assign_legacy_owner_to_admin` — datos (asigna la fila legacy y los prompts huérfanos al primer superuser)

**API (`apps/core/views.py`):**
- `get_workspace_settings(owner=None)` ahora resuelve la fila correcta:
  - `owner` autenticado → su fila exclusiva (la crea clonando defaults del legacy si no existe)
  - `owner=None` o anónimo → fila legacy (workers asíncronos sin contexto)
- La OpenAI key NO se clona al crear settings de un nuevo usuario (cada uno configura la suya o usa el fallback `.env`)
- `_PER_USER_FIELDS` documenta qué campos se clonan como defaults
- `WorkspaceSettingsView` (GET/PUT) usa `get_workspace_settings(request.user)`

**Editor views (`apps/editor/views.py`):**
- 14 call sites de `get_workspace_settings()` actualizados:
  - Workers asíncronos → `get_workspace_settings(project.owner)` (cargan el proyecto antes para resolver owner)
  - Views con request → `get_workspace_settings(request.user)` o `get_workspace_settings(project.owner)` cuando el proyecto ya está cargado
- Esto cubre: transcripción, append, editorial agent (run/refine/adjust/split), depurate, deep analysis, sugerencias temáticas, generación de meta

**Smoke test verificado:**
- Admin → fila id=1 (legacy, singleton_key=1)
- Usuario nuevo → fila id=2 (singleton_key=None, sin OpenAI key clonada)
- Borrar usuario → cascade limpia su fila

### ✅ Fase B — `DepurationPromptVersion` por owner (HECHO)

- Añadido `DepurationPromptVersion.owner = ForeignKey(User, null=True)` en migración 0010
- Migración 0011 asigna prompts huérfanos al primer superuser
- `DepurationPromptVersionListCreateView`:
  - GET filtra por `owner=request.user` (admin/staff ve todos)
  - POST asigna `owner=request.user` automáticamente
- `DepurationPromptVersionActivateView` solo deja activar prompts propios (admin/staff puede activar cualquiera)
- `DepurationPromptDefaultActivateView` también con `IsAuthenticated`

### ✅ Fase C — Inner views project filtradas por owner (HECHO)

- Helper `_user_project_or_404(request, pk)` en `apps/editor/views.py`:
  - Admin/staff puede acceder a cualquier proyecto
  - Usuarios normales solo ven sus propios proyectos
  - Devuelve 404 (no 403) para no filtrar la existencia del recurso
- 60+ ocurrencias de `get_object_or_404(Project, pk=pk)` reemplazadas en bloque por el helper (PowerShell global replace, idempotente y verificado)
- DRF `DEFAULT_PERMISSION_CLASSES` cambiado a `['IsAuthenticated']` global
- Endpoints públicos marcados explícitamente con `permission_classes = [AllowAny]`:
  - `HealthCheckView`
  - `ProjectVideoPreviewStreamView`, `ProjectTimelineMediaView`
  - `ExportJobDownloadView`, `ExportJobStreamView`
  - `EmailTokenObtainPairView` (login), `TokenRefreshView`, `TokenVerifyView`

### 🛡️ Página HTML para 401/403 en /api/* (HECHO)

- `apps/accounts/middleware.py` `ApiHtmlErrorPageMiddleware` intercepta 401/403 cuando el cliente es navegador (`Accept: text/html`)
- Renderiza panel oscuro con paleta del editor, esquinas rectas, botón naranja "Ir al login"
- URL del login configurable vía `FRONTEND_LOGIN_URL` (dev: `127.0.0.1:5173/login`, prod: `/login`)
- Las llamadas fetch JSON siguen recibiendo el JSON original

### ✅ Fase D — BYOK con cifrado Fernet en reposo (HECHO)

**Decisión de diseño (vs. el plan original).** En lugar de crear un modelo `UserAPIKey` separado, se aprovechó que `WorkspaceSettings` ya es OneToOne con `owner` (multi-tenancy Fase A): cada usuario ya tiene su fila exclusiva, así que basta con **cifrar el campo existente** y hacerlo transparente vía property. Esto evita refactor de los 20+ call sites en `services.py` que leen `settings_obj.openai_api_key`.

**Cifrado (`apps/core/encryption.py`).**

- Algoritmo: **Fernet** (AES-128 CBC + HMAC-SHA256) de `cryptography>=44`.
- Origen de la llave de cifrado:
  1. `settings.BYOK_FERNET_KEY` (env, base64-url-safe 32 bytes) → producción.
  2. Derivación PBKDF2-SHA256(`SECRET_KEY`, salt fija, 200k iters) → solo dev/CI.
- API: `encrypt_str(plaintext) -> bytes`, `decrypt_bytes(data) -> str`.
- `_get_fernet()` cacheado con `lru_cache` (1 instancia por proceso).

> ⚠️ **Hardening pendiente para producción:** definir `BYOK_FERNET_KEY` como variable de entorno separada del `SECRET_KEY`. Si se rota `SECRET_KEY` sin tener `BYOK_FERNET_KEY` explícita, todos los tokens cifrados quedan ilegibles.

**Modelo (`apps/core/models.py::WorkspaceSettings`).**

```python
openai_api_key_enc = models.TextField(blank=True, default="")  # ciphertext

@property
def openai_api_key(self) -> str:
    raw = self.openai_api_key_enc or ""
    if not raw or not raw.startswith("gAAAAA"):
        return raw  # tolera valores legacy sin cifrar
    return decrypt_bytes(raw.encode("ascii"))

@openai_api_key.setter
def openai_api_key(self, value):
    text = (value or "").strip()
    if not text:
        self.openai_api_key_enc = ""
    elif text.startswith("gAAAAA"):
        self.openai_api_key_enc = text
    else:
        self.openai_api_key_enc = encrypt_str(text).decode("ascii")
```

**Migraciones (3 escalonadas para no perder datos).**

| # | Tipo | Acción |
|---|---|---|
| `0014_workspacesettings_openai_api_key_enc` | schema | Añade `openai_api_key_enc` (TextField vacío) sin tocar el campo plano |
| `0015_encrypt_existing_openai_keys` | data | Para cada `WorkspaceSettings` con valor plain → cifra con Fernet, escribe en `openai_api_key_enc`, vacía el plano. Reverse: descifra y restaura. |
| `0016_remove_workspacesettings_openai_api_key` | schema | Elimina el campo plano legacy |

**API (sin endpoints nuevos).**

- `PUT /api/core/settings/` con `{"openai_api_key": "sk-..."}` → cifra y persiste.
- `PUT /api/core/settings/` con `{"openai_api_key": ""}` → **borra la key BYOK** (antes lo ignoraba; corregido para permitir borrado explícito desde la UI).
- `GET /api/core/settings/` → nunca devuelve la key (write_only en serializer); solo `has_openai_api_key` y `openai_api_key_source` (`database` | `env` | `none`).
- `WorkspaceSettingsView.put` ahora **re-anota** `_openai_api_key_source` después del `save()` para que el response refleje el estado nuevo (bug pre-existente: respondía con la metadata cacheada del GET previo).

**Resolver de credenciales (orden de prioridad).**

`get_workspace_settings(owner)` → `_annotate_api_key_metadata(settings_obj)`:
1. `settings_obj.openai_api_key` (property descifra) ≠ "" → `source="database"`, usa la del usuario.
2. Sino, `settings.OPENAI_API_KEY` del `.env` ≠ "" → `source="env"`, fallback compartido.
3. Sino, `source="none"` y `_has_openai_api_key=False` (las funciones IA fallan limpiamente).

> El fallback a env permite que el sistema siga funcionando para usuarios que aún no han pegado su key. Cuando se quiera forzar BYOK estricto, basta con quitar las 2 líneas que copian `env_api_key` al settings_obj en `_annotate_api_key_metadata`.

**UI (`SettingsPage.jsx`).**

- Label cambiado a `"OpenAI API key (BYOK)"`.
- Mensaje de estado contextual:
  - `database` → "Tu key personal está activa (cifrada en BD con Fernet). Solo se usa para tus llamadas IA."
  - `env` → "No tienes key personal: se usará la key del sistema (.env) como fallback."
  - `none` → "Sin key configurada. Las funciones IA estarán deshabilitadas hasta que añadas una."
- Botón **"Borrar mi key"** visible solo cuando hay key personal activa (con confirm).

**Verificación E2E (Django test client).**

| Caso | Resultado |
|---|---|
| Round-trip `encrypt_str` / `decrypt_bytes` | ✅ "sk-test-12345" → 100-byte token "gAAAAA…" → "sk-test-12345" |
| `PUT /api/core/settings/` con key | ✅ status 200, `has_key=True`, `source="database"` |
| BD: el campo guarda **ciphertext, no plaintext** | ✅ no contiene plaintext en raw |
| `GET /api/core/settings/` no expone la key | ✅ `openai_api_key` ausente del payload |
| Aislamiento entre usuarios (admin vs tester) | ✅ admin ve `source="env"` mientras tester tiene su BYOK |
| `PUT` con `openai_api_key=""` borra la key | ✅ tras delete, source vuelve a `env` |

**Notas de seguridad.**

- La key nunca se serializa en GET (write-only en DRF).
- En reposo: cifrada con AES-128 CBC + HMAC. Atacante con dump de la BD necesita además la `BYOK_FERNET_KEY`.
- En memoria: descifrada al leer la property; los call sites de IA la pasan al cliente OpenAI como bearer y se garbage-collecta.
- Si se borra `BYOK_FERNET_KEY` (o se rota `SECRET_KEY` sin haber definido `BYOK_FERNET_KEY` explícita), las keys cifradas quedan ilegibles. La property devuelve `""` (las funciones IA caen al fallback env o a `none`); no se lanza excepción para no romper el sistema.

### Lecciones aprendidas

- **Singleton + multi-tenant no se llevan bien.** Heredar el modelo singleton original obligaba a refactor masivo (~14 call sites). Si se hubiera diseñado con `owner` desde el día 1, habría sido trivial.
- **Workers asíncronos siempre necesitan `project.owner`** — no `request.user` (no hay request). Cargar el proyecto temprano para resolver owner es el patrón correcto.
- **Las claves de API y los prompts NO se deben clonar entre usuarios** al provisionar un workspace nuevo. Cada usuario empieza con defaults limpios y trae su propia key.
- **DRF `DEFAULT_PERMISSION_CLASSES = IsAuthenticated` es seguro** siempre que se marquen explícitamente las excepciones (login, refresh, health, streams). Cambiarlo después implica auditoría manual de cada vista.

---

### ✅ Fase B' — Prompts globales del sistema editables por admin (HECHO)

**Principio de diseño (capas de prompts).** Para evitar que el avance del SaaS contamine la base, los prompts se organizan en **3 capas** estables:

1. **Capa 0 — Defaults en código.** Constantes versionadas en el repo (`_THEMATIC_SEG_SYSTEM_PROMPT`, `_PUBLISH_META_SYSTEM_PROMPT`, `DEEP_ANALYSIS_SYSTEM_PROMPT`, prompt dinámico del agente editorial). **Fuente de verdad**, nunca se pierde.
2. **Capa 1 — Override del super-admin (Fase B').** Tabla `GlobalPromptSetting`. Aplica globalmente a todos los workspaces. Solo el admin puede editar. Si no hay override (`is_overridden=False`), el resolver devuelve la constante de Capa 0 sin alteración.
3. **Capa 2 — Personalización por usuario (ya existía antes).** Cada usuario sigue ajustando *sobre la base efectiva*:
   - `DepurationPromptVersion` por owner: prompts de depuración propios.
   - Workspace settings por owner: instrucciones extra del agente editorial, modelo IA preferido, reglas de corrección.

> **Garantía:** mientras Capa 1 esté vacía (estado por defecto), el sistema se comporta bit-a-bit como antes de Fase B'. El cambio solo añade un punto de indirección (`get_global_prompt(key, fallback)`) que devuelve el fallback si no hay override.

> **Roles:** el admin global = super-usuario que administra cuentas, settings globales y la base de prompts del producto. Los usuarios normales personalizan a su gusto encima de esa base.

> **Roadmap futuro (no urgente):** si se necesitara override por usuario para los 4 prompts del sistema, se añadiría una Capa 3 (`UserPromptOverride`) que el resolver consultaría antes que la Capa 1. La arquitectura actual lo soporta sin refactor.

**Contexto.** Tras Fase B se identificaron 4 prompts adicionales hardcodeados que NO son del depurado y por tanto no estaban cubiertos por `DepurationPromptVersion`. Decisión del usuario (opción 2 de 3): **solo el admin edita esos 4 prompts globales; el cambio aplica a todos los workspaces**. Razonamiento: son prompts del producto (segmentación temática, metadatos, agente editorial), no preferencias por proyecto.

**Prompts cubiertos** (clave → ubicación del default):

| `prompt_key` | Default constante | Estrategia |
|---|---|---|
| `thematic_segmentation` | `_THEMATIC_SEG_SYSTEM_PROMPT` en `apps/editor/services.py` | Reemplazo completo |
| `publish_meta` | `_PUBLISH_META_SYSTEM_PROMPT` en `apps/editor/services.py` | Reemplazo completo |
| `deep_analysis` | `DEEP_ANALYSIS_SYSTEM_PROMPT` en `apps/editor/deep_analysis.py` | Reemplazo completo |
| `editorial_agent_extra` | `""` (vacío) | Append al system prompt dinámico del agente editorial |

> Los 3 primeros son monolíticos y se reemplazan completos. El del agente editorial se construye dinámicamente con 5+ variables (lista de bloques, instrucciones de formato, instrucciones custom del usuario, etc.), así que el override admin se **apenda** al final como bloque adicional, no reemplaza nada.

**Modelo (`apps/core/models.py`).**

```python
class GlobalPromptSetting(TimeStampedModel):
    PROMPT_KEY_CHOICES = [
        ("thematic_segmentation", "Segmentación temática (sugerir secuencias)"),
        ("publish_meta", "Generación de metadatos de publicación"),
        ("deep_analysis", "Análisis profundo del proyecto"),
        ("editorial_agent_extra", "Instrucciones extra para agente editorial (se apendizan)"),
    ]
    prompt_key = models.CharField(max_length=64, unique=True, choices=PROMPT_KEY_CHOICES)
    prompt_text = models.TextField(blank=True, default="")
    is_overridden = models.BooleanField(default=False)
    updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, ..., on_delete=SET_NULL)
```

Migraciones: `0012_globalpromptsetting.py`, `0013_alter_globalpromptsetting_prompt_key_and_more.py`.

**Resolver (`apps/core/global_prompts.py`).** Patrón de import perezoso para evitar circulares:

```python
def get_global_prompt(prompt_key, fallback):
    try:
        row = GlobalPromptSetting.objects.filter(prompt_key=prompt_key, is_overridden=True).first()
    except (OperationalError, ProgrammingError):
        row = None  # Tabla aún no migrada → usa fallback
    if row and row.prompt_text:
        return row.prompt_text
    return fallback() if callable(fallback) else fallback
```

Las constantes hardcodeadas se mantienen como fallback. En cada call site se hace `from apps.core.global_prompts import get_global_prompt` **dentro del cuerpo de la función** (no en el top del módulo) para no introducir dependencia cíclica entre `apps.core` y `apps.editor`.

**API admin-only (`apps/core/views.py`).**

| Método | Endpoint | Permiso | Acción |
|---|---|---|---|
| GET | `/api/core/global-prompts/` | `IsAdminUser` | Lista los 4 prompts con `default_text`, `override_text`, `is_overridden`, `effective_text`, char counts y `updated_by_email` |
| PUT | `/api/core/global-prompts/<prompt_key>/` | `IsAdminUser` | Setea override (`is_overridden=True`, `updated_by=request.user`); valida `prompt_text` no vacío y `prompt_key` válido (400 si no) |
| DELETE | `/api/core/global-prompts/<prompt_key>/` | `IsAdminUser` | Restaura default (`is_overridden=False`, `prompt_text=""`) |

**UI (`frontend/src/pages/SettingsPage.jsx`).** Sección condicional `<section className="settings-global-prompts">` que se renderiza solo si `useAuth().user.is_admin`. Cada prompt se muestra como tarjeta con:
- Header con label, char counts del default y override, y badge si está overridden
- Botones "Editar" / "Restaurar default"
- Textarea inline (rows=14, monospace) al editar, con "Cancelar" + "Guardar override"
- Preview `<pre>` con los primeros 320 chars del texto efectivo

Estilos en `frontend/src/styles/app.css` siguiendo la línea del editor: chrome denso, esquinas rectas, paleta dark, acento naranja para overrides activos.

**Verificación E2E (vía Django test client).**

| Caso | Resultado esperado | Resultado real |
|---|---|---|
| TESTER `GET` lista | 403 | ✅ 403 |
| ADMIN `GET` lista | 200 con 4 prompts | ✅ `[('thematic_segmentation', False, 1923), ('publish_meta', False, 1897), ('deep_analysis', False, 2132), ('editorial_agent_extra', False, 0)]` |
| ADMIN `PUT publish_meta` con texto | 200, `is_overridden=True`, `updated_by_email=admin@altoque.tv` | ✅ |
| TESTER `PUT publish_meta` | 403 | ✅ 403 |
| ADMIN `DELETE publish_meta` | 200, `is_overridden=False` | ✅ |
| ADMIN `PUT prompt_key inválido` | 400 | ✅ 400 |
| Resolver con override activo | devuelve override | ✅ "RESOLVER_OVERRIDE" |
| Resolver tras DELETE | devuelve fallback | ✅ "DEFAULT_FALLBACK" |

**Notas de seguridad.**
- `IsAdminUser` requiere `user.is_staff=True`. En este sistema, admin = `is_superuser=True` que también marca `is_staff`.
- El admin puede ver el `default_text` antes de overridear (no hay filtración: ya estaba hardcodeado en el repo).
- No hay aislamiento por workspace: por diseño, el override admin aplica globalmente. Si en el futuro se quiere override por workspace, hay que añadir FK `workspace` y cambiar el unique constraint a `(prompt_key, workspace)`.

---

## FASE 6 — Hardening + Auto-update + Observabilidad

**Objetivo:** Sistema confiable a escala. Reducir intervención manual.

**Duración estimada:** ongoing

### 6.1 Auto-update silencioso del companion

- [ ] Migrar a Squirrel.Windows o usar PyUpdater
- [ ] Companion en background descarga update e instala al cerrar
- [ ] Rollback si la nueva versión falla al iniciar 3 veces

### 6.2 Firma de código

- [ ] Adquirir EV Code Signing Certificate (~$300/año, ej. Sectigo)
- [ ] Firmar `MaxEditor_Setup.exe` y companion `.exe` con `signtool`
- [ ] Elimina warning de SmartScreen

### 6.3 Telemetría y monitoring

- [ ] Sentry en server y companion (errores)
- [ ] Métricas básicas: signups, MAU, exports/día, errores por versión
- [ ] Dashboard simple con Plausible o Umami para web analytics

### 6.4 Backups y recovery

- [ ] Backup diario de PostgreSQL via cron en cPanel
- [ ] Test de restore (al menos 1 vez al mes)
- [ ] Plan de DR documentado

### 6.5 Seguridad

- [ ] Rate limiting en `/api/auth/login` (anti-brute-force)
- [ ] Captcha en signup público (hCaptcha)
- [ ] Logs de auditoría de cambios de suscripción
- [ ] Pen-test ligero (al menos OWASP Top 10 review)

---

## Riesgos y mitigaciones

| Riesgo | Probabilidad | Impacto | Mitigación |
|---|---|---|---|
| Antivirus bloquea companion | Alta | Medio | Firma de código + whitelisting docs |
| SmartScreen warning | Alta | Bajo | EV cert en fase 6 |
| Usuario tiene FFmpeg conflictivo | Baja | Bajo | Companion usa su propia copia, no PATH del sistema |
| Mixed content errors | Media | Alto | Cert local autofirmado (mitigado por diseño) |
| Migración de data SQLite→PostgreSQL falla | Media | Alto | Test exhaustivo + dry-run + backup pre-migración |
| Endpoint FFmpeg local rompe en máquinas exóticas | Media | Medio | Telemetría + matriz de Windows versions soportadas |
| Stripe webhook se pierde | Baja | Alto | Stripe reintentos automáticos + endpoint idempotente |
| Companion-server desync (refresh JWT falla) | Media | Medio | Retry con backoff exponencial + UI clara |

---

## Criterios de "done" para cada hito

Una fase está realmente completa cuando:
1. Todos los checkboxes están marcados
2. Los entregables del hito funcionan en una **máquina limpia distinta a la de desarrollo**
3. Hay tests automatizados o manuales documentados
4. La documentación correspondiente está actualizada
5. El usuario (tú) ha validado el flujo end-to-end

---

## Próximos pasos inmediatos (los siguientes 3 días de trabajo)

1. **Hoy:** Revisar este plan, ajustar prioridades si algo no encaja
2. **Día 1:** Empezar Fase 0.1 — instalar `simplejwt`, crear app `accounts`, modelo User
3. **Día 2:** Modelo Subscription + endpoints de auth + tests
4. **Día 3:** Comenzar 0.3 — esqueleto de `media_engine/` y mover el primer módulo (probe)

---

**→ Documento vivo.** Actualizar al finalizar cada hito o cuando una decisión técnica cambie.
