ADR-060: Audio, video y correo en el catálogo de formatos; subida audiovisual por flujo¶
Estado: Aceptado
Fecha: 2026-10-30
Autores: Giampiero (mantenedor principal); salda la deuda Q2 de ADR-057 §1 («audio y video, que storage no admite en ninguna lista: limitación declarada») y el hueco de PRONOM del .eml
Relacionados: ADR-057 (catálogo cerrado), ADR-058 (gate que casa por MIME), ADR-027 (almacén WORM), ADR-042 (AIP versionado)
Contexto¶
Las audiencias grabadas, la atención telefónica, las reuniones virtuales y las notas de voz son documentos de archivo. El Ac. AGN 001/2024 los trata en tres sitios que no hay que mezclar: el art. 4.1.4 manda registrar en la TRD el formato electrónico de cada tipo documental; el art. 7.1.2 par. dice que los documentos electrónicos «se dispondrán» en formatos abiertos, libres y accesibles (no dice «conservar»); y el art. 9.2.2 manda almacenar por formato los archivos sonoros y audiovisuales, con referencia cruzada al inventario del expediente. Sus fichas descriptivas son el Anexo 9 (registros sonoros: «Formato (v.g. … WAV)», «Duración», velocidad, calidad del sonido) y el Anexo 10 (registros audiovisuales: formato, duración, color). La norma no fija una lista de formatos. El catálogo de 18 códigos no los tenía porque storage no admitía ningún MIME de audio o video, y admitirlos no era un cambio de lista:
upload()leía el fichero entero (await file.read()) y lo duplicaba en memoria; con 2 GiB no cabe enmemory: 1G.- El tope era de 100 MiB (≈ 7 min de video H.264 a 2 Mbps); gateway, nginx y SvelteKit tenían el suyo.
- El AIP, el reempaquetado, la réplica y la fixity relían objetos enteros en memoria: admitir video sin streaming habría prometido una preservación que revienta el contenedor.
- La heurística del PDF («%PDF-» con basura binaria delante, en el primer KiB) confundía un MP3 con «%PDF-» en su ID3.
message/rfc822(el.emlde E19) estaba en el catálogo (fmt/950) pero no en la tabla PRONOM de storage: el AIP de un expediente con correo salía sinformatRegistrypara ese objeto.
No se encontró una «Guía de formatos de preservación» del AGN que nombre WAV, FLAC, MKV o FFV1 (ni en documentos/ ni en
la búsqueda web; lo más cercano es la guía de digitalización de la Biblioteca Nacional, 2022). La elección se apoya en el
art. 7.1.2 par., en los requisitos propios («Audio: WAV/FLAC. Video: MKV con códec libre») y en PRONOM; este ADR no cita una
guía que no existe.
Decisión¶
- Entran 8 códigos (catálogo de 18 → 26), al final para no reordenar los arreglos ya normalizados. «Apto» significa
abierto y sin pérdida; aptos:
wav,flac,mkv. Como el códec no se identifica,wav(¿PCM?) ymkv(¿FFV1 o H.264?) son aptos condicionales (condicionalen el catálogo) y el aviso al Comité dice «apto según contenedor, códec sin verificar» (formato_apto_condicional);flaces sin pérdida por construcción. Cada fila lleva unafamilia(texto, hoja_de_calculo, presentacion, imagen, audio, video, correo_y_datos) que el backend entrega y la UI usa para agrupar; es informativa, no entra en ningún CHECK.
| Código | Ext. | MIME canónico | PUID (verificado en pronom.nationalarchives.gov.uk, 2026-10-30) | Apto | Nota |
|---|---|---|---|---|---|
wav |
wav | audio/wav |
fmt/6 (supertipo; DROID/Siegfried darían fmt/141/142/143 o BWF fmt/1/2/527) |
sí | PCM sin pérdida. BWF no es código propio: mismo MIME y extensión |
flac |
flac | audio/flac |
fmt/279 |
sí | sin pérdida, abierto |
mp3 |
mp3 | audio/mpeg |
fmt/134 |
no | lo más común en grabadoras y centrales; con pérdida |
opus |
opus | audio/opus |
fmt/946 |
no | notas de voz de WhatsApp y MediaRecorder; con pérdida |
mp4 |
mp4 | video/mp4 |
fmt/199 (cubre .mp4, .m4v y .m4a) |
no | cámaras, Teams/Zoom/Meet, Android, notas de voz de iPhone |
mov |
mov | video/quicktime |
x-fmt/384 |
no | video de iPhone; propietario |
mkv |
mkv | video/x-matroska |
fmt/569 (cubre .mka) |
sí | contenedor abierto que fijan los requisitos propios; habla del contenedor, no del códec |
webm |
webm | video/webm |
fmt/573 |
no | lo que graba el navegador; abierto pero con pérdida |
Fuera a propósito: Ogg Vorbis (audio/ogg, fmt/203: casi nadie lo produce y compartiría .ogg con Opus), AVI
(fmt/5), WMA/WMV/ASF (propietarios), AAC crudo/ADTS, AMR/3GP y RF64 (WAV de más de 4 GiB, por encima del tope). El
olfateo los reconoce para dar un 415 que nombra lo que es.
2. Un MIME canónico por código (salvo pdf/pdfa) y un mapa de alias en orpycamcp_common (audio/x-wav →
audio/wav, audio/mp4 → video/mp4, …) con canonico(), aplicado antes del primer chequeo de formato y al escribir
el plan de preservación. m4a no es un código: el contenedor ISO BMFF de un .m4a y de un .mp4 es el mismo
(fmt/199) y dos códigos con el mismo MIME abrirían un oráculo en el gate ADR-058 (_formato_de toma el primero; el
declarado decidiría cuál pasa). Un guardián exige MIME único en todo el catálogo salvo pdf/pdfa. audio/ogg y
application/ogg no son alias (cubren Opus, admitido, y Vorbis, no): son «declarados a resolver por firma».
3. Olfateo por firma (storage-service/app/core/contenido.py, sobre el fichero y su cabecera de 64 KiB) con las firmas
de PRONOM, comprobadas antes de la heurística del PDF con basura delante:
| Firma | Resultado |
|---|---|
RIFF…WAVE |
audio/wav (RIFF…AVI → video/x-msvideo, no admitido; RF64 → ninguno) |
fLaC · ID3 · FFFB/FFFA/FFF3/FFF2/FFE3/FFE2 |
audio/flac · audio/mpeg (ADTS FFF1/FFF9 → audio/aac, no admitido) |
OggS + OpusHead en 64 B · OggS + \x01vorbis |
audio/opus · audio/ogg (Vorbis, no admitido) |
1A45DFA3 + DocType matroska / webm |
video/x-matroska / video/webm |
ftyp: qt · M4A /M4B /M4V /isom/iso2-9/mp41/mp42/avc1/dash/MSNV/f4v · 3gp* |
video/quicktime · video/mp4 · video/3gpp (no admitido) |
caja moov/mdat/wide/free/skip/pnot sin ftyp |
video/x-isobmff |
Lo que la firma no distingue (va literal): WAV de BWF (irrelevante, mismo código); audio-solo de video en ISO BMFF
(por eso un solo código mp4); MKV con video de .mka (mismo DocType, un código); el códec (FFV1 vs H.264 dentro de
MKV, H.264 vs HEVC dentro de MP4: exige recorrer el contenedor con ffprobe/MediaInfo); y un ISO BMFF sin ftyp
(QuickTime anterior a 2001 o MP4 muy viejo), único caso en que el declarado decide, y solo entre video/quicktime y
video/mp4 (ambos no aptos). Un MP3 sin ID3 comprueba una cabecera de trama: un binario que empiece por FFFB se
registra como audio/mpeg (el impacto es un registro sin reproducción, jamás saltarse el gate de otro tipo de la TRD).
4. Endpoint de subida aparte: POST /api/v1/storage/upload/audiovisual (tope 2 GiB, solo los 8 MIME verificados por
contenido). Alternativa A (un solo /upload con tope por clase) descartada: obliga a subir el tope de gateway, nginx y
SvelteKit para todo el prefijo; un PDF de 2 GiB se escribiría entero a disco antes del 413 (el problema que el gateway
ya documenta); y Starlette vuelca el cuerpo antes del handler, así que no hay dónde poner un semáforo. La puerta propia
lee el cuerpo ella misma (python_multipart.MultipartParser, 0.0.32): cupo de concurrencia antes de leer (lleno → 503
upload_av_ocupado + Retry-After: 30), Content-Length obligatorio (411), SHA-256 y escritura a un temporal del spool
por trozos, olfateo al reunir 64 KiB (no AV → 415 sin escribir el resto), corte con 413 al cruzar el tope. RBAC
PERM_RADI nivel 1, el mismo acto que /upload (subir el anexo de una radicación) con otro tope. /upload sigue en 100
MiB y también admite audio/video hasta ese tope (la nota de voz de 2 MB no necesita la puerta grande).
5. Ninguna subida materializa el fichero: los cuatro endpoints existentes pasan el SpooledTemporaryFile al servicio
(upload(fuente, …)), el hash va por trozos de 1 MiB en un hilo, y put_object sube desde el fichero con part_size de
64 MiB (multipart S3) mediante un lector asíncrono (miniopy-async 1.23.5 hace await si read devuelve una corrutina;
verificado en su código). Solo un PDF con el gate PDF/A encendido se lee a memoria para veraPDF (≤ 100 MiB).
6. La cadena de preservación pasa a flujo: verify_integrity y la réplica hashean por trozos
(app/core/fixity.sha256_de_objeto); el AIP baja cada documento a un temporal del spool recalculando su fixity por
trozos, el ZIP se escribe a otro temporal con zf.open(..., "w", force_zip64=True) (escribir_bag; build_bag queda
como envoltorio en memoria para Bags pequeños) y se sube desde fichero; ZIP_STORED para audio/*, video/* y
image/jpeg. El reempaquetado extrae el payload del AIP anterior a temporales verificando cada entrada. Tope
AIP_MAX_BYTES (16 GiB), comparado con la suma de tamaños antes de bajar un byte: superado, evento PREMIS fallo
(aip_demasiado_grande, total y tope) y 409 aip_demasiado_grande hacia archive; nunca un AIP parcial. Archive lo
registra como pendiente con ese motivo (no como fixity_mismatch: antes cualquier 409 desconocido se leía así).
7. PDF/A se dispara por MIME verificado, no por «%PDF-» en el primer KiB (ingesta y AIP): audio y video nunca llegan a
veraPDF.
8. La migración de formato excluye audio y video antes de descargar: no hay migrador. El plan puede declararlos en
riesgo (alimenta el aviso formato_en_riesgo_segun_plan del Comité), pero el barrido los cuenta como sin_migrador y
POST /preservacion/migrar-formato de un fichero AV responde 422 formato_sin_migrador sin descargarlo.
PlanCreate.formatos_origen y FORMAT_MIGRATION_MIME_TYPES se guardan canónicos.
9. message/rfc822 → fmt/950 en la tabla PRONOM de storage; el test de gemelos pierde la excepción de eml.
10. Migración archive 085 (td_formatos_validos con los 26 códigos): solo ensancha, ninguna fila existente se invalida;
la 082 no se edita y un guardián comprueba que conserva su literal de 18. El test de gemelos lee la última
migración que define la función. Sin backfill; tenant_demo no se recrea.
11. Topes coordinados: storage 2147483648 < gateway = SvelteKit = 2164260864 (2 GiB + 16 MiB de multipart) y nginx 2064M,
solo en dos location exactas (/radicar/api/storage-av, /api/v1/storage/upload/audiovisual); el global sigue en
512M y el prefijo /api/v1/storage/ del gateway en 110 MiB. BODY_SIZE_LIMIT de adapter-node es global:
hooks.server.js restituye 512 MiB a toda ruta que no sea la audiovisual (413; 411 si llega troceado sin
Content-Length), o cualquier request.formData() quedaría abierto a 2 GiB en la memoria de Node. La ruta SSR
reenvía request.body en crudo (duplex: "half") con su propio signal (el envoltorio de 30 s de fetchTimeout.js
cortaría una subida cuyas cabeceras de respuesta llegan después de los gigas). Timeout de las dos rutas de subida: 300 s
en el gateway, 600 s en nginx. Un guardián en shared/tests ata los cuatro números.
12. Spool: volumen nombrado storage-spool en /var/spool/orpycamcp-upload (dev y producción). Si no existe o no es
escribible el arranque de storage falla: un spool en la capa del contenedor llena /var del anfitrión en silencio.
Disco acotado a réplicas × MAX_CONCURRENT_AV_UPLOADS (2) × 2 GiB, más los temporales del AIP.
Gate y avisos (sin cambio de lógica)¶
- Gate ADR-058: casa por MIME canónico. Una Salida/Interna en
video/mp4de un tipo que declaramp4esconforme; de un tipo que declara solopdfa,no_conformemotivoformato; con tipomkvy anexovideo/webm,no_conforme(un código por contenedor). - Avisos ADR-057:
formato_no_apto_conservacionsalta para una serie CT/S cuyo tipo declare solomp3/mp4/mov/webm/opus(deseado: una audiencia CT grabada en MP4);formato_en_riesgo_segun_plancasa con el MIME canónico porque el plan lo guarda canónico. - UI:
SoporteFormatoagrupa por familia;/radicarenruta por tipo MIME o extensión, avisa del tamaño antes de subir (topes en variables privadasUPLOAD_MAX_BYTES/UPLOAD_AV_MAX_BYTES, nuncaPUBLIC_), pinta el progreso conXMLHttpRequesty sube los audio y video uno a uno. - MCP: solo descripciones (
listar_formatos_documentales,formatos_electronicos); no hay tool de subida (los bytes no pasan por el asistente), como/upload; el censo no gana huecos. - Ventanilla pública: sin cambio. La lista de anexos que acepta document-service para un anónimo (PDF, JPG, PNG, TIFF)
es corta a propósito (
anexos_publicos.py); storage admite audio y video también por/internal/anexo-externo(10 MiB), pero document-service no los deja llegar. Ensanchar la superficie anónima es otra decisión.
Consecuencias¶
- Una entidad puede declarar el audio y el video de sus audiencias en la TRD, radicarlos hasta 2 GiB y preservarlos en un AIP que no revienta el contenedor.
- El almacén WORM recibe objetos multipart: la retención COMPLIANCE sobre un objeto multipart se prueba contra RustFS
(
tests/integration/test_worm_multipart_real_s3.py); un almacén que acepta la retención y no la aplica es el modo de fallo queRetencionNoVerificadaErrorya documenta, y si falla la épica no cierra (ADR-027). - Las exportaciones INT-01 y las importaciones INT-05 conservan su tope de 512 MiB: un expediente con video por encima de él da el 413 explícito que ya existía.
Deuda declarada¶
- Códec no identificado (FFV1 vs H.264 en MKV, HEVC en MP4/MOV):
apto_preservaciondemkvhabla del contenedor. La identificación real (ffprobe, MediaInfo o Siegfried en storage) es roadmap, con el mismo estatus que DROID para el resto. Los identificadores usados en las pruebas son ffprobe (independiente del servicio) y las firmas de PRONOM; Siegfried no estaba disponible en el entorno de la épica y los PUID de la tabla se verificaron contra la página de PRONOM, no contra su salida. - Duración y metadatos técnicos (Anexos 9 y 10 del Ac. 001/2024: duración, velocidad, color y calidad del sonido): no se
extraen. Se resolverán junto con la descripción multinivel (INT-06/EAD,
docs/es/api.md§OAI/EAD), que es donde esos campos viven en el paquete de intercambio. - Reproducción con salto: la descarga mediada no sirve
Range; se descarga o se reproduce sin saltar. - Transcripción (whisper-service, perfil
assistant, apagado): fuera. Troceo de horas de audio, cola y residencia de datos de una audiencia reservada (ADR-006/E21) merecen una épica propia. - MP3 sin ID3 con una sola trama comprobada; ISO BMFF sin
ftypdecidido por el declarado entremovymp4. - Sin migrador AV: un formato AV en riesgo se avisa, no se migra.
- La fixity de
format_migration_servicey depreservation_worm_service(índice, actas, instrumentos de control) sigue leyendo el objeto entero: son artefactos acotados (≤ 100 MiB) y la migración ya excluye AV antes de descargar. El guardiántest_ningun_servicio_materializa_un_objeto_entero…lista cada lectura entera que queda, con su motivo. - Observación adyacente: Ghostscript tampoco convierte
application/msword, que es justo el ejemplo del comentario deconfig.py; ese caso termina enfallidouna vez por fichero (no en bucle). No se corrige aquí. - Partir el AIP en varios volúmenes para expedientes de más de 16 GiB (C2): hoy se señala, no se resuelve.
- La conexión de base de datos de la petición se mantiene tomada mientras dura la subida AV (máximo
MAX_CONCURRENT_AV_UPLOADSpor réplica); reemplazar la dependencia por una adquisición tardía es una mejora, no un defecto de corrección.
Correcciones de la auditoría (ronda única)¶
- S1 slowloris: plazo total de la subida AV (
120 s + Content-Length / 256 KiB/s) y silencio máximo entre trozos de 30 s →408 upload_av_lento(limpia el temporal y el cupo); subcupo de 1 subida AV por (tenant, usuario) →429 upload_av_usuario_ocupado; en nginx,client_body_timeout 30sylimit_connpor IP en las doslocationAV. - S2 olfateo con disfraz: la puerta AV rechaza un «%PDF-» en el primer KiB y los comienzos
MZ,\x7fELFy#!; un ISO BMFF sinftypexige 2-3 cajas encadenadas válidas (una caja cuya longitud se sale de la cabecera no se puede encadenar y se rechaza); unID3exige versión 2-4, tamaño syncsafe y, si la etiqueta cabe en la cabecera, sincronía de trama MPEG después. Residual declarado: una etiqueta ID3 mayor que la cabecera de 64 KiB (carátulas) no se puede verificar. - S3: la imagen
productioncrea/var/spool/orpycamcp-uploadcon dueñoorpycamcp(un volumen nuevo hereda dueño y modo). - S4:
allowed_public_mime_types(pdf, jpeg, png, tiff) gobierna/internal/anexo-externo; antes usaba la lista de anexos completa y desde este ADR admitía audio y video. - S5: al arrancar se purgan los temporales huérfanos del spool (
av-*.part,aip-*.bin,aip-zip-*.zip,aip-ant-*.bin) con más deSPOOL_ORPHAN_MINUTES(720); el AIP lleva un cupo deMAX_CONCURRENT_AIPS(1) por réplica. - S6: se eligió la regla de ciclo de vida
AbortIncompleteMultipartUpload(1 día) en los buckets de anexos y de preservación, y no abortar a mano: miniopy solo aborta anteException, no ante una cancelación, y abortar a mano exige una API privada de listado; la regla cubre además la caída del proceso. Mejor esfuerzo: si el almacén no la soporta se avisa en el log. - C2: un expediente de conservación total con más de
AIP_MAX_BYTES(16 GiB) no recibe AIP. Quedaagotadode inmediato conultimo_error = aip_demasiado_grande(métrica de agotados,GET /indices/aip-agotados, asientoarchive.aip_agotado), no «pendiente» para siempre. Salida: subir el tope y reintentar a mano; la solución de fondo (partir el AIP en volúmenes) es deuda. - C3: el rechazo por tope es un evento
EMPAQUETADO/fallocon su motivo y el asientoaip.rechazado_por_tamano, no unaFIJACION. - C5: el 422
formato_sin_migradornombra el formato: «El formato X no tiene migrador en este despliegue».
Desviaciones respecto del diseño inicial¶
- El cupo de concurrencia es un contador sobre el bucle de eventos (equivalente a
asyncio.Semaphore.locked()sin espera). - El AIP baja primero cada documento a un temporal (con su fixity por trozos) y luego lo copia al ZIP, en vez de descargar mientras escribe: la validación PDF/A necesita los PDF antes de armar el PREMIS. El resultado en memoria es el mismo; el spool admite hasta 2× el payload de forma transitoria.
- El 411 de
hooks.server.jssolo aplica a cuerposTransfer-Encoding: chunkedsinContent-Length: las llamadas internasevent.fetchde SvelteKit no declaran longitud y no son tráfico del exterior. - Los fixtures se generaron con
ffmpegdel anfitrión (ya instalado; nada se instaló) enscripts/fixtures-av.sh, no con una imagen fijada por digest.
Enmienda del lote 6 (2026-10-30)¶
- AV-10:
SubprocessGhostscriptMigrator.admite_origensolo acepta PDF y PostScript/EPS (antes todo salvo audio/video). - AV-06:
/upload/audiovisualdecide el permiso con una conexión que vuelve al pool (require_permission(..., retener_conexion=False)) y toma la del servicio después de recibir el cuerpo. - AV-05: el guardián de lecturas enteras recorre todo
app/por AST;main.pyyrouters/preservacion.py(migración de formato) quedan listadas con su motivo. - AV-11: un MP3 sin ID3 exige dos tramas consecutivas coherentes (misma versión y frecuencia, la 2.ª justo tras la longitud de la 1.ª); un ISO BMFF sin
ftypsolo es QuickTime y lo sostienenmoov/mdatencadenadas (unvideo/mp4declarado ya no lo salva). Sigue sin verificarse una etiqueta ID3 mayor que la cabecera de 64 KiB.