dec-0109

Vista generada de dec-0109: Placement mediakit: facts en `@styx/domain`, parser en `native/zig/media-core`, policy en `@styx/playback-policy`

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0109-mediakit-placement-facts-parser-policy.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0109-mediakit-placement-facts-parser-policy.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-0109-mediakit-placement-facts-parser-policy.md

Por qué importa (del frontmatter del ADR):

Resuelve d07 (placement del harvest mediakit, styx.model.yml, reabsorbido en track/domain-media) y fija dónde viven los facts de contenedor, el parser que los extrae y la policy que los consume. Sin este ADR, el primer executor de domain-media#02 o del demux de F2 elegiría sitio por comodidad: un crate media-probe aislado que el demuxer no reutiliza, o una policy de ranking de códecs metida en el modelo de dominio.

Nodos del roadmap que lo citan en refs: track/media-engine

Páginas de la documentación que lo citan: Motores Zig y libav (especificado)

Texto del ADR

Leído de docs/decisions/dec-0109-mediakit-placement-facts-parser-policy.md, el fichero canónico.

dec-0109 — Placement mediakit: facts en @styx/domain, parser en native/zig/media-core, policy en @styx/playback-policy

  • Fecha: 2026-09-28
  • Estado: LOCKED (2026-10-01). Opción C. waxin, vía AskUserQuestion: "Lockear dec-0107 y dec-0109 ya", aceptando la propuesta. Resuelve d07. Ver Lock (2026-10-01, waxin). Hasta el lock estuvo PROPOSED (decisión #5 del plan de tandas) y sólo avanzó lo reversible: el spike spikes/zig-container-facts/ y su evidencia.
  • Propone: builder-dataplane (lane zig-demux-spike, rama w2/zig-demux-spike).
  • Nodo: spike/sota-exp (evidencia) → track/domain-media (d07, DM3 mediakit-harvest).
  • Cita: r29 (harvest mediakit: D2 separar facts de policy, D3 interface MediaProbe pensada para el swap, D4 placement diferido a d07), dec-0039 (split de r29 D3: subprocess v1, libav v2, mismo contrato MediaProbe), r01 (Bun decide, Zig ejecuta), r04/r20 §3.6 (SeekableMediaSource), r07 (FFI order), r16 (frontier), r18/r20 (contract-first Arktype), r23 (codec-agnostic, CanonicalCodecPolicy), r28 (anti-especulativo: consumidor real).
  • Evidencia: spikes/zig-container-facts/EVIDENCE.md.

Contexto

  1. El hueco nº 1 frente a Jellyfin 12 es que no hay demux. native/zig no tiene parser de moov, EBML ni Cues, ni remux ni packaging. Todos los facts de contenedor llegan hoy por un único camino: apps/catalog-svc/src/service/ffprobe/ffprobe-client.ts hace un cmd.workers.probe por NATS y workers-svc lanza ffprobe. Es el mismo coste por fichero que paga Jellyfin al escanear, y además no da el índice de keyframes sin leer el fichero entero.
  2. d07 lleva abierto desde r29 D4: "crate native/zig/media-probe dedicado vs distribuido en media-core/transmux vs módulos sueltos", diferido "al ejecutar F-DOMAIN-MEDIA/F2". domain-media#02 (el harvest) está bloqueado por él.
  3. El oráculo del harvest no está en el repo. docs/references/mediakit no existe (el plan de tandas lo verificó con find vacío). El modelo StreamInfo/ContainerInfo que r29 D2 quería cosechar de core/stream.zig hay que reconstruirlo. El spike lo ha hecho clean-room y lo ha medido contra ffprobe.
  4. El modelo de dominio ya existe, pero es pobre. packages/domain/src/models/MediaIndex.ts (IMediaIndex/IMediaStream) sólo tiene códec, dimensiones, fps, canales, sample rate, idioma y default. No tiene perfil/nivel, bit depth/chroma, color/HDR, codec private, forced, título ni keyframes con offset. Justo lo que domain-media#01 pide transportar al planner.

Lo que midió el spike (resumen de EVIDENCE.md)

  • Exactitud: en 733 comparaciones campo a campo contra ffprobe 6.1.1, sobre 25 ficheros (16 sintéticos, 8 de la suite Matroska de IETF CELLAR y el fMP4 bipbop de Apple), hay 715 iguales y 0 mismatches atribuibles al parser. Las 2 distintas y las 2 que sólo tiene ffprobe son fallos del propio ffprobe con sidx (duración de audio en global_sidx y DASH, nb_frames en global_sidx). extradata_md5 coincide 42/42: el codec private es byte a byte el de libav. El canario (oráculo desplazado) da 177 mismatches, así que el harness sí discrimina.
  • Bytes: mediana de 66 KB (0,89 % del fichero), 0,05 % en un MKV de 122 MB. Sobre todo el corpus son 3,2 MB, frente a 14,6 MB de ffprobe sin índice y 662 MB con índice de keyframes.
  • Latencia (binario ReleaseSafe, máquina compartida; pasada de la ronda 4): 118 µs dentro del proceso y 2,1 ms con spawn, frente a 68 ms de ffprobe (99 µs / 1,9 ms / 66 ms en la pasada de la ronda 3). En la ronda 2, en las mismas condiciones, ReleaseSafe dio 100 µs y ReleaseFast 97 µs.
  • Robustez: testing.allocator en todos los tests y barrido OOM en cada allocación. La primera ronda anunció "4,8 M probes de fuzz sin panics", pero el fuzz sólo mutaba bytes de los fixtures y no cubría la aritmética sobre valores declarados. La revisión encontró dos fallos que ese fuzz no vio: la suma de segment_duration de elst desbordaba u64 (panic en Debug y ReleaseSafe, UB en el ReleaseFast con el que se midió la latencia) y un trun sin campos por sample llevaba el CLI a 535 MB de RSS con un fichero de 519 B (1,5 GB con 4 pistas). Los dos están corregidos (aritmética comprobada y presupuesto global por probe), con tests en rojo antes del fix y en verde después, y el fuzz tiene ahora un generador estructural de valores extremos que encuentra el desborde de elst en el código anterior. La segunda revisión (ronda 3) dejó un test con su mutante en rojo por cada reserva del presupuesto, corrigió la cota de memoria (un fMP4 con samples en el moov y moof concatenaba dos índices: 50,3 MB de pico con 659 B; ahora un solo array por pista, 16,8 MB, y cota de 2,5 × índice declarada con su derivación) y rehízo el lado Matroska del fuzz, que no llegaba a los topes. La tercera (ronda 4) encontró que un sidx descartado no devolvía su reserva de keyframes (falso TooLarge en un DASH en el tope exacto) ni liberaba la capacidad que había hecho crecer; los dos están corregidos, y cada guarda de la cola del sidx tiene ya test y mutante en rojo. La cuarta (ronda 5) hizo que los tests de memoria no dependieran de que el allocator de test creciera en sitio (modo de crecimiento fijado en el allocator contador y cota elegida por los crecimientos que de verdad se movieron); 23 mutantes, 0 supervivientes.
  • sidx: la primera ronda sólo validó global_sidx (un sidx delante del primer moof). Con un sidx por segmento (DASH) el parser tomaba el primero como índice entero: 1 keyframe y 0,5 s en un fichero de 3 s. Corregido: un sidx sólo es índice si lo referenciado llega al final del fichero o si hasta EOF sólo quedan cajas sin media, recorridas una a una; si no, se recorren los moof. DASH está ahora en el corpus y coincide con ffprobe.
  • Separación: el parser sólo depende de std y consume un ByteSource (size, read(off,len)). El adapter a LocalFileSource.readAt ocupa 33 líneas.

Opciones

Facts (tipo)ParserPolicyVeredicto
A. Crate dedicado native/zig/media-probeen el crate (Zig) + copia TScrate propioen el crate (codecQualityRank junto al modelo)✗ Mezcla facts y policy, contra r29 D2. El demuxer y el packager de F2 necesitan el mismo índice (Cues/stss/moof) y acabarían con un segundo parser o importando un crate "de probe".
B. Todo en TS (parser en @styx/domain o en catalog-svc)@styx/domainTS en el control plane@styx/playback-policy✗ Los bytes de cabecera cruzarían a JS, contra r01 ("los bytes de vídeo no atraviesan JS"). El índice de keyframes lo necesita el data plane (seek, segmentación), que tendría que pedírselo a Bun.
C. Distribuido, separando facts de policy (recomendada)@styx/domain (contract-first Arktype)native/zig/media-core/container/@styx/playback-policy (CanonicalCodecPolicy)✓ Ver abajo.

Qué se propone (opción C)

  1. Parser en native/zig/media-core/container/, como módulo de media-core que sólo depende de std y lee a través de un ByteSource mínimo que cualquier SeekableMediaSource satisface (LocalFileSource ya lo hace; HTTP/S3 lo harán). No es un crate aparte porque el índice que extrae es el mismo que usarán el demuxer, el remux y el packager fMP4/HLS de F2. Tampoco entra en media-daemon porque es lógica de medios, no del runtime del daemon.

  2. Facts como tipo en @styx/domain: IMediaIndex/IMediaStream evolucionan (breaking, romper limpio) con los campos que el spike demuestra extraíbles y el planner consume: profile, level, bitDepth, chroma/pixFmt, colour (code points H.273: primaries, transfer, matrix, rango), hdr (sdr|hdr10|hlg|dolby_vision), sar, codecTag, codecPrivate (hash; los bytes sólo en el data plane), forced, title, indexSource (stss|all_sync|fragments|sidx|cues|none) y keyframes {ptsUs, offset}. El schema Arktype en @styx/api-contracts es la fuente (r18/r20). El daemon los serializa por su socket (r07). Por NATS viajan facts, nunca bytes de medios.

  3. Policy en @styx/playback-policy: codecQualityRank → CanonicalCodecPolicy versionada (r23), orden de fallback y selección de pista. Consumen facts y no se definen junto a ellos. El parser no contiene ninguna decisión: el spike lo cumple (src/facts.zig).

  4. Dos motores intercambiables detrás del interface MediaProbe (r29 D3 / dec-0039): el parser Zig propio y libav dentro del proceso por interop C (el libav-bridge de r25 §2), seleccionables con engine=zig|libav. Los dos viven en el data plane, leen del mismo ByteSource (libav a través de un AVIOContext propio, así que pasa por el mismo readAt, el mismo tope de rango y el mismo path guard) y devuelven el mismo tipo de facts. Se comparan siempre sobre el mismo corpus y el mismo harness. FFmpeg nunca se ejecuta como proceso: ni ffprobe ni cmd.workers.probe son camino de producción ni fallback. El camino actual de catalog-svc (apps/catalog-svc/src/service/ffprobe/ffprobe-client.ts → cmd.workers.probe → spawn de ffprobe) es lo que se reemplaza, no algo que se conserva.

    Relación con dec-0039. dec-0039 sancionaba el spawn de ffprobe/ffmpeg como v1 y libav como v2 prioritaria, con el contrato MediaProbe diseñado para el swap. La decisión de waxin del 2026-09-28 cambia dos cosas: FFmpeg sólo como librería por interop C, nunca como proceso (se cae la v1 subprocess), y un motor Zig propio al mismo nivel que libav. Esa decisión se registra en un ADR de motor que enmienda dec-0039 (número por asignar en la integración). Lo que se conserva de dec-0039 es el contrato MediaProbe como punto de swap. Este ADR no decide el motor: fija dónde viven los facts, el parser Zig y la policy, y que ambos motores cumplen el mismo contrato. El orden entre motores, qué hacer cuando el seleccionado no lee un fichero y la forma del bridge de libav quedan para el ADR de motor.

Consecuencias

  • d07 se resuelve y domain-media#02 se desbloquea. El "harvest" deja de depender de docs/references/mediakit: la validación pasa a ser un corpus reproducible (gen-corpus.sh/fetch-realworld.sh) y el harness compare.ts con canario.
  • ffprobe es el oráculo de medición del spike, no un fallback. Sirvió para comparar campo a campo. No es camino de producción en ninguna forma. La siguiente evidencia a producir es la comparación contra libav por FFI en el mismo harness: exactitud (pts/keyframes idénticos, facts campo a campo), bytes leídos, latencia, CPU, RSS por probe, fuzz y fugas, y cobertura de formatos, con engine=zig|libav como dimensión.
  • Consumidor real en cuanto se promueva (r28): el scan de catálogo, que hoy paga ~60-75 ms y un proceso por fichero, y un pase completo si quiere keyframes.
  • Trabajo que el spike deja identificado para la promoción (no se hace aquí):
    • Índice cuando no está en cabeceras (MKV sin Cues, -live): pase de Clusters. Es el primer trozo del demuxer.
    • fMP4 sin sidx/mfra: el índice por moof debe ser perezoso (300 lecturas en bipbop es aceptable en local, no sobre HTTP/S3).
    • sidx escrito por ffmpeg declara earliest_presentation_time = tiempo de decodificación con SAP_type=0. Hay que corregirlo con el primer trun del subsegmento al hacer seek.
    • sidx validado sólo con el muxer de ffmpeg (global_sidx y DASH por segmento). Sin muestra de sidx jerárquico ni de empaquetadores de terceros (Shaka Packager, Bento4, MP4Box). Si un empaquetador escribe un sidx que no llega a la cola del fichero, el parser recorre los moof: el índice sale bien, pero con una lectura por fragmento.
    • Los parsers de entrada no confiable se compilan en ReleaseSafe: en ReleaseFast un desborde aritmético que se escape a los tests es UB, no un panic.
    • Metadatos HDR del bitstream (SEI/OBU: mastering display, MaxCLL) y Dolby Vision: sin muestra en el corpus, falta validarlos contra un oráculo.
  • El código de spikes/zig-container-facts/ no se importa. Se reescribe al promoverlo y el spike se borra tras decidir (spikes/README.md).

Qué NO decide

  • La forma del demuxer/remux/packager de F2 (sólo que comparte módulo e índice con el parser).
  • El motor: orden entre zig y libav, selección por sesión o por despliegue, qué hacer cuando el motor elegido no lee un fichero y la forma del bridge de libav. Es el ADR de motor que enmienda dec-0039.
  • Nombres finales de campos en @styx/api-contracts: eso lo fija domain-media#02 al tocar el contrato.

Lock (2026-10-01, waxin)

Decisión de waxin vía AskUserQuestion (2026-10-01): "Lockear dec-0107 y dec-0109 ya", con la propuesta del ADR. Se lockea la opción C:

  • Facts: el tipo vive en @styx/domain (IMediaIndex/IMediaStream evolucionan, rompiendo limpio).
    • El schema contract-first es TypeBox 1.x en @styx/api-contracts, no Arktype, porque dec-0116 (LOCKED) sustituyó Arktype después de escribirse este ADR.
    • Por NATS viajan facts, nunca bytes de medios.
  • Parser: vive en native/zig/media-core/container/, depende sólo de std y lee por un ByteSource que satisface cualquier SeekableMediaSource. Es el mismo módulo y el mismo índice que usan el demuxer, el remux y el packager.
  • Policy: vive en @styx/playback-policy (CanonicalCodecPolicy versionada, r23), que consume facts y no se define junto a ellos.
  • d07 queda resuelto por este ADR y domain-media#02 deja de estar bloqueado por él.

Lo que el lock deja fuera es lo que dice "Qué NO decide":

  1. La forma del demuxer, el remux y el packager de F2. Este lock sólo fija que comparten módulo e índice con el parser.
  2. El motor: el orden entre zig y libav, la selección por sesión o por despliegue, qué hacer cuando el motor elegido no lee un fichero y la forma del bridge de libav. El "ADR de motor que enmienda dec-0039" del texto es dec-0110 (LOCKED, 2026-09-28). Lo que dec-0110 no fije de esa lista sigue abierto allí, no aquí.
  3. Los nombres finales de los campos en @styx/api-contracts. Los fija domain-media#02 al tocar el contrato.

Estado del árbol al lockear (no cambia la decisión)

  • El placement ya es el del árbol. El parser y el demux Zig viven en native/zig/media-core/container/ (facts.zig, isobmff.zig, matroska.zig, probe.zig, mp4_demux.zig, mkv_demux.zig…), detrás del contrato MediaEngine de track/media-engine (ME0 y ME1 en pass). Este lock ratifica ese sitio y no mueve código.
  • El camino de ffprobe como proceso ya no existe. catalog-svc pide los facts al daemon por qry.media.probe (session-ipc v2, {rootId, relPath}), cmd.workers.probe está retirado y el guard check:no-media-spawn vigila que no vuelva.
  • Falta el lado TS de los facts. packages/domain/src/models/MediaIndex.ts sigue sin profile, bitDepth, color/HDR ni keyframes. Es el criterio 5 de DM1 y el trabajo de domain-media#02.

Follow-ups que el lock no ejecuta

  • Borrar spikes/zig-container-facts/ (spikes/README.md: "el código se elimina o reescribe después de decidir"). Antes hay que comprobar que su evidencia (EVIDENCE.md, corpus y harness) está promovida o citada desde track/media-engine.
  • Los pendientes de promoción de "Consecuencias" (índice perezoso por moof, sidx de terceros, metadatos HDR del bitstream) siguen en track/media-engine.

Qué desbloquea

  • ME2 (zig-demux-differential, track/media-engine) tenía como única condición pendiente para pass este lock (manifest de media-engine, entrada ME2). Ya no la tiene. Sigue en partial hasta que un verificador distinto del autor lo pase por el manifest: el lock no cierra el gate.
  • DM3 (mediakit-harvest, track/domain-media) sigue open. El lock cumple su criterio 4 (placement d07 decidido, docs/track/domain-media/plans/gate-proposal.md). Los criterios 1 a 3 siguen sin cumplir: no hay TrackSelector ni CanonicalCodecPolicy versionada, y no hay golden de mediakit que portar, porque el oráculo no está en ningún path trackeado. Es el trabajo de domain-media#02.