SuperAPI AI Directory

adonis-attachment-variants

Design & creation

View repository

Updated 10-15 20:05

About this skill

AdonisJS Attachment — variants & converters

Variants are derived files (thumbnails, resized images, previews) generated from an attachment. They are declared as converters in config/attachment.ts, generated asynchronously in a queue after save (for variants listed in the decorator's variants option) or on the fly by the attachments route.

Full documentation: https://adonis-attachment.jrmc.dev

Configuration

// config/attachment.ts
import { defineConfig } from '@jrmc/adonis-attachment'
import { InferConverters } from '@jrmc/adonis-attachment/types/config'

const attachmentConfig = defineConfig({ converters: { thumbnail: { resize: 300, format: 'webp', }, medium: { resize: 600, blurhash: true, }, }, })

export default attachmentConfig

declare module '@jrmc/adonis-attachment' { interface AttachmentVariants extends InferConverters {} }


The `declare module` block gives typed variant names — keep it in sync.

When no `converter` is specified, the **autodetect converter** picks the right one from the mime type (image / PDF / document / video). Since v5, converter options are flat (no `options: {}` wrapper needed, though still accepted).

Then in the model, list the variants to pre-generate:

```ts
@attachment({ variants: ['thumbnail', 'medium'] })
declare avatar: Attachment | null

Pre-generation is optional when using router.attachments() — missing variants are generated on demand.

Image converter (sharp)

Requires npm install sharp.

thumbnail: {
  converter: () => import('@jrmc/adonis-attachment/converters/image_converter'), // optional, autodetect covers it
  resize: 300,                 // number, or sharp resize object:
  // resize: { width: 400, height: 400, fit: 'cover', position: 'top' },
  format: 'jpeg',              // default 'webp'; string or { format, options: { quality: 80 } }
  autoOrient: false,           // default true (EXIF-based rotation)
  blurhash: true,              // default false; or { enabled: true, componentX: 4, componentY: 4 }
}

resize/format accept everything the sharp API accepts (https://sharp.pixelplumbing.com). The blurhash string is stored on the variant JSON (variant.blurhash).

Video / PDF / document thumbnails

These converters shell out to system binaries (since v5, no npm wrapper packages are needed):

Converter Import path Binaries required
Video thumbnail @jrmc/adonis-attachment/converters/video_thumbnail_converter ffmpeg, ffprobe
PDF thumbnail @jrmc/adonis-attachment/converters/pdf_thumbnail_converter poppler (pdftoppm, pdfinfo)
Document thumbnail @jrmc/adonis-attachment/converters/document_thumbnail_converter LibreOffice (soffice)
# linux
sudo apt-get install ffmpeg poppler-data poppler-utils
# mac
brew install ffmpeg poppler

All three accept the image converter options (format, resize, ...) to post-process the generated thumbnail:

preview: {
  converter: () => import('@jrmc/adonis-attachment/converters/pdf_thumbnail_converter'),
  format: 'webp',
  resize: 720,
}

Custom binary paths (deployments, precompiled binaries):

const attachmentConfig = defineConfig({
  bin: {
    ffmpegPath: app.makePath('bin/ffmpeg'),
    ffprobePath: app.makePath('bin/ffprobe'),
    pdftoppmPath: '...',
    pdfinfoPath: '...',
    sofficePath: '...',
  },
  converters: { /* ... */ },
})

Custom converter

node ace make:converter gif2webp

Creates app/converters/gif_2_webp_converter.ts. A converter extends the base class and returns a Buffer or a file path:

import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
import type { Input } from '@jrmc/adonis-attachment/types/input'
import Converter from '@jrmc/adonis-attachment/converters/converter'
import sharp from 'sharp'

export default class Gif2WebpConverter extends Converter {
  async handle({ input }: ConverterAttributes): Promise<Input> {
    return sharp(input, { animated: true, pages: -1 }).webp().toBuffer()
  }
}

this.options holds the converter config; this.binPaths holds the bin config. Register it:

converters: {
  gif: { converter: () => import('#converters/gif_2_webp_converter') },
}

Variant storage paths (v5.1+)

By default variants are stored next to the original: avatars/image.jpg/thumbnail.webp.

const attachmentConfig = defineConfig({
  variant: {
    basePath: 'variants',   // prefix all variants: variants/avatars/image.jpg/thumbnail.webp
    ignoreFolder: true,     // drop the original folder: variants/image.jpg/thumbnail.webp
  },
  converters: { /* ... */ },
})

Queue

Variant generation runs async in a @poppinss/defer queue, one task per model attribute. Concurrency (default 1):

defineConfig({ queue: { concurrency: 2 }, converters: { /* ... */ } })

Queue callbacks (in a preload file):

import { attachmentManager } from '@jrmc/adonis-attachment'

attachmentManager.queue.onError = (error, task) => { /* ... */ }
attachmentManager.queue.taskCompleted = (task) => { /* ... */ }
attachmentManager.queue.drained = () => { /* ... */ }

Waiting for completion (tests):

const notifier = new Promise((resolve) => { attachmentManager.queue.drained = resolve })
// ... save the model ...
await notifier

timeout config (default 30000 ms) bounds each conversion operation.

Events (v5.1+)

Emitted via the AdonisJS emitter: attachment:variant_started, attachment:variant_completed, attachment:variant_failed.

// start/attachment.ts (node ace make:preload attachment)
import emitter from '@adonisjs/core/services/emitter'
import type { AttachmentEventPayload } from '@jrmc/adonis-attachment/types/event'

emitter.on('attachment:variant_failed', (payload: AttachmentEventPayload) => {
  // payload: { tableName, attributeName, primary: { key, value }, variants? }
})

Regenerating variants

RegenerateService re-creates variants declared in the model's variants option:

import { inject } from '@adonisjs/core'
import { RegenerateService } from '@jrmc/adonis-attachment'

@inject()
async regenerate(regenerate: RegenerateService) {
  await regenerate.row(user).run()      // one row
  await regenerate.model(User).run()    // whole model

  // filters
  await regenerate.model(User, { variants: ['thumbnail'], attributes: ['avatar'] }).run()
}

Typical use: after adding/changing a converter in config, run a regeneration from an ace command or a route.