Saltar a contenido

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 en memory: 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 .eml de 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 sin formatRegistry para 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

  1. 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?) y mkv (¿FFV1 o H.264?) son aptos condicionales (condicional en el catálogo) y el aviso al Comité dice «apto según contenedor, códec sin verificar» (formato_apto_condicional); flac es sin pérdida por construcción. Cada fila lleva una familia (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/mp4 de un tipo que declara mp4 es conforme; de un tipo que declara solo pdfa, no_conforme motivo formato; con tipo mkv y anexo video/webm, no_conforme (un código por contenedor).
  • Avisos ADR-057: formato_no_apto_conservacion salta para una serie CT/S cuyo tipo declare solo mp3/mp4/mov/webm/ opus (deseado: una audiencia CT grabada en MP4); formato_en_riesgo_segun_plan casa con el MIME canónico porque el plan lo guarda canónico.
  • UI: SoporteFormato agrupa por familia; /radicar enruta por tipo MIME o extensión, avisa del tamaño antes de subir (topes en variables privadas UPLOAD_MAX_BYTES/UPLOAD_AV_MAX_BYTES, nunca PUBLIC_), pinta el progreso con XMLHttpRequest y 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 que RetencionNoVerificadaError ya 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

  1. Códec no identificado (FFV1 vs H.264 en MKV, HEVC en MP4/MOV): apto_preservacion de mkv habla 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.
  2. 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.
  3. Reproducción con salto: la descarga mediada no sirve Range; se descarga o se reproduce sin saltar.
  4. 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.
  5. MP3 sin ID3 con una sola trama comprobada; ISO BMFF sin ftyp decidido por el declarado entre mov y mp4.
  6. Sin migrador AV: un formato AV en riesgo se avisa, no se migra.
  7. La fixity de format_migration_service y de preservation_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án test_ningun_servicio_materializa_un_objeto_entero… lista cada lectura entera que queda, con su motivo.
  8. Observación adyacente: Ghostscript tampoco convierte application/msword, que es justo el ejemplo del comentario de config.py; ese caso termina en fallido una vez por fichero (no en bucle). No se corrige aquí.
  9. Partir el AIP en varios volúmenes para expedientes de más de 16 GiB (C2): hoy se señala, no se resuelve.
  10. La conexión de base de datos de la petición se mantiene tomada mientras dura la subida AV (máximo MAX_CONCURRENT_AV_UPLOADS por 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 30s y limit_conn por IP en las dos location AV.
  • S2 olfateo con disfraz: la puerta AV rechaza un «%PDF-» en el primer KiB y los comienzos MZ, \x7fELF y #!; un ISO BMFF sin ftyp exige 2-3 cajas encadenadas válidas (una caja cuya longitud se sale de la cabecera no se puede encadenar y se rechaza); un ID3 exige 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 production crea /var/spool/orpycamcp-upload con dueño orpycamcp (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 de SPOOL_ORPHAN_MINUTES (720); el AIP lleva un cupo de MAX_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 ante Exception, 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. Queda agotado de inmediato con ultimo_error = aip_demasiado_grande (métrica de agotados, GET /indices/aip-agotados, asiento archive.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/fallo con su motivo y el asiento aip.rechazado_por_tamano, no una FIJACION.
  • C5: el 422 formato_sin_migrador nombra 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.js solo aplica a cuerpos Transfer-Encoding: chunked sin Content-Length: las llamadas internas event.fetch de SvelteKit no declaran longitud y no son tráfico del exterior.
  • Los fixtures se generaron con ffmpeg del anfitrión (ya instalado; nada se instaló) en scripts/fixtures-av.sh, no con una imagen fijada por digest.

Enmienda del lote 6 (2026-10-30)

  • AV-10: SubprocessGhostscriptMigrator.admite_origen solo acepta PDF y PostScript/EPS (antes todo salvo audio/video).
  • AV-06: /upload/audiovisual decide 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.py y routers/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 ftyp solo es QuickTime y lo sostienen moov/mdat encadenadas (un video/mp4 declarado ya no lo salva). Sigue sin verificarse una etiqueta ID3 mayor que la cabecera de 64 KiB.