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.
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.
{
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, max100).
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
- Upload. A new JPEG, PNG, GIF or WebP in
core_filesis queued right away when the feature is on. A failure to queue never fails the upload. - Sweep. The
ai-alt-detectcron (hourly) looks for images still missing ALT in a configured language and queues them. - Work. The
ai-queuecron (every minute) takes up to 2 queued images and describes them. - Describe once. The image gets one base description in English, per file fingerprint.
- Translate. Each missing language gets a translation of that base description. English uses it as-is.
- Write. Each language goes to
core_files_alt, but only if no person wrote it and the file is still the same version. - Notify.
files.alt.updatedis 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:
| Policy | Behavior |
|---|---|
automatic | Default. The job may describe the image. |
manual | The job skips it. People write ALT by hand. |
disabled | The 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.
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:
| Order | Source | Result |
|---|---|---|
| 1 | occurrence.decorative | "", so screen readers skip the image. source: "decorative" |
| 2 | occurrence.alt (non-empty) | That text. source: "occurrence" |
| 3 | alts[locale] | The file's ALT, even an intentional "". source: "file" |
| 4 | alts[fallbackLocale] | First match in fallbackLocales. source: "fallback" |
| 5 | Nothing | "" 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:
| Job | Schedule | Does |
|---|---|---|
ai-queue | * * * * * | Works the ai queue, 2 tasks per run. |
ai-alt-detect | 0 * * * * | 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:
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:
| Route | Permission | Purpose |
|---|---|---|
GET /admin/files/{id}/alt | files:can_view | ALT per site language, with origin and stale. |
PUT /admin/files/{id}/alt | files:can_edit_alt | Set one language as a human text ("" allowed). |
DELETE /admin/files/{id}/alt | files:can_edit_alt | Remove one language (?languageCode=), so the job may write it again. |
PUT /admin/files/{id}/alt-policy | files:can_edit_alt | Set 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:
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/gifandimage/webp. It never analyses other files. - Base language is English. Other languages are translations of the English description.
- Legacy
metadata.altis not migrated. An ALT a plugin stored in the free-formcore_files.metadatacolumn has no language, and VitNode does not guess one. The job treats those images as missing ALT text. Copy such values intocore_files_altyourself withPUT /admin/files/{id}/altif 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.