Vista generada de dec-0109: Placement mediakit: facts en `@styx/domain`, parser en `native/zig/media-core`, policy en `@styx/playback-policy`
docs/decisions/dec-0109-mediakit-placement-facts-parser-policy.mdVista generada desde
docs/decisions/dec-0109-mediakit-placement-facts-parser-policy.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | LOCKED |
| Fecha | 2026-09-28 |
| Fichero | docs/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 cratemedia-probeaislado 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)
Leído de docs/decisions/dec-0109-mediakit-placement-facts-parser-policy.md, el fichero canónico.
@styx/domain, parser en native/zig/media-core, policy en @styx/playback-policyAskUserQuestion: "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.zig-demux-spike, rama w2/zig-demux-spike).spike/sota-exp (evidencia) → track/domain-media (d07, DM3 mediakit-harvest).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).spikes/zig-container-facts/EVIDENCE.md.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.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.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.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.EVIDENCE.md)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.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.std y consume un ByteSource (size,
read(off,len)). El adapter a LocalFileSource.readAt ocupa 33 líneas.| Facts (tipo) | Parser | Policy | Veredicto | |
|---|---|---|---|---|
A. Crate dedicado native/zig/media-probe | en el crate (Zig) + copia TS | crate propio | en 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/domain | TS 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. |
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.
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.
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).
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.
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.engine=zig|libav como dimensión.-live): pase de Clusters. Es el primer
trozo del demuxer.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.spikes/zig-container-facts/ no se importa. Se reescribe al promoverlo y el
spike se borra tras decidir (spikes/README.md).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.@styx/api-contracts: eso lo fija domain-media#02 al tocar el
contrato.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:
@styx/domain (IMediaIndex/IMediaStream evolucionan, rompiendo
limpio).
@styx/api-contracts, no Arktype, porque
dec-0116 (LOCKED) sustituyó Arktype después de escribirse este ADR.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.@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":
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í.@styx/api-contracts. Los fija domain-media#02 al tocar
el contrato.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.qry.media.probe (session-ipc v2, {rootId, relPath}), cmd.workers.probe está retirado y
el guard check:no-media-spawn vigila que no vuelva.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.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.moof, sidx de terceros,
metadatos HDR del bitstream) siguen en track/media-engine.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.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.