Modulos VMS e IA
Estado: IMPLEMENTADO (Eje 1 y Eje 2) el 2026-08-21. Levantado el mismo día a partir de la frase del cliente: «son dos software diferentes pero unificados en uno — la VMS no graba 24/7, sino que tiene planes de grabación como Indigo, y Orion IA es lo que hace BriefCam».
Qué significa esto en la práctica
Sección titulada «Qué significa esto en la práctica»Dos productos con ciclos de vida propios que hoy comparten un solo backend:
- Orion VMS: graba vídeo según un plan por cámara (horario, disparador, retención) — no 24/7 fijo. Es la función de videovigilancia clásica: grabar, reproducir, exportar, mapas.
- Orion IA: analítica sobre vídeo — facial, tamper, cuerpo/postura, synopsis, mapa de calor. No le importa quién grabó el vídeo ni si se grabó en absoluto: puede leer de Orion VMS, de un NVR de terceros (IndigoVision hoy; otros mañana), o en teoría de un stream en vivo sin grabación de por medio.
Hoy el frontend ya separa esto (Layout.tsx, switcher "vms" | "ia",
navegación en grupos distintos). Lo que NO está separado es el backend en dos
puntos concretos, y son los dos ejes de este diseño.
Eje 1: la grabación es todo-o-nada, tiene que ser un plan por cámara
Sección titulada «Eje 1: la grabación es todo-o-nada, tiene que ser un plan por cámara»Estado actual (verificado 2026-08-21)
Sección titulada «Estado actual (verificado 2026-08-21)»continuous_recorder.go no tiene ningún concepto de horario ni de disparador
por movimiento. readConfig() lee un solo interruptor global
(module_vms + recording_enabled) y reconcile() arranca un ffmpeg
-c copy por cada cámara activa, indefinidamente, hasta que algo lo pare.
No hay campo en Camera para “esta cámara graba sí/no”, ni para franjas
horarias, ni retención por cámara (la retención es
recording_retention_days, global).
Existe Camera.AnalyticSchedule (JSONB) con un comentario aspiracional
(“per-analytic schedules”) pero cero consumidores en todo el backend y cero
UI que lo edite — es terreno libre, no hay que migrar nada.
Diseño: RecordingPlan
Sección titulada «Diseño: RecordingPlan»Tabla nueva, igual que se hizo con StorageTarget para multi-storage —
plantilla reutilizable, asignable por cámara o por grupo:
type RecordingPlan struct { ID uint Name string // "24/7", "Horario oficina", "Solo eventos" Trigger string // "continuous" | "schedule" | "motion" Schedule JSONMap // franjas: {"mon":[["06:00","22:00"]], "tue":[...], ...} // vacío/nil cuando Trigger != "schedule" RetentionDays int // 0 = usa la retención global del destino Active bool}Camera.RecordingPlanID *uint— nullable. Nil = plan por defecto (continuo, retención global), igual que se hizo conStorageTargetID: un despliegue existente no cambia de comportamiento hasta que un admin asigne planes a mano.- El disparador
"motion"queda definido en el esquema pero fuera de alcance de esta fase — hoy no existe ningún concepto de “grabar solo si hay movimiento” en el recorder (se confirmó por grep exhaustivo). Añadirlo exige decidir con qué señal se dispara (¿la propia analítica de IA, que entonces dejaría de ser independiente de VMS?) y es una decisión de producto aparte."continuous"y"schedule"son el alcance real de esta fase.
continuous_recorder.go: el gancho ya existe
Sección titulada «continuous_recorder.go: el gancho ya existe»reconcile() ya compara, cámara por cámara, si algo cambió y para/arranca
solo esa cámara sin tocar las demás — es el mecanismo que hoy usa la
reasignación de destino (multi-storage). El mismo patrón sirve para el plan:
para cada cámara activa con module_vms=true: plan := resolver_de_plan(cámara) // nil-safe, cae al plan por defecto debe_grabar_ahora := plan.Trigger == "continuous" || (plan.Trigger == "schedule" && dentro_de_franja(plan, ahora_bogota())) si debe_grabar_ahora y no está corriendo → arrancar si NO debe_grabar_ahora y está corriendo → parar (deja el segmento actual cerrarse solo)El tick de reconcile() es cada 60 s — la franja horaria tiene una latencia
de activación/desactivación de hasta 1 minuto, que es aceptable para un plan
de horario (no para “grabar el segundo exacto de un evento”, que es harina
del disparador "motion", fuera de alcance).
Retención por plan, no solo por destino
Sección titulada «Retención por plan, no solo por destino»pruneByDiskUsage ya recorre por destino (multi-storage, 2026-08-21).
Se añade un segundo filtro: dentro de cada destino, un segmento se poda si
supera el máximo entre la retención del destino y la retención del plan
de su cámara (lo que primero se cumpla). Cámara con plan “solo 7 días” no
debe sobrevivir 30 días porque su destino tiene retención de 30 — el plan es
más específico y gana.
Eje 2: la IA no puede depender de que el vídeo esté “disfrazado” de Orion
Sección titulada «Eje 2: la IA no puede depender de que el vídeo esté “disfrazado” de Orion»Estado actual (verificado 2026-08-21) — esto es lo más importante del diseño
Sección titulada «Estado actual (verificado 2026-08-21) — esto es lo más importante del diseño»Hoy, para que Review/Synopsis analicen vídeo de un NVR IndigoVision, el
backend primero lo copia a recordings/<camId>/<día>/HH-MM-SS.mp4 —
exactamente la misma ruta y el mismo formato de nombre que usa una grabación
propia de Orion (indigo_review_service.go, vía clip-service HTTP o SMB-pull
- ffmpeg). Solo entonces
dense_tracker.pyla “descubre” — literalmente camina el filesystem (discover_segments(),recordings_root / cam / día.glob("*.mp4")) sin ninguna capa de abstracción, y hoy ya incluye explícitamente las cámarasbrand == "indigo-nvr"en ese barrido.
Es decir: la independencia de la IA respecto al VMS existe a nivel de “de
quién es el vídeo fuente”, pero no a nivel de pipeline — el pipeline exige
que todo pase por el disfraz de recordings/. Eso es lo que hace que
Synopsis-sobre-Indigo dependa de traer el vídeo completo por SMB o por el
clip-service ANTES de poder analizar nada, y es la causa raíz del cuello de
botella que Carlos está investigando (ver [[orion-sinopsis-indigo]]).
Diseño: VideoSource, una interfaz en vez de un filesystem
Sección titulada «Diseño: VideoSource, una interfaz en vez de un filesystem»El contrato real que necesita dense_tracker.py (y por extensión
face_recorded.py, synopsis_renderer.py) es mínimo, ya se dedujo del
código existente:
class VideoSource(Protocol): def listar_segmentos(self, camera_id: int, desde: datetime, hasta: datetime) -> list[Segmento]: ... def abrir(self, segmento: Segmento) -> str: # ruta legible por OpenCV/ffmpeg ...
@dataclassclass Segmento: camera_id: int started_at: datetime # con tz America/Bogota, como hoy deriva del nombre rel_key: str # identificador estable, hoy es "<cam>/<día>/<archivo>" mtime: floatDos implementaciones:
OrionRecordingSource— lo que hay hoy, caminarecordings/.listar_segmentoses literalmentediscover_segments()renombrado, sin cambiar su lógica.IndigoNVRSource— nueva.listar_segmentosusa ONVIF Profile G (Recording Search) para listar qué grabaciones existen en el NVR sin descargarlas — confirmado que hoy NO está implementado (internal/onvif/solo cubre discovery/auth/streaming).abrir()es donde sigue viviendo el fetch bajo demanda (clip-service o SMB), pero solo para el segmento que realmente se va a procesar, no para todo el rango de una vez.
Esto invierte el orden actual: hoy es “traer todo → después descubrir qué hay”; el diseño es “listar qué hay → traer solo lo que se procesa”. Es la diferencia entre transferir 2 horas de vídeo por SMB para analizar 2 minutos, y no transferir nada hasta saber exactamente qué 2 minutos hacen falta.
Por qué no es un rediseño de los workers, es una capa
Sección titulada «Por qué no es un rediseño de los workers, es una capa»dense_tracker.py no necesita reescribirse: su contrato interno ya es
(camera_id, ruta abrible, timestamp, mtime) — eso es exactamente lo que
VideoSource.listar_segmentos() + abrir() le siguen dando. El cambio es
de dónde saca esa lista, no qué hace con ella. Mismo principio que ya se
aplicó con StorageResolver: una sola función que decide la fuente,
compartida por todo lo que hoy lee recordings/ directamente.
El vínculo Cámara↔canal del NVR, hoy es más débil de lo que parece
Sección titulada «El vínculo Cámara↔canal del NVR, hoy es más débil de lo que parece»Camera.DeviceID ya asocia una cámara Orion con un Device (NVR) completo,
pero no con un canal específico dentro de él — la resolución de “cuál
grabación en el NVR es esta cámara” se hace por IP en tiempo de consulta
(Camera.CameraIP()). Es suficiente para el clip-service de hoy (que recibe
camera_ip), pero ONVIF Recording Search identifica grabaciones por
RecordingToken, no por IP — hace falta un campo nuevo,
Camera.NVRChannelToken string, poblado por el flujo de descubrimiento que
ya existe (DiscoverChannels), para que IndigoNVRSource sepa qué token
pedir sin adivinar por IP.
Cómo se relaciona con module_vms / module_ai
Sección titulada «Cómo se relaciona con module_vms / module_ai»Los dos flags ya existen y ya hacen división de responsabilidad real (no son
cosméticos): module_vms=false apaga el recorder; module_ai=false bloquea
la ingesta de eventos analíticos. Este diseño no los toca — los hace
verdad de punta a punta:
- Hoy, con
module_vms=false, Synopsis-propio da 409 (correcto, no hayrecordings/propio) pero Synopsis-sobre-Indigo sigue exigiendo pasar por ese mismo árbol de todos modos. ConVideoSource, Synopsis-sobre-Indigo deja de necesitarmodule_vmsen absoluto — es la prueba de que “Orion IA no depende de Orion VMS” deja de ser cierto solo en el frontend. RecordingPlanvive exclusivamente bajomodule_vms— no tiene sentido con la VMS apagada.
Estado de implementación (2026-08-21)
Sección titulada «Estado de implementación (2026-08-21)»Eje 1 — RecordingPlan: COMPLETO
Sección titulada «Eje 1 — RecordingPlan: COMPLETO»models.RecordingPlan+Camera.RecordingPlanID(nullable, mismo criterio queStorageTargetID: nil = plan por defecto, un despliegue existente sigue grabando 24/7 sin tocar nada).PlanResolver(recording_plan_resolver.go): mismo patrón de caché TTL de 30s queStorageResolver. Un plan borrado/desactivado cae a continuo, NO apaga la cámara en silencio — mismo criterio de seguridad queStorageTarget.continuous_recorder.go:reconcile()para/arranca por cámara según su franja (reutilizando el mismo mecanismo de la reasignación de destino), y la retención por plan (cleanupOnce) poda cada cámara con el MÍNIMO entre su plan y la retención del destino — un plan más estricto SIEMPRE gana.- CRUD admin-only en
/api/recording-plans(lectura abierta a cualquier operador — a diferencia destorage-targets, un plan no expone infraestructura). - 15 tests: evaluación de franjas (incluida medianoche cruzada), plan continuo/schedule/motion/desactivado/borrado, retención por plan que NO toca otras cámaras del mismo destino, CRUD con duplicados y validación.
trigger="motion"está en el esquema y se acepta al crear un plan, peroPlanResolverlo trata como continuo (documentado y con test que lo fija): no hay ninguna señal de movimiento independiente de la IA todavía. Implementarlo de verdad es una decisión de producto aparte — de qué señal lo dispara — que se queda fuera a propósito.
Eje 2 — VideoSource: COMPLETO, con una limitación medida
Sección titulada «Eje 2 — VideoSource: COMPLETO, con una limitación medida»workers/synopsis/video_source.py: interfazVideoSource+OrionRecordingSource(envuelvediscover_segments()sin cambiar su lógica) +IndigoNVRSource.dense_tracker.pyya no llamadiscover_segments()a ciegas para las cámaras Indigo: las lista conIndigoNVRSource.listar_segmentos()(SMB, solo nombres de archivo, sin descargar) y solo materializa (abrir()) el segmento concreto que se va a procesar, liberando el temporal siempre (finally) — invierte el orden destage_range_into_recordings, que hoy trae y transcodifica el rango completo antes de que exista ningún trabajo de Sinopsis.- Verificado contra el NVR real de Girardot/CPMSGIR (10.3.96.10) el
2026-08-21: NO expone ONVIF Profile G — ni los puertos HTTP típicos
(80/8080/8899/8000/2020) responden.
IndigoNVRSourceusa el backend SMB ya existente (indigo_fetch.list_vmf_for_range/fetch_vmf/vmf_to_mp4) detrás de la misma interfaz. Si un sitio futuro tuviera un NVR con Profile G, se añade un backend nuevo sin tocardense_tracker.pyde nuevo — esa es la promesa de la interfaz. - Recorte fino: CERRADO (2026-08-21, misma tarde).
Segmentoganó dos campos opcionales,recorte_ss/recorte_to(NoneparaOrionRecordingSource, que no los necesita: cada segmento propio ya ES la ventana exacta de 15 min que grabó ffmpeg).IndigoNVRSource.listar_segmentos()calcula la ventana dentro del.vmfque cubre el rango pedido — mismo cálculo que ya usabastage_range_into_recordings, no una fórmula nueva — yabrir()la aplica al transcodificar. Un.vmfde hasta 6h ya no se procesa entero para analizar unos minutos. 3 tests nuevos fijan el cálculo (incluido el caso “la ventana pedida empieza antes de que arranque el archivo”, dondessdebe quedar en 0, no en negativo) y queabrir()realmente propaga los valores avmf_to_mp4en vez de ignorarlos. - Bug encontrado y corregido durante la integración: el refresco de fondo
(
maybe_refresh_background) usabaseg(ruta remota simbólica) en vez deseg_local(MP4 ya materializado) — habría fallado en silencio para toda cámara Indigo. - 11 tests en
test_video_source.py, incluido el que fija la garantía central:listar_segmentos()nunca llama afetch_vmf/vmf_to_mp4. 244 tests Python en verde en total (suite completo deworkers/tests).
Hallazgo colateral: AnalyticSchedule no estaba muerto, solo mal buscado
Sección titulada «Hallazgo colateral: AnalyticSchedule no estaba muerto, solo mal buscado»La investigación previa a este diseño concluyó “cero consumidores en el
backend” porque solo miró Go. workers/shared/worker_utils.py:is_within_schedule()
SÍ lo consume — pero para decidir cuándo corre una ANALÍTICA de IA
({"enabled":true,"bands":[...]}, por tipo de analítica), no cuándo graba
una cámara. Es, sin buscarlo, la misma separación de este diseño: un
horario para IA (AnalyticSchedule) y un horario distinto para VMS
(RecordingPlan.Schedule) — formatos y consumidores deliberadamente
separados, no una duplicación a limpiar.
Fuera de alcance (deuda técnica identificada, no bloqueante)
Sección titulada «Fuera de alcance (deuda técnica identificada, no bloqueante)»Migración de dense_tracks.segment_path a multi-destino: CERRADA
(2026-08-21) — ver docs/almacenamiento-distribuido.md. Se resolvió sin
migrar la columna: los workers Python ahora consultan al API la raíz de
cada cámara (OrionStorageResolver).
Disparador "motion" en RecordingPlan: investigado, sigue sin
implementar — y con razón. No es solo “falta una decisión de qué señal
usar”: la única señal de movimiento que existe hoy (motion_detector.py)
es infraestructura de IA, no de VMS:
- Publica en un bus de actividad sobre Redis (
activity_bus.py) cuyo propósito documentado es “que face/yolo no gasten CPU en cámaras quietas” — es un componente de gating para analítica, no una señal de grabación. - Está catalogado como
AnalyticMotion(analytic_motionpor cámara, bajoallowed_analytic_types), con el mismo guardrail que face/tamper. - Si
RecordingPlan.trigger="motion"consumiera esta señal, grabar pasaría a depender de que Redis esté vivo, el worker de motion esté corriendo, ymodule_aiesté encendido — exactamente el acoplamiento VMS→IA que este diseño existe para deshacer, solo que invertido.
La decisión real pendiente, para cuando se aborde: si el VMS necesita su
propio detector de movimiento ligero (MOG2 sin Redis, sin depender de
module_ai, activable con VMS solo) — que es como funciona el “grabar solo
con movimiento” en un NVR clásico — en vez de reutilizar el de IA. Es una
pieza nueva, no cablear la existente.
Frontend de RecordingPlan (página de gestión + selector en la ficha de
cámara) y recorte fino en IndigoNVRSource: completados el 2026-08-21.
Ver [[orion-almacenamiento-distribuido]] para el precedente de diseño (StorageTarget/StorageResolver, mismo patrón de plantilla-por-cámara) y [[orion-sinopsis-indigo]] para el cuello de botella actual que el Eje 2 resuelve de raíz.