npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-manager

Que 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:

  1. El mimetype declarado por el cliente (file.mimetype de multer).
  2. Los magic bytes del propio buffer.
  3. 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.webp

El 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.webp

El 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 fila

deleteAsset 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 informativas

Cada 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: minioadmin

Configurar 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.