dec-0110

Vista generada de dec-0110: Motor de medios dual: demux/remux/packaging propio en Zig y libav (FFmpeg 8) como librería, configurable

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0110-motor-de-medios-dual-zig-libav.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0110-motor-de-medios-dual-zig-libav.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoLOCKED
Fecha2026-09-28
Ficherodocs/decisions/dec-0110-motor-de-medios-dual-zig-libav.md

Enmienda a: dec-0039

Por qué importa (del frontmatter del ADR):

Gobierna cómo Styx hace demux, remux y packaging fMP4-CMAF/HLS: motor propio en Zig y motor libav (FFmpeg 8 como librería vía C interop dentro del daemon), intercambiables detrás de un contrato común MediaEngine. Sin este ADR, un executor leería dec-0039/r07 («spawn ffprobe/ffmpeg = v1 sancionada») y añadiría más procesos ffmpeg, o escribiría el demux Zig sin un oráculo in-process con el que compararlo, o acoplaría el daemon a libav sin posibilidad de build sin FFmpeg (licencias).

Nodos del roadmap que lo citan en refs: track/byte-runtime/sec, track/media-engine, track/media-engine/audio-transcode

Páginas de la documentación que lo citan: Plugins y fuentes (implementado), Motor de medios y plan de reproducción (implementado), Planos y servicios (implementado), Transferencias (especificado), Modos de reproducción (implementado), Motores Zig y libav (especificado), Navegadores (especificado), Pistas y subtítulos (especificado), Visión (especificado), Primera película en Chrome (especificado)

Texto del ADR

Leído de docs/decisions/dec-0110-motor-de-medios-dual-zig-libav.md, el fichero canónico.

dec-0110 — Motor de medios dual: demux/remux/packaging propio en Zig y libav (FFmpeg 8) como librería, configurable

  • Fecha: 2026-09-28
  • Estado: LOCKED
  • Nota de enmienda (2026-10-02, waxin, P9 del plan docs/track/byte-runtime/plans/vertical-vod-web.md): §1.2 incluye también TrueHD (AC3/EAC3/DTS/TrueHD → AAC, encoder aac nativo y swresample, todo LGPL). Se cierra con el gate item nuevo track/media-engine:ME7 (milestone track/media-engine/audio-transcode).
  • Decisor: waxin, vía AskUserQuestion (2026-09-28): demux/remux/packaging fMP4-CMAF/HLS PROPIO en Zig, con motor CONFIGURABLE entre el propio Zig y FFmpeg 8 (último) para comparar; FFmpeg NUNCA invocado como proceso: sólo como librería (libavformat/libavcodec/libavutil) vía interop C desde el daemon Zig (FFI).
  • Enmienda: dec-0039 (spawn ffprobe/ffmpeg como v1 sancionada, derivado de r29-D3).
  • Refrenda: r01 (Bun decide, Zig ejecuta; los bytes no pasan por JS), r03 (sin FUSE en hot path), r04 (SeekableMediaSource, cancel(requestId)), r07 (orden FFI: daemon Zig por socket > Node-API > bun:ffi), dec-0014 (C-ABI reservado a Zig↔libav), dec-0029 (crates por capability: el placement de §1 respeta su taxonomía media-core / transmux + libav-bridge / media-daemon sin cambiarla), r25 §2 (crates transmux + libav-bridge, @cImport; su «CTransmuxFFI se consume tal cual» queda superado por el hallazgo de licencia del harvest, §1.1), r31 D4 / dec-0043 (clean-room; d02: Styx MPL-2.0, provisional, LICENSE raíz), r16 (frontier), r23 (codec-agnostic, capabilities versionadas).
  • Cita: dec-0109 (PROPOSED: facts en @styx/domain, parser en native/zig/media-core, policy en @styx/playback-policy; spike zig-container-facts), dec-0112 (gate WC-JF12).
  • Numeración: redactado como dec-0113; renumerado a dec-0110 antes de merge al fijarse la numeración final de las tandas 2/3: dec-0104 colas VOD · dec-0105 quic-zig upstream · dec-0106 ExtentMap · dec-0107 live/VOD · dec-0108 R-08 · dec-0109 placement mediakit · dec-0110 este ADR · dec-0111 client-core ABI v0 · dec-0112 gate WC-JF12 · dec-0113 identity token model · dec-0114 scanner por contenido · dec-0115 TypeBox vs Arktype (SUPERSEDED) · dec-0116 contratos TypeBox + Elysia 2 beta (LOCKED) · dec-0117+ reservados (spire/conduit/zkit). Nota del integrador de la tanda 3: la reserva original "0115 TypeBox, 0116+ spire/conduit/zkit" se desplazó uno al integrarse bench/typebox-vs-arktype, que ocupó 0115 y 0116.

Contexto

  1. No hay motor de medios. native/zig no tiene demux (ni moov, ni EBML/Cues), ni remux, ni packaging fMP4/CMAF/HLS. Los facts de contenedor llegan por un único camino: apps/catalog-svc/src/service/ffprobe/ffprobe-client.ts → cmd.workers.probe por NATS → apps/workers-svc/src/service/ffprobe/ffprobe-client.ts hace Bun.spawn('ffprobe', …).
  2. dec-0039 (split de r29-D3) sancionaba ese spawn como v1 (por r07: «FFmpeg como proceso, no FFI de inicio») con migración a libav «prioritaria». dec-0014 ya reservaba el C-ABI para Zig↔libav, y r25 §2 / dec-0029 ya nombraban el crate libav-bridge con @cImport.
  3. El spike zig-container-facts (dec-0109, PROPOSED) mostró que un parser Zig clean-room de ISOBMFF + Matroska da los mismos facts que ffprobe (688/703 iguales, 0 mismatches atribuibles al parser, extradata byte a byte igual) leyendo ~0,9 % del fichero, en µs y sin proceso.
  4. El diferencial frente a Jellyfin en remux y seek (dec-0112, SEEK-01 / DENSITY-01) depende de no tener un proceso ffmpeg por sesión. Y para saber si la ventaja es de arquitectura o de implementación del demux, hay que poder correr el mismo Styx con dos motores.

Qué se decide

1. Dos motores, un contrato: MediaEngine

  • El contrato MediaEngine vive en native/zig/media-core (módulo engine/), junto a los tipos comunes. Es una interfaz Zig (vtable/union etiquetada) con dos implementaciones:
    • engine = zig — motor propio: demux ISOBMFF/fMP4 y Matroska/WebM, índice de keyframes, remux a fMP4-CMAF. Zig puro, repartido entre media-core (demux) y native/zig/transmux (mux fMP4-CMAF) según §1.1; el demux/facts reutiliza el parser que dec-0109 propone para media-core (si dec-0109 se lockea con otro placement, el motor Zig lo sigue; este ADR no decide ese placement).
    • engine = libav — libavformat/libavcodec/libavutil de FFmpeg 8 enlazadas en el daemon. Vive en un crate separado native/zig/libav-bridge (dec-0029, r25 §2), que es el único sitio con @cImport de cabeceras de FFmpeg y el único que enlaza sus libs.
  • Superficie común (mismos tipos de entrada/salida para ambos):
    • probe(source, opts) → ContainerFacts — facts de contenedor y pistas (la forma de los facts la fija dec-0109 / track/domain-media; el contrato sólo exige que ambos motores emitan la misma estructura).
    • openDemuxer(source, opts) → Demuxer con nextPacket(), seek(ts, flags), keyframeIndex(); paquetes en un Packet común (pista, pts/dts en base de tiempo racional, flag keyframe, datos como slice con dueño explícito).
    • openRemuxer(selection, target = .fmp4_cmaf) → Remuxer con writePacket(), flushFragment() → Fragment (bytes + metadatos: duración, keyframe inicial, rangos).
    • packager HLS/CMAF (init segment, media segments, playlists m3u8, byte-range HLS): la generación de playlists y la segmentación por tiempo son código Zig común a ambos motores. Cada motor sólo aporta demux + mux de fragmentos. Así la comparación aísla lo que difiere y no duplica lógica de delivery.
    • Cancelación por request (requestId, r04/r20 §3.6) propagada a las lecturas de la fuente; error set común (EngineError), sin filtrar códigos AVERROR fuera del bridge.
    • Capabilities del motor declaradas y versionadas (r23): qué (contenedor, códec, target) soporta cada motor. La selección las consulta; nunca se asume.
  • Entrada de bytes: ambos motores leen sólo a través de la fuente del daemon (SeekableMediaSource Zig: hoy media-core/source/local_file.zig con ReadRequest/ ReadPurpose, y el resto de fuentes cuando existan). libav recibe un AVIOContext custom cuyos callbacks read_packet/seek llaman a esa fuente con el purpose correcto (probe, playback, seek…). Prohibido pasar rutas de fichero o URLs a avformat_open_input: libav no abre ficheros, sockets ni protocolos por su cuenta (r03, r04; además cierra superficie de ataque de los protocol handlers de FFmpeg).

1.1 Placement por crate (conforme a dec-0029 / r25 §2, sin cambiarlo)

Este ADR no mueve la taxonomía de crates de dec-0029 / r25 §2: la rellena.

CrateQué pone este ADR ahíDepende de
native/zig/media-corecontrato MediaEngine (engine/), tipos comunes (Packet, Fragment, EngineError, capabilities del motor), facts de contenedor y demux Zig (ISOBMFF/fMP4, Matroska/WebM, índice de keyframes), fuente (source/)—
native/zig/transmuxremux Zig: loop de remux (selección de pistas, rebasado pts/dts, arranque en keyframe tras seek), muxer fMP4-CMAF propio (engine=zig) y el packager HLS/CMAF común a ambos motores (init/media segments, m3u8, byte-range HLS). Zig puro: nunca importa libav-bridgemedia-core
native/zig/libav-bridgeimplementación engine=libav, clean-room: demux y mux de fragmentos con libavformat, transcodificación de audio a AAC con libavcodec (§1.2), AVIOContext custom sobre la fuente, av_log, manifiesto de licencia. Único @cImport de FFmpegmedia-core
native/zig/media-daemoncompositor: registra los motores disponibles (libav sólo con -Dlibav=true), aplica la selección de §3 y expone los comandos IPClos tres anteriores
  • transmux no depende de libav-bridge: el packager consume Fragment del contrato, venga del muxer Zig o del mux de libav. Así el daemon compila y empaqueta con -Dlibav=false, y la comparación entre motores sólo varía demux + mux de fragmentos (§1, superficie común).
  • El «remux loop + muxing fMP4 + seek/DTS rebasing» en Zig que r25 §2 asignaba a transmux es exactamente el remux Zig de esta tabla; la diferencia es que aquí el loop es agnóstico del motor (lee Packet del contrato), en lugar de ir atado a libav. Es clean-room, igual que libav-bridge (ver el punto siguiente).
  • CTransmuxFFI no se vendoriza ni se enlaza. TransmuxCore (y su capa C CTransmuxFFI, app IPTV) es GPL-3.0 (docs/archive/research/reference-harvest/projects/mks-iptv.md: LICENSE raíz GPL v3 + TransmuxCore/README), incompatible con Styx MPL-2.0 (r31 D4 / dec-0043, d02). Por eso libav-bridge y el remux Zig de transmux son clean-room: TransmuxCore se puede consultar como referencia de comportamiento (invariantes, state machines, recetas de correctitud A/V como el delay_moov/frame_size de AC3/EAC3/DTS, fallos observados), con el registro por idea que exige r31 D4; no se copia, porta ni vendoriza código ni structs.
  • Qué hace cada pieza de TransmuxCore (para no heredar una premisa falsa): CTransmuxFFI es sólo la capa C de acceso a FFmpeg (apertura, demux, codec, encoders). El remux a fMP4 fragmentado creciente y la playlist HLS no viven ahí sino en Swift: TransmuxingService (loop av_read_frame/av_interleaved_write_frame, fMP4 progresivo) y HLSSegmenter (parsea moof/mdat y genera el m3u8). En Styx ese papel lo cubren el remux loop y el packager Zig de native/zig/transmux (tabla de arriba), no ningún port.
  • r25 §2 decía que CTransmuxFFI «se consume tal cual» (@cImport o compilar sus .c). Esa premisa quedó superada por el hallazgo de licencia del harvest (mks-iptv.md, posterior a r25): consumirlo tal cual metería código GPL-3.0 en un artefacto MPL-2.0. r25 no se edita (registro de época); la corrección queda registrada aquí.

1.2 Transcodificación de audio (Tier 1)

  • Decisión: PERMITIDA en el build por defecto (LGPL) la transcodificación de audio AC3/EAC3/DTS → AAC con vídeo en copia (el Tier 1 de TransmuxCore, ~30 % de los streams IPTV según docs/archive/research/2026-06-21-mksiptv-app-exploration.md). Decodificación con los decoders de libavcodec y codificación con el encoder AAC nativo de FFmpeg (aac, LGPL). Sin libfdk_aac (nonfree) y sin ningún componente GPL.
  • Vive en libav-bridge (es libavcodec) y se declara como capability versionada del motor (r23): con -Dlibav=false no existe. La policy la pide por pista de audio (acción ortogonal por track, r05); engine=zig no la declara, así que una sesión que la necesite la resuelve la selección de §3 por capability, nunca por suposición.
  • El encode de vídeo queda fuera del alcance de este ADR (ver «Lo que este ADR NO decide»).

2. FFmpeg nunca como proceso

  • Prohibido invocar ffmpeg, ffprobe o cualquier binario de FFmpeg como proceso desde código de producto (apps/, packages/, native/), por spawn, exec, shell o sidecar. Esto sustituye la sanción de dec-0039.
  • El spawn existente (workers-svc ffprobe-client.ts + el cmd.workers.probe que lo alimenta) es legado a retirar: se reemplaza por un comando al daemon (probe vía MediaEngine) y se borra (DELETE > @deprecated, regla de repos orquestados por axon). Hasta que aterrice el reemplazo sigue funcionando, pero no se añaden call-sites nuevos. Un guard (grep/test) que falle ante spawn('ffmpeg'|'ffprobe') en código de producto acompaña el borrado.
  • Los tests tampoco usan el CLI: el oráculo de conformidad del motor Zig es el motor libav in-process (test diferencial, §4). ffprobe-CLI queda fuera del repo como herramienta. (Jellyfin, dentro de su contenedor de benchmark, lanza su propio ffmpeg: eso es el sistema de referencia, no código de Styx.)

3. Selección por config y policy — Bun decide, Zig ejecuta

  • Config del daemon: media.engine = zig | libav | auto (default de arranque) y media.engine.fallback = off | libav (sólo con auto).
  • Policy: la elección efectiva por sesión la decide el control plane (@styx/playback-policy, r01/r05) a partir de las capabilities de cada motor y la pasa en el comando de apertura de sesión; el daemon valida que el motor elegido declara la capability y ejecuta. Override por sesión permitido para benchmarks y diagnóstico.
  • auto = motor Zig si declara la capability para (contenedor, códec, target), si no libav. El fallback en caliente (Zig falla a mitad y se reintenta con libav) sólo existe si está configurado, y nunca es mudo: contador por motivo, atributo en la traza, y visible en la explicación del PlaybackPlan.
  • El motor usado es atributo obligatorio de telemetría (media.engine en spans/métricas OTel) y de cada corrida WC-JF12 (dec-0112 §4): toda corrida SEEK/DENSITY se hace con engine=zig y engine=libav, además de contra Jellyfin.

4. Conformidad diferencial

  • Suite diferencial in-process: mismo corpus (el de dec-0112, fijado por hash), ambos motores, comparación a nivel de facts, paquetes (pts/dts/flags/hash de payload) y fragmentos (hash de mdat, tablas trun/tfdt). Toda discrepancia se clasifica: bug del motor Zig, bug o laxitud de libav, o diferencia legítima (documentada). Un canario (oráculo desplazado) debe producir discrepancias, igual que en el spike, para probar que el harness discrimina.
  • Salida remuxada validada además por un tercer criterio independiente de ambos motores (conformidad CMAF/ISOBMFF por estructura) para no dar por buena una salida sólo porque los dos motores coinciden en el mismo error.

5. FFmpeg 8: build, versión y licencias

  • Versión: la última release estable 8.x en el momento del pin (FFmpeg 8.1 es la que embarca Jellyfin 12; se confirma al pinear). Fijada por tag y hash del tarball/commit; subir de versión es un cambio explícito con la suite diferencial en verde. No se usa el FFmpeg del sistema (el del contenedor es 6.1).
  • Build: FFmpeg compilado desde fuente con configuración mínima (sólo demuxers/muxers/parsers/ bsf necesarios, más los decoders AC3/EAC3/DTS y el encoder aac nativo que exige §1.2; --disable-programs --disable-network --disable-autodetect), y enlazado desde native/zig/build.zig detrás de una opción (-Dlibav=true|false). Con false el daemon se construye sin FFmpeg y engine=libav no está disponible (capability ausente, no crash). Cómo se integra la compilación (paso externo con prefijo, o paquete de build de Zig) lo decide el milestone de build; el lock es: versión fijada, reproducible, y el daemon siempre compila sin él.
  • Licencias (a vigilar):
    • Por defecto LGPL-2.1+, sin ningún componente GPL: --disable-gpl --disable-nonfree. Demux, remux, packaging y la transcodificación de audio a AAC con el encoder nativo (§1.2) no necesitan nada GPL. Tampoco entra código GPL por otra vía: nada de TransmuxCore/CTransmuxFFI en el árbol (§1.1).
    • Con LGPL se prefiere enlace dinámico (o, si es estático, se distribuyen los objetos para re-enlazar); la decisión va con el primer artefacto distribuible, no antes.
    • Componentes GPL (x264, x265, filtros GPL) sólo con flag explícito de build, nunca por defecto, y marcan el artefacto como GPL. nonfree (p. ej. fdk-aac) prohibido en artefactos distribuibles.
    • El build emite un manifiesto de licencia (flags de configure, versión, libs) que el daemon expone (p. ej. en Styx Doctor) para que el estado de licencia sea observable.
  • Memoria: libav asigna con av_malloc, fuera de los allocators Zig y de TrackingAllocator. Hueco honesto: el consumo de libav se contabiliza por RSS/cgroup y por contadores propios del bridge (paquetes y buffers vivos por sesión), y cuenta contra los presupuestos de memoria por sesión. Contextos libav por sesión, nunca compartidos entre hilos sin sincronización; av_log redirigido al logging del daemon.

Alternativas rechazadas

  • Seguir con spawn (dec-0039 v1). Rechazada por waxin: un proceso por operación es justo el coste que queremos quitarle a Jellyfin, y saca bytes y control del daemon.
  • Sólo motor Zig, sin libav. Rechazada: sin oráculo in-process ni fallback, y sin forma de separar «ventaja de arquitectura» de «ventaja de demux» en los benchmarks.
  • Sólo libav. Rechazada: contradice r16 (frontier, control e2e) y deja el hot path en código C ajeno; el mandato es el motor propio, con libav como vara de medir y fallback.
  • libav vía Node-API o bun:ffi desde servicios TS. Rechazada por r01 (bytes no pasan por JS), r07 y dec-0014.
  • Playlists/segmentación implementadas por cada motor. Rechazada: duplica lógica y ensucia la comparación; el packager es común.
  • libav abriendo rutas/URLs directamente. Rechazada: salta SeekableMediaSource, la cancelación por request, la caché por niveles y amplía superficie de ataque.

Consecuencias

  • dec-0039 queda enmendado: el spawn deja de ser v1 sancionada; la migración ya no es «prioritaria», es obligatoria y termina en borrado.
  • Nace un contrato nuevo (y el demux Zig) en media-core, se materializan los crates transmux (remux Zig + muxer fMP4-CMAF + packager común) y libav-bridge que dec-0029 / r25 §2 ya listaban (§1.1); el daemon gana un comando de probe (y más tarde de sesión de remux/packaging) que sustituye a cmd.workers.probe.
  • workers-svc pierde el executor de ffprobe; si se queda sin responsabilidad concreta, su futuro se decide aparte (no aquí).
  • El build nativo gana una dependencia opcional pesada (FFmpeg) con coste de CI y de disco; se mitiga con la opción -Dlibav=false y caché del build de FFmpeg.
  • La propuesta de milestones está en docs/track/byte-runtime/plans/media-engine-milestones-proposal.md (propuesta, no nodos).

Lo que este ADR NO decide

  • No crea nodos ni milestones en styx.model.yml; la propuesta es un plan a revisar en la pasada de gobernanza.
  • No lockea dec-0109 (placement de facts/parser/policy); sólo exige que ambos motores emitan la misma estructura de facts.
  • No decide el encode de vídeo (encoders HW, AV2, tonemapping, x264/x265): sólo demux, remux, packaging y la transcodificación de audio a AAC de §1.2. El encode de vídeo, cuando llegue, pasa por el mismo contrato, la misma regla «nunca proceso» y la regla de licencias de §5.
  • No decide el protocolo de entrega (HLS por HTTP/H3, MoQT): el packager produce fragmentos y playlists; cómo viajan lo deciden track/byte-runtime y los ADRs de transporte.
  • No fija el modelo de linking definitivo (dinámico vs estático) más allá de la regla LGPL de §5.

Back-refs

  • dec-0039 lleva banner de enmienda y amendedOrSupersededBy: [dec-0110].
  • r29 (D3, padre de dec-0039) no se toca: el matiz queda registrado vía dec-0039.
  • Relacionados: dec-0112 (gate WC-JF12; engine es dimensión de las corridas), dec-0114 (scanner: la huella de contenido usa el probe de este motor).