Translation in MetaLoom is three separate jobs, and it is worth naming them before writing any pipeline:
-
Extract the source text — from speech, from documents, from images.
-
Translate that text.
-
Optionally speak the translation back onto the asset as narration audio.
All three are stock nodes. This page builds the pipeline end to end, then covers the two off-graph routes that are still the right answer for a one-off backfill or a human-reviewed translation.
The Shape of It
|
Important
|
|
Step 1 — Extract the Source Text
Pick the extractor that matches the medium. All four write their text into Loom as a component and carry it on a typed output port a downstream node can be wired to.
| Medium | Node | Output port | Notes |
|---|---|---|---|
Video, audio |
|
The utterances with per-utterance timings. |
|
Documents, any format |
|
The document body with the markup stripped, via Apache Tika. ( |
|
Images with text |
|
Tesseract glyph recognition, in reading order. |
|
Scanned pages, forms |
|
A vision-language model (olmOCR) that keeps tables, columns and layout that plain OCR flattens. One port per configured prompt; with no prompts configured the node exposes a single |
All four ports carry a type in the text family — text/transcript for whisper, text/plain for the
rest — which is what lets any of them be wired into a consumer that declares text/*, such as
tts or sentiment. Nothing addresses a node by
its graph id any more: the edge says which port feeds which, and an edge whose two ends do not
agree on the content type is rejected when the pipeline is saved.
For scanned documents, prefer vlm over ocr: a translated invoice whose table structure was
flattened into a column of numbers is not a usable translation.
A speech extraction graph is exactly the one from
Transcribing for the Chat Agent — source, MIME filter, sha512, whisper.
Build that first and confirm the transcripts are stored before adding anything downstream.
Step 2 — Translate
translate takes text on its text input port and puts the translation
on its translation output port. Because the port is typed text/*, any of the four extractors above
can feed it — you draw the edge, and nothing in the node’s configuration says where the text came
from.
{ "id": "translate", "type": "translate", "name": "Translate to English",
"options": { "targetLanguage": "en", "model": "google/gemma-2-27b-it" } }
|
Note
|
The |
One node per language
A translate node translates into exactly one language. For several, add several nodes and connect the same upstream text to each:
{ "id": "to-en", "type": "translate", "options": { "targetLanguage": "en" } },
{ "id": "to-fr", "type": "translate", "options": { "targetLanguage": "fr" } }
Each translation is stored under its own target language, so one asset carries en, de and fr
side by side and each stays separately retrievable:
curl -s -H "Authorization: Bearer $TOKEN" \
"$LOOM/api/v1/assets/$ASSET/json-comps" \
| jq '.data[] | select(.schemaType == "translation" and .variant == "fr") | .data.text'
They also run independently, so a slow or unavailable model for one language does not hold up the others.
Long transcripts
A feature-length transcript does not fit in a model’s context window. The node splits it at paragraph
boundaries first, then sentence boundaries — never mid-sentence — and rejoins the answers in order.
maxChunkChars sets the budget per request, and each chunk is one call to the model, so a longer
recording costs proportionally more. chunkCount in the stored result records how many it took.
When not to use the node
Two situations are still better served off the graph.
A one-off backfill of an existing library. Adding a node to a pipeline only affects what the pipeline processes next. To translate what you already have, read the stored text over REST, translate it, and write the result back as a component on the same asset:
ASSET=<asset-uuid>
# 1. Read the stored transcript
curl -s -H "Authorization: Bearer $TOKEN" \
$LOOM/api/v1/assets/$ASSET/transcripts | jq -r '.[0].text' > source.txt
# 2. Translate it with your provider (any OpenAI-compatible endpoint works)
TRANSLATION=$(curl -s http://llm:8080/v1/chat/completions \
-H 'Content-Type: application/json' -d "{
\"model\": \"google/gemma-2-27b-it\",
\"messages\": [{ \"role\": \"user\", \"content\":
$(jq -Rs '"Translate the following text into English. Return only the translation.\n\n" + .' source.txt) }]
}" | jq -r '.choices[0].message.content')
# 3. Store it back on the asset, in the same shape the node writes
curl -s -X POST $LOOM/api/v1/assets/$ASSET/json-comps \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -n --arg t "$TRANSLATION" '{
nodeKind: "translate",
schemaType: "translation",
variant: "en",
producerVersion: "google/gemma-2-27b-it",
data: { targetLanguage: "en", text: $t }
}')"
Writing the same schemaType and variant the node writes means backfilled and pipeline-produced
translations read back through one query.
An occasional translation a human should see first. The chat agent already
reaches transcripts through search_transcript. A skill makes the behaviour consistent:
---
name: transcript-translator
description: Translate an asset's transcript into a target language, preserving speaker turns and timings.
---
# Transcript Translator
When the user asks for a translation of a clip:
1. Resolve the asset with `get_asset` (or `search_assets` if only a name was given).
2. Pull the transcript with `search_transcript`.
3. Translate **only what is there**. Keep the utterance order and quote the original timings so the
user can locate each line in the video.
4. Render as a table: `start | original | translation`.
If no transcript exists, say so and suggest running the Whisper pipeline — never translate from the
filename or invent dialogue.
The agent’s domain tools are read-only: it produces the translation in the conversation, not on the asset. Pair it with the REST write above when the translation must be stored.
Step 3 — Speak the Translation
tts turns upstream text into narration audio. Unlike the llm node it does
consume upstream text — through a single text input port typed text/*. Its worker configuration
says how to speak, never what to speak:
# worker cortex.yml
nodes:
tts:
ttsHost: tts
ttsPort: 9100
language: en # en → Kokoro (CPU), de → Orpheus/Kartoffel (GPU recommended)
voice: af_heart # German default: Jakob
The words come from the graph. The full dubbing pipeline is:
{
"nodes": [
{ "id": "source", "type": "filesystem-source", "name": "Video library",
"options": { "path": "/media/video" } },
{ "id": "hash", "type": "sha512", "name": "SHA-512 identity" },
{ "id": "speech", "type": "whisper", "name": "Transcribe",
"timeoutMs": 3600000 },
{ "id": "translate", "type": "translate", "name": "Translate to English",
"options": { "targetLanguage": "en" } },
{ "id": "narrate", "type": "tts", "name": "Speak the translation" }
],
"edges": [
{ "id": "e1", "source": "source", "sourcePort": "media",
"target": "hash", "targetPort": "media" },
{ "id": "e2", "source": "source", "sourcePort": "media",
"target": "speech", "targetPort": "video" },
{ "id": "e3", "source": "speech", "sourcePort": "transcript",
"target": "translate", "targetPort": "text" },
{ "id": "e4", "source": "translate", "sourcePort": "translation",
"target": "narrate", "targetPort": "text" }
]
}
Every edge names both ends: sourcePort on the producing node, targetPort on the consuming one.
There is no positional fallback — an edge without them is rejected. The filter’s routing key is
branch (ANY, PASS or REJECT) and is only valid on an edge leaving a filter node.
Three things to know about the TTS step:
-
whispertakesvideohere, notaudio. Its two media inputs are alternatives: exactly one of them must be wired. Wireaudioinstead for an audio-only library — see Whisper. -
The node emits
audioandflag, and records anasset_node_resultledger entry — the WAV itself stays in the worker’s localtts_bincache, the same way thumbnails do. Wireaudiointo S3 Sink if it needs to be served. -
Do not wire a raw
llmresult_{promptId}port intotextwhen that prompt returns a JSON document: the synthesizer would read the braces and field names aloud. The types cannot catch this for you — both ports aretext/plain— so feed it prose.
The sidecar itself is a small FastAPI service shipped next to the node (server/ in the source tree),
with a production path where Orpheus runs on vLLM or llama.cpp.
Choosing a Route
| Situation | Route | Why |
|---|---|---|
Translation on every ingest |
The |
Belongs in the graph; composes with |
Several target languages |
One |
Each translation is stored under its own language and stays separately retrievable. |
Dubbing / accessibility audio |
|
|
One-off backfill of an existing library |
REST batch |
A pipeline only affects what it processes next; a backfill reaches what is already stored. |
Occasional, reviewed translation |
Chat agent + skill |
A human sees the output before it counts. |
Gotchas
| Symptom | Cause |
|---|---|
The |
It only ever saw the filename. It cannot read a transcript — use |
Two |
Both are set to the same |
The translation is truncated |
The input hit |
The narration is in the right words but the wrong accent |
The |
Saving the pipeline fails with |
Nothing is wired into the |
Saving fails with |
The two ends of an edge disagree. A |
Saving fails with |
An edge is missing |
The narration reads out JSON syntax |
A JSON-returning |
The translation is stored but not visible |
It is a JSON component — read it from |
OCR-based translation of scans is garbled |
Use |
Run rejected with |
A kind in the graph is not registered on any online worker — see node availability. |
Next Steps
-
Get the source text first — Transcribing for the Chat Agent.
-
Translate per scene rather than per file — Scene-Level Video Analysis.
-
The node’s full option set — Translate.
-
Build a node of your own — A Custom Node in Python.