Logo VitNode

AI

Automatic Image ALT Text

VitNode can describe uploaded images in the background and translate the ALT text into every site language, under a budget and without overwriting text a person wrote. This page covers how it works, how to render it and how to run its cron.

Automatic ALT text describes images in Core Files with a vision model, once per image, and translates that description into each site language. It runs in the background as the system actor, so it never spends a member's AI points. It is off by default.

plugins/blog/src/pages/post-page.tsx
import { resolveImageAlt } from '@vitnode/core/lib/files/resolve-alt'

;<img
  alt={
    resolveImageAlt({
      alts: item.coverImage.alts, // the file's ALT per language
      fallbackLocales: [defaultLocale],
      locale,
      occurrence: { alt: item.coverImageAlt }, // this article's own ALT
    }).alt
  }
  src={item.coverImage.url}
/>

Turn it on

Configure a vision model with pricing

At least one model must declare image-input (the description) and text (the translations), and both need pricing. The feature always runs under a budget, so VitNode refuses an unpriced model with AI_PRICING_MISSING.

apps/api/src/vitnode.api.config.ts
{
  id: 'default',
  name: 'GPT-4o Mini',
  model: openai('gpt-4o-mini'),
  capabilities: ['text', 'image-input', 'structured-output', 'streaming'], 
  pricing: { rates: { inputPerMillion: '0.15', outputPerMillion: '0.60' } },
}

Set a site budget

On Artificial Intelligence (AI) → Overview, click Settings and, under Budget, set Monthly site budget. Automatic ALT cannot be switched on without one: the API answers 400 and the form shows "Set a monthly site budget before turning on automatic ALT text." You can also set Background jobs budget to cap system work separately. It counts inside the site budget.

Switch it on

In the same Settings sheet, under ALT text:

  • Write ALT text automatically. Turns the feature on.
  • Images queued per sweep. How many images one hourly sweep queues (default 10, max 100).

The job writes ALT text in every language in core_languages. Add a language and the next sweep fills it in.

Make sure the cron runs

Nothing happens until the host runs VitNode's cron. See Running the cron.

How it works

  1. Upload. A new JPEG, PNG, GIF or WebP in core_files is queued right away when the feature is on. A failure to queue never fails the upload.
  2. Sweep. The ai-alt-detect cron (hourly) looks for images still missing ALT in a configured language and queues them.
  3. Work. The ai-queue cron (every minute) takes up to 2 queued images and describes them.
  4. Describe once. The image gets one base description in English, per file fingerprint.
  5. Translate. Each missing language gets a translation of that base description. English uses it as-is.
  6. Write. Each language goes to core_files_alt, but only if no person wrote it and the file is still the same version.
  7. Notify. files.alt.updated is emitted with the languages that were written.

One description per image, translations per language

The base description is stored in core_files_alt_analysis, keyed by the file and its fingerprint (SHA-256 of the stored bytes). When you add a language later, the sweep notices the gap and only the translation runs. The image does not go back to the vision model.

When a file's bytes change, its fingerprint changes. AI texts written for the old fingerprint count as missing, so the image is described again. The file ALT API marks such texts as stale until then.

People always win

The job never overwrites a row with origin: "human", whether on a new run, on a retry or after the file changes. That includes an empty human text: an empty string means "a person decided this image needs no description", which is different from no row at all.

To let the job write a language again, delete that language's ALT row.

Per-file policy

Each file has an altPolicy:

PolicyBehavior
automaticDefault. The job may describe the image.
manualThe job skips it. People write ALT by hand.
disabledThe job skips it, and the file is never sent to an external AI provider.

The job checks the policy again at write time, so a policy changed mid-run still wins.

The sweep

The sweep walks core_files in id order from a stored cursor, at most 200 files per run, and queues up to Images queued per sweep of them. When it reaches the end, it wraps to the start, so it revisits every image and finds any new language or missed upload.

Queuing is deduplicated: a file that is already pending or processing in the queue is not queued twice.

Budget waits are not failures

When the site budget, the background jobs budget or the global switch stops a run, the task is deferred, not failed. It goes back to pending without spending a retry, with the file's state set to waiting_budget. It wakes at the start of the next budget month or in one hour, whichever comes first. Raise the budget mid-month and work resumes within the hour.

Other failures (provider error, invalid output, timeout) use the normal queue retries: 3 attempts, then the file's state is failed. The next sweep wrap queues it again.

Its own queue

ALT tasks run on a dedicated ai queue. The general process-queue worker skips it, and ai-queue processes it under its own lock with a batch of 2. A slow vision model never delays e-mails or other queue work.

Each task has a 15-minute lease. A worker that dies mid-task leaves it processing; once the lease expires it goes back to pending (the attempt is spent), or to failed when no attempts are left. The AI run it started is settled as uncertain by ai-maintenance and charged at its reservation.

No exactly-once promise

Results are saved as soon as they exist, and a retry reuses the stored base description. A second worker waits while another one is describing the same image version. But a crash mid-call can still repeat one provider call: the one that was in flight.

Image bytes stay private

The job reads the image server-side and sends bytes to the provider, never a URL:

  • through the storage adapter's optional read(key, { maxBytes }) method (the Local adapter has one);
  • otherwise by a bounded server-side fetch() of the adapter's URL.

Files over 20 MB are skipped. With sharp installed, the copy sent to the provider is resized to at most 1024 px and re-encoded as WebP. A private file never has to be made public for the AI provider.

Render the ALT text

For images that have ALT text, Content Engine file descriptors carry it as alts, a map of language code to text:

{
  id: 42,
  name: 'bike.jpg',
  url: 'https://example.com/uploads/bike.jpg',
  mimeType: 'image/jpeg',
  alts: { en: 'A red bicycle leaning on a brick wall', pl: 'Czerwony rower oparty o ceglaną ścianę' },
  // ...
}

resolveImageAlt() from @vitnode/core/lib/files/resolve-alt picks the text for one place the image appears, in this order:

OrderSourceResult
1occurrence.decorative"", so screen readers skip the image. source: "decorative"
2occurrence.alt (non-empty)That text. source: "occurrence"
3alts[locale]The file's ALT, even an intentional "". source: "file"
4alts[fallbackLocale]First match in fallbackLocales. source: "fallback"
5Nothing"" with source: "missing". Flag it to editors.

The article's own ALT text always wins over the file's default. The blog's post page (plugins/blog/src/pages/post-page.tsx) is a complete example.

Running the cron

ALT text depends on three cron jobs registered by @vitnode/core:

JobScheduleDoes
ai-queue* * * * *Works the ai queue, 2 tasks per run.
ai-alt-detect0 * * * *The sweep: queues images missing ALT text.
ai-maintenance*/10 * * * *Settles dead runs, reconciles costs, prunes history.

Registering a cron job does not run it. The host must call VitNode's cron endpoint, in one of two ways.

In-process. On a long-running server, register the Node CRON adapter. It calls the endpoint every minute:

apps/api/src/vitnode.api.config.ts
import { NodeCronAdapter } from '@vitnode/node-cron'
import { buildApiConfig } from '@vitnode/core/vitnode.config'

export const vitNodeApiConfig = buildApiConfig({
  cron: NodeCronAdapter(), 
  // ...
})

External scheduler. On serverless hosting nothing stays alive to hold a timer, so an external scheduler must call the endpoint, ideally once a minute:

curl -X POST https://example.com/api/@vitnode/core/cron \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $CRON_SECRET"

Set a real CRON_SECRET. Each call runs every job whose schedule came due since its last run. A slower tick still runs the hourly sweep; it just works the queue less often (2 images per tick). Check Last Run at /admin/core/advanced/cron. See Cron over REST and Node CRON adapter.

Edit ALT text through the API

The file ALT routes live in the admin/files module and use the files staff permissions:

RoutePermissionPurpose
GET /admin/files/{id}/altfiles:can_viewALT per site language, with origin and stale.
PUT /admin/files/{id}/altfiles:can_edit_altSet one language as a human text ("" allowed).
DELETE /admin/files/{id}/altfiles:can_edit_altRemove one language (?languageCode=), so the job may write it again.
PUT /admin/files/{id}/alt-policyfiles:can_edit_altSet automatic, manual or disabled.
await fetcher({
  plugin: '@vitnode/core',
  method: 'put',
  module: 'admin/files',
  path: '/{id}/alt',
  args: {
    params: { id: 42 },
    body: { languageCode: 'pl', text: 'Czerwony rower' },
  },
})

React to new ALT text

files.alt.updated fires when the job (origin: "ai") or a person (origin: "human") writes a file's default ALT:

plugins/gallery/src/api/lib/events.ts
import { buildEventListener } from '@vitnode/core/api/lib/events'

export const altUpdatedListener = buildEventListener({
  event: 'files.alt.updated',
  name: 'refresh-gallery-alt',
  handler: async (c, { fileId, languageCodes, origin }) => {
    await c
      .get('log')
      .info(`ALT for file ${fileId} (${languageCodes.join(', ')}) by ${origin}`)
  },
})

See Built-in events.

Limits and gotchas

  • Images only. The job reads image/jpeg, image/png, image/gif and image/webp. It never analyses other files.
  • Base language is English. Other languages are translations of the English description.
  • Legacy metadata.alt is not migrated. An ALT a plugin stored in the free-form core_files.metadata column has no language, and VitNode does not guess one. The job treats those images as missing ALT text. Copy such values into core_files_alt yourself with PUT /admin/files/{id}/alt if you know their language.
  • Cost. The Automatic ALT text card on Artificial Intelligence (AI) → Overview shows images described, translations and the known cost per image and per translation.

Learn more