Translating Content

Extract the source text, translate it, and optionally speak the translation back onto the asset as narration — the three jobs a translation pipeline is really made of.

Translation in MetaLoom is three separate jobs, and it is worth naming them before writing any pipeline:

  1. Extract the source text — from speech, from documents, from images.

  2. Translate that text.

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

Extract, translate, speak Four extractor nodes turn media into text: whisper for speech, tika for documents, ocr for images and vlm for scanned pages, each carrying its result on a named output port. Those ports feed the translate node, whose translated text can then be wired into the text input port of the tts node to synthesize narration audio. 1 · Extract — stock nodes whisper transcript video · audio tika content documents ocr text images with text vlm result_{promptId} scans · layout kept 2 · Translate translate one node per language translation — plain prose 3 · Speak tts Orpheus · Kokoro audio — narration WAV plain text
Important

translate needs a reachable language model — any OpenAI-compatible endpoint — on the worker that runs it. A kind no online worker advertises makes the run fail with a 503; see Which node kinds a worker can actually run.

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

whisper

transcript

The utterances with per-utterance timings.

Documents, any format

tika

content

The document body with the markup stripped, via Apache Tika. (flags records which parsers ran.)

Images with text

ocr

text

Tesseract glyph recognition, in reading order.

Scanned pages, forms

vlm

result_{promptId}

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 result port.

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 llm node cannot do this job. Its only input port is media and it builds its prompt from the asset’s filename, so there is no port to wire a transcript into. It is built for classification and tagging from metadata. The two nodes share their model plumbing, not their shape.

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:

  • whisper takes video here, not audio. Its two media inputs are alternatives: exactly one of them must be wired. Wire audio instead for an audio-only library — see Whisper.

  • The node emits audio and flag, and records an asset_node_result ledger entry — the WAV itself stays in the worker’s local tts_bin cache, the same way thumbnails do. Wire audio into S3 Sink if it needs to be served.

  • Do not wire a raw llm result_{promptId} port into text when 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 are text/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 translate node

Belongs in the graph; composes with tts for dubbing.

Several target languages

One translate node per language

Each translation is stored under its own language and stays separately retrievable.

Dubbing / accessibility audio

translate + tts

tts speaks whatever is wired into its text port.

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 llm node "translates" nonsense

It only ever saw the filename. It cannot read a transcript — use translate, which has a text input port.

Two translate nodes, but only one translation stored

Both are set to the same targetLanguage. The language is the storage key, so the second overwrites the first. Give them different targets.

The translation is truncated

The input hit maxChars. Raise it, or translate per scene rather than per file.

The narration is in the right words but the wrong accent

The tts node’s own language still points at the source language. It says how to speak, never what to speak — set it to match the translate node’s target.

Saving the pipeline fails with requires input 'text' …​ but nothing is connected

Nothing is wired into the tts node’s text port. A required input port with no edge is rejected when the definition is saved, not at run time — the run never starts.

Saving fails with incompatible content types

The two ends of an edge disagree. A struct/json port cannot feed a text/* input, and assignability never crosses families — so a hash/sha512 port cannot feed a string input either.

Saving fails with does not say which ports it connects

An edge is missing sourcePort or targetPort. Both are mandatory on every edge.

The narration reads out JSON syntax

A JSON-returning llm prompt port is wired into text. Both ports are text/plain, so validation cannot catch it — wire a prose port instead. The same applies to translate: it will dutifully translate field names.

The translation is stored but not visible

It is a JSON component — read it from GET /api/v1/assets/{uuid}/json-comps, filtered by variant.

OCR-based translation of scans is garbled

Use vlm instead of ocr; layout-preserving transcription survives translation, flattened text does not.

Run rejected with 503

A kind in the graph is not registered on any online worker — see node availability.

Next Steps

Looking for something else?