@soamee/media-manager
v1.0.2
Published
Framework-agnostic media processing: upload, resize, format conversion, cloud storage. With NestJS wrapper.
Readme
@soamee/media-manager
Gestion de media para Node.js: upload, resize, variantes, conversion de formato, y almacenamiento cloud. Core agnostico con wrapper NestJS.
Instalacion
npm install @soamee/media-managerQue necesitas segun lo que uses
| Quieres | Necesitas | Si falta |
|---------|-----------|----------|
| Procesar imagenes, variantes, cualquier poster | npm install sharp + vips-dev en la imagen | No arranca |
| Procesar video | npm install fluent-ffmpeg + apk add ffmpeg | No arranca |
| Posters de PDF | apk add poppler-utils | No arranca |
| Renditions diferidas | npm install pg | No arranca |
| Subida por stream a S3 | npm install @aws-sdk/lib-storage | Arranca. Sube por buffer: mas RAM, mismo resultado |
| Storage S3/DO/MinIO | npm install @aws-sdk/client-s3 | No se puede construir el provider |
| Storage GCP | npm install @google-cloud/storage | No se puede construir el provider |
"No arranca" solo aplica si tu configuracion lo pide. Si ningun profile usa video, que falte ffmpeg da igual y no se menciona. Ver Entorno para la politica y como saltarsela en desarrollo.
Ninguna se importa en el nivel superior: se cargan de forma perezosa la primera
vez que se usan, asi que require('@soamee/media-manager') nunca falla por una
dependencia ausente. El corte lo decide init(), con un mensaje que dice que
falta y como instalarlo, no un volcado del cargador nativo.
Uso con NestJS (recomendado)
import { MediaManagerModule, MediaManagerService, UseProfile, MediaUploadInterceptor } from '@soamee/media-manager/nestjs';
// app.module.ts
@Module({
imports: [
MediaManagerModule.forRoot({
storage: {
provider: 's3',
bucket: 'my-bucket',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: 'eu-west-1',
},
profiles: {
avatar: {
variants: [
{ name: 'thumb', width: 150, height: 150, fit: 'cover' },
{ name: 'medium', width: 400 },
],
outputFormat: 'webp',
maxFileSize: 5_000_000,
allowedMimes: ['image/jpeg', 'image/png'],
folder: 'avatars',
},
lesson: {
video: true,
generateThumbnail: true,
thumbnailWidth: 600,
maxFileSize: 800_000_000,
folder: 'lessons',
},
},
}),
],
})
export class AppModule {}
// media.controller.ts
@Controller('media')
export class MediaController {
constructor(private mediaManager: MediaManagerService) {}
@Post('avatar')
@UseProfile('avatar')
@UseInterceptors(FileInterceptor('file'), MediaUploadInterceptor)
async uploadAvatar(@UploadedFile() file: Express.Multer.File) {
return this.mediaManager.process(file, 'avatar');
}
}Async config (con ConfigService)
MediaManagerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
storage: {
provider: config.get('STORAGE_PROVIDER'),
bucket: config.get('STORAGE_BUCKET'),
accessKeyId: config.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: config.get('AWS_SECRET_ACCESS_KEY'),
},
profiles: { ... },
}),
});Uso standalone (sin NestJS)
import { MediaManager } from '@soamee/media-manager/core';
const manager = new MediaManager({
storage: { provider: 's3', bucket: 'my-bucket', ... },
profiles: {
avatar: {
variants: [{ name: 'thumb', width: 150, height: 150, fit: 'cover' }],
outputFormat: 'webp',
folder: 'avatars',
},
},
});
const result = await manager.process(imageBuffer, 'avatar');
// result.original -> { url, mimetype, width, height, size }
// result.variants -> { thumb: { url, mimetype, width, height, size } }Storage Providers
| Provider | Config provider | Cubre |
|----------|------------------|-------|
| S3 | 's3' | AWS S3, DigitalOcean Spaces, MinIO |
| GCP | 'gcp' | Google Cloud Storage |
| None | 'none' | In-memory (dev/testing) |
S3 config
storage: {
provider: 's3',
bucket: 'my-bucket',
accessKeyId: '...',
secretAccessKey: '...',
region: 'us-east-1',
endpoint: 'nyc3.digitaloceanspaces.com', // para DO/MinIO
cdnEndpoint: 'cdn.example.com', // opcional
}GCP config
storage: {
provider: 'gcp',
bucket: 'my-bucket',
projectId: '...',
privateKey: '...',
clientEmail: '...',
clientId: '...',
}Profiles
Cada profile define como procesar un tipo de media:
profiles: {
avatar: {
variants: [
{ name: 'thumb', width: 150, height: 150, fit: 'cover' },
{ name: 'medium', width: 400 },
],
outputFormat: 'webp', // 'original' | 'webp' | 'avif' | 'jpeg' | 'png'
quality: 80, // 1-100
maxFileSize: 5_000_000, // bytes
allowedMimes: ['image/jpeg', 'image/png'],
folder: 'avatars',
},
}Variant config
| Campo | Tipo | Default | Descripcion |
|-------|------|---------|-------------|
| name | string | required | Nombre de la variante |
| width | number | - | Ancho en pixels |
| height | number | - | Alto en pixels |
| fit | string | - | cover, contain, fill, inside, outside |
| format | OutputFormat | profile format | Override formato para esta variante |
| quality | number | profile quality | Override calidad |
Video config
profiles: {
lesson: {
video: true, // usa defaults
// o con config custom:
video: {
codec: 'libx264',
audioCodec: 'aac',
preset: 'slow',
crf: 18,
bitrate: '5000k',
maxBitrate: '6000k',
bufferSize: '10000k',
fps: 30,
maxWidth: 1280,
maxHeight: 1280, // por defecto = maxWidth
minimumDuration: 1,
audioBitrate: '128k',
audioChannels: 2,
profile: 'main',
level: '4.0', // opcional
pixelFormat: 'yuv420p',
faststart: true,
},
generateThumbnail: true,
thumbnailWidth: 600,
},
}| Campo | Default | Descripcion |
|-------|---------|-------------|
| maxWidth | 1280 | Cota superior de ancho. Nunca hace upscale |
| maxHeight | = maxWidth | Cota superior de alto. Acota el lado mayor, asi el video vertical no se dispara |
| audioBitrate | '128k' | Bitrate de audio |
| audioChannels | 2 | Downmix a estereo, seguro en movil |
| profile | 'main' | Perfil H.264. main es el default seguro para navegadores moviles |
| level | - | Nivel H.264. Se omite si no se indica |
| pixelFormat | 'yuv420p' | Requerido por la mayoria de decodificadores moviles |
| faststart | true | Mueve el atomo moov al inicio. Sin esto la descarga progresiva no empieza a reproducir hasta tener el fichero entero |
El escalado acota ambos ejes y fuerza dimensiones pares, porque libx264 con
yuv420p rechaza las impares. Un origen mas pequenio que la cota se deja como
esta en vez de agrandarlo.
Deteccion de tipo
El tipo de contenido se resuelve por este orden:
- El mimetype declarado por el cliente (
file.mimetypede multer). - Los magic bytes del propio buffer.
- La extension del nombre de fichero, como pista.
El wrapper de NestJS propaga el mimetype y el nombre original automaticamente.
Con el core directamente puedes pasar un MediaInput en vez de un Buffer:
await manager.process(
{ buffer, mimetype: file.mimetype, filename: file.originalname },
'lesson',
);Sigue aceptando Buffer o una ruta de fichero como antes.
Documentos
Los documentos son un tercer tipo, junto a imagen y video. Se guardan tal cual y
nunca pasan por el pipeline de imagen: sharp rechaza un PDF de plano
(Input buffer contains unsupported image format), asi que antes de existir esta
rama cualquier subida de documento era un 500.
Se enrutan como documento:
application/pdf- Office y OpenDocument (
.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp),.rtf,.csv,.txt - Cualquier cosa, si el profile lleva
document: true
profiles: {
contract: {
document: true,
folder: 'contracts',
generateThumbnail: true,
thumbnail: {
page: 1, // 1-based
format: 'webp',
variants: [{ name: 'card', width: 320 }],
},
},
}contracts/9f3a.../original.pdf
contracts/9f3a.../poster.webp
contracts/9f3a.../poster-card.webpEl poster se renderiza con pdftoppm (poppler) a resolucion nativa y se
redimensiona con el mismo pipeline de imagen que el poster de video, asi que
respeta formato, calidad y variantes igual que cualquier otra imagen.
Solo PDF genera poster. Un .docx se sube correctamente pero sin miniatura:
rasterizarlo requeriria LibreOffice headless, unos 500MB de imagen Docker y un
proceso lento y fragil bajo concurrencia. Si lo necesitais, lo razonable es
convertir a PDF antes de subir.
Si poppler no esta instalado, el documento se sube igual y se omite el poster. Un renderizador ausente es un hueco operativo, no una razon para tumbar una subida que ya ha ido bien. Un PDF danado si da error.
Renditions de video
Varias salidas MP4 del mismo video, cada una con su nombre:
profiles: {
lesson: {
video: true,
folder: 'lessons',
renditions: [
{ name: 'mobile', maxWidth: 640, crf: 28, bitrate: '800k' },
{ name: 'sd', maxWidth: 854, crf: 24, bitrate: '1500k' },
{ name: 'hd', maxWidth: 1280, crf: 21, bitrate: '3000k' },
],
},
}
// result.variants -> { mobile: {...}, sd: {...}, hd: {...} }Una rendition cuyas cotas ya contienen al origen se salta: un video 720p no
produce un fichero hd de 1280 que seria identico al original. Cada rendition
acepta cualquier campo de VideoConfig.
Las renditions se transcodifican a partir del MP4 ya normalizado, no del fichero original, por velocidad de decodificacion.
Thumbnails de video
El frame se extrae a resolucion nativa y se procesa con el mismo pipeline de imagen que cualquier otra variante, asi que respeta formato y calidad:
profiles: {
lesson: {
video: true,
generateThumbnail: true,
thumbnailWidth: 600,
thumbnail: {
at: '10%', // porcentaje de la duracion, o segundos absolutos
format: 'webp',
quality: 75,
variants: [
{ name: 'mobile', width: 480 },
{ name: 'card', width: 320 },
],
},
},
}
// result.thumbnail -> { url, mimetype, width, height, size }
// result.thumbnailVariants -> { mobile: {...}, card: {...} }at por defecto es el 10% de la duracion. Un offset fijo temprano cae en negro
en cualquier video que abra con fundido.
Naming: URLs derivables
Por defecto (naming: 'uuid') cada fichero lleva su propio uuid, sin relacion
entre ellos. Con naming: 'asset-key' todos los ficheros de una subida
comparten prefijo:
new MediaManager({
storage: { ... },
naming: 'asset-key',
profiles: { ... },
});lessons/9f3a.../original.mp4
lessons/9f3a.../mobile.mp4
lessons/9f3a.../hd.mp4
lessons/9f3a.../poster.webp
lessons/9f3a.../poster-mobile.webpEl resultado incluye assetId y storageKey. Guardando solo el
storageKey puedes componer la URL de cualquier variante:
const result = await manager.process(file, 'lesson');
// result.storageKey -> 'lessons/9f3a...'
manager.getVariantUrl(result.storageKey!, 'mobile.mp4');
// -> https://cdn.example.com/lessons/9f3a.../mobile.mp4
await manager.getVariantStream(result.storageKey!, 'mobile.mp4');Guardar la clave en vez de la URL absoluta significa que cambiar de CDN o de bucket es cambiar una variable de entorno, no un UPDATE masivo sobre cada fila.
Persistencia
La libreria no crea ni migra la tabla de assets. MediaAsset es una entidad de
dominio que tus modelos referencian con foreign keys reales, asi que su schema
vive en tus migraciones, donde Prisma puede verlo. Una tabla creada en runtime
no se puede relacionar desde schema.prisma, y Prisma la reportaria como drift
ofreciendo borrarla — llevandose por delante el unico mapeo entre tus filas y
los objetos del bucket.
En su lugar, la libreria define el contrato y tu lo implementas:
interface MediaPersistence {
save(asset: MediaAssetRecord): Promise<void>;
findByAssetId(assetId: string): Promise<MediaAssetRecord | null>;
delete(assetId: string): Promise<void>;
}new MediaManager({
storage: { ... },
naming: 'asset-key', // requerido por persistence
persistence: new PrismaMediaPersistence(prisma),
profiles: { ... },
});prisma/media-migration.example.prisma— el modelo para copiar y adaptar.examples/prisma-persistence.ts— la implementacion para copiar y adaptar.
Si se configura persistence sin naming: 'asset-key', el constructor falla:
una fila persistida solo sirve si su clave se puede convertir en URLs, y la
estrategia uuid no puede.
Con persistencia configurada aparecen dos metodos mas:
await manager.getAsset(assetId); // MediaAssetRecord | null
await manager.deleteAsset(assetId); // borra ficheros Y filadeleteAsset borra todos los ficheros del asset antes que la fila. Borrar solo
la fila dejaria los objetos en el bucket, facturando para siempre y sin nada que
apunte a ellos.
Renditions diferidas
Transcodificar tres renditions de un video largo dentro del request HTTP es un
timeout garantizado. Con renditionMode: 'deferred' la llamada sube el original
y el poster, encola el resto, y devuelve enseguida con status: 'pending':
import { PostgresMediaJobStore } from '@soamee/media-manager/core';
new MediaManager({
storage: { ... },
naming: 'asset-key',
persistence: new PrismaMediaPersistence(prisma),
renditionMode: 'deferred',
jobs: new PostgresMediaJobStore({ databaseUrl: process.env.DATABASE_URL! }),
profiles: { ... },
});Requiere jobs y persistence: una rendition diferida termina despues de que
quien llamo ya tenga su resultado, asi que sin cola donde esperar y sin fila
donde escribir, la salida no tendria donde aterrizar. El constructor falla si
faltan.
Luego, desde un worker o un cron:
const { completed, failed } = await manager.processPendingRenditions(5);Descarga el original una vez por asset, no una por rendition, transcodifica,
sube, y fusiona cada variante en el registro. Cuando no queda nada encolado para
ese asset, pasa a status: 'ready'.
La tabla de jobs
_media_processing_jobs si la crea la libreria en onModuleInit, al
contrario que la de assets. La diferencia es que esta tabla no tiene relaciones,
solo la escribe la libreria, y es desechable: un job perdido se re-encola desde
su asset. Si no se puede crear, el store se deshabilita y lo avisa por log en
vez de tumbar la aplicacion.
El reparto usa FOR UPDATE SKIP LOCKED, asi que varios workers pueden pollear
la misma tabla sin darse el mismo job. Un job que falla vuelve a pending hasta
agotar maxAttempts (3 por defecto).
Subida por stream
Cuando el provider lo soporta, los ficheros de video se suben leyendo del disco
en vez de cargarse enteros en memoria. Con maxFileSize: 800_000_000 y tres
renditions, la diferencia es varios GB de RAM por request.
- S3: requiere
@aws-sdk/lib-storage. - GCP: sin dependencias extra.
- Si el provider no puede, cae a subida por buffer sin fallar.
Entorno: que hay y que falta
Varias funciones dependen de binarios de sistema o modulos nativos que la imagen Docker puede no tener. La postura de la libreria: si tu configuracion pide algo que este entorno no puede hacer, el arranque falla.
El razonamiento es que activar posters o reescalado en una imagen que no puede hacer ninguna de las dos es una promesa que el despliegue no puede cumplir. Un contenedor que levanta igual sirve media silenciosamente incompleta durante todo el tiempo que nadie lea el log — y nadie lee el log. Fallando en el arranque, lo caza el rollout.
Que se comprueba
init() cruza el entorno con lo que tu configuracion realmente necesita,
derivado de los profiles. Si no tienes ningun profile de video, nunca se te
menciona ffmpeg:
MEDIA_CAPABILITIES_MISSING: the configuration enables media processing this
environment cannot perform.
[missing] ffmpeg - needed by profile "lesson"
Video uploads fail with FFMPEG_NOT_AVAILABLE.
fix: npm install fluent-ffmpeg and apk add --no-cache ffmpeg
Install the missing dependencies, drop the profile settings that need them, or
set capabilities.onMissing to 'warn' to start anyway.Con NestJS el chequeo va en onModuleInit, asi que no hay que llamarlo a mano.
Con el core directo, init() hay que llamarlo tu, o no se comprueba nada.
Bloquea o no
La linea la marca quien lo pidio, no lo bien que el codigo lo encaje:
| Capacidad | Bloquea | Por que |
|-----------|---------|---------|
| sharp | Si | Un profile pide variantes o un poster |
| ffmpeg / ffprobe | Si | Un profile pide video |
| poppler | Si | Un profile pide generateThumbnail sobre documentos |
| postgres | Si | La config pide renditionMode: 'deferred' |
| @aws-sdk/lib-storage | No | Nadie lo configura. Optimizacion interna con fallback identico |
Saltarsela
new MediaManager({
storage: { ... },
capabilities: {
onMissing: 'warn', // 'throw' (default) | 'warn' | 'ignore'
logger: (msg) => logger.warn(msg),
},
profiles: { ... },
});| Valor | Que hace | Cuando |
|-------|----------|--------|
| throw | No arranca | Default. Produccion |
| warn | Loguea y arranca | Maquina de desarrollo, CI, o un contenedor que solo atiende parte de los profiles |
| ignore | Ni loguea | Tienes tu propio chequeo via el health check |
Un caso real de warn: un worker que solo procesa imagenes, con la misma config
que la API pero sin ffmpeg en su imagen.
Las capacidades opcionales no lanzan con ninguna politica.
Health check
El arranque cubre el dia del despliegue. El health check cubre el resto: un
binario que desaparece en una reconstruccion de la imagen, o los modos warn e
ignore, donde el proceso levanta con carencias.
import { MediaManagerHealthIndicator } from '@soamee/media-manager/nestjs';
@Controller('health')
export class HealthController {
constructor(private media: MediaManagerHealthIndicator) {}
@Get()
@HealthCheck()
check() {
return this.health.check([() => this.media.isHealthy('media')]);
}
}{
"media": {
"status": "down",
"missing": ["ffmpeg", "ffprobe"],
"checkedAt": "2026-08-24T10:00:00.000Z",
"message": "ffmpeg: npm install fluent-ffmpeg and apk add --no-cache ffmpeg"
}
}Tiene la forma que espera @nestjs/terminus pero no depende de el, asi que
sirve igual desde un controller normal. Con
isHealthy('media', { failOnDegraded: true }) tambien tumba el check por las
opcionales.
Sin NestJS:
const report = await manager.checkCapabilities();
report.ok; // false si falta algo requerido por tu config
report.missing; // requeridas y ausentes: bloquean el arranque
report.degraded; // opcionales y ausentes: solo informativasCada entrada trae impact (que se rompe) y remedy (como instalarlo).
Response
interface MediaResult {
original: { url, mimetype, width?, height?, size? };
variants: { [name]: { url, mimetype, width?, height?, size? } };
thumbnail?: { url, mimetype, width?, height?, size? }; // solo video
thumbnailVariants?: { [name]: { url, ... } }; // solo video
assetId?: string;
storageKey?: string; // solo con naming: 'asset-key'
duration?: number; // segundos, solo video
}Docker
Ver docker/Dockerfile.example para dependencias de sistema (Sharp + FFmpeg).
Dev local con MinIO (S3 compatible):
cd docker && docker compose up -d
# MinIO en localhost:9000, console en localhost:9001
# User: minioadmin / Pass: minioadminConfigurar para MinIO:
storage: {
provider: 's3',
bucket: 'test-media',
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
endpoint: 'localhost:9000',
}Prisma
Ver prisma/media-migration.example.prisma para schema de ejemplo.
