Journey. As a developer, I want to trim an audio clip, convert an image
to WebP and make a thumbnail, and cut a segment out of a video — without
standing up my own FFmpeg pipeline — and I want to know reliably when each
job is done so I can pick up the result.
The platform ships three sibling transformer services built on FFmpeg:
| Service | Port (local) | Shape | Operations |
|---|---|---|---|
| Audio Transformer | :40162 | Async jobs (submit → poll → download) | trim, reverse, transcode, transmux |
| Image Transformer | :40030 | Synchronous binary response | transcode, resize, upscale |
| Video Transformer | :40160 | Async jobs (submit → SSE completion) | trim, demux, mux, reverse, transcode, transmux |
All three operate on a source_path: a local filesystem path on the host running the service — not a URL. There is no upload endpoint and no CDN involvement: your app (or an upstream step in your pipeline) places the file on disk — a shared volume, a mounted bucket, a prior job's result_path — and passes the absolute path. Results are written to a per-tenant output directory on the same host ($TMPDIR/<service>-output/<tenant_id>/…), which makes chaining jobs (audio transcode → trim) trivial.
Prerequisites
ffmpeg+ffprobeon your machine, to fabricate test assets.python3with Pillow (for format/size assertions), orfileas a fallback.- The transformer services authenticate with the API key pair only
(x-api-key / x-client-app-key headers) — no JWT. The one exception in this guide is the Notifications SSE subscribe call, which additionally requires a user JWT (see Step 4).
- Sandbox environment accepts any tenant id; the snippets use
playground_a43b973c77e94b7f8b0e64d96c86edf8.
Concepts
- Two completion models. Audio jobs are polled via
GET /v3/audio/jobs/{id} and downloaded via GET /v3/audio/jobs/{id}/download. Video has no REST poll or download — completion arrives as a Notifications SSE event (VideoJobCompleted / VideoJobFailed, sub_topic = job id) whose payload carries a result_path you read directly from disk. Images are fully synchronous: POST returns the transformed bytes (save with -o), not JSON.
- Field-name dialects differ per service. Audio
trimuses camelCase
inPoint/outPoint (float seconds); video trim uses snake_case in_point/out_point as strings ("00:00:05"); video transcode takes resolution as "1280:720" — never "1080p". Images resize takes mode: "Single" (capitalized) plus width/height.
bitrateis in kbps. Audiotranscodeexpects e.g.192; the service
appends the k when building the FFmpeg argument.
- Errors are RFC 9457 Problem Details. Synchronous image failures return
a application/problem+json body. Audio/video job failures surface in the job's error object (poll) or the SSE VideoJobFailed payload.
- Outputs are transient. Nothing guarantees how long result files or job
records live; treat the output directory as a scratch space and copy results into your own storage promptly.
Step 1 — Prepare test assets
Create a workdir with four small, known-good media files. Everything else in this guide reads from here:
WORKDIR=$(mktemp -d /tmp/g50-media.XXXXXX)
ffmpeg -v error -y -f lavfi -i "sine=frequency=440:duration=8" "${WORKDIR}/tone.wav"
ffmpeg -v error -y -f lavfi -i "testsrc=duration=5:size=640x360:rate=30" \
-pix_fmt yuv420p "${WORKDIR}/clip.mp4"
python3 -c "from PIL import Image; Image.new('RGB', (1200, 800), (40, 110, 60)).save('${WORKDIR}/photo.png')"
ls -la "${WORKDIR}"
test -s "${WORKDIR}/tone.wav" && test -s "${WORKDIR}/clip.mp4" && test -s "${WORKDIR}/photo.png" \
&& echo "assets ready in ${WORKDIR}"Expected — three files: an 8 s WAV tone, a 5 s 640×360 MP4 test pattern, and a 1200×800 PNG.
Step 2 — Transcode audio (async: submit → poll → download)
Submit a transcode job (wav → mp3 at 192 kbps), then poll the job until it completes and download the result:
AUDIO_JOB=$(curl -s -X POST "${AUD_URL}/v3/audio/transcode" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"source_path\": \"${WORKDIR}/tone.wav\", \"target_format\": \"mp3\", \"bitrate\": 192, \"tenant_id\": \"${TENANT_ID}\"}")
echo "${AUDIO_JOB}"
AUDIO_JOB_ID=$(echo "${AUDIO_JOB}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
test -n "${AUDIO_JOB_ID}" && echo "job id: ${AUDIO_JOB_ID}"Expected response — 202 Accepted with a JobResource: {"id":"60OK…","status":"pending","progress":0.0,"filename":"tone.wav",…,"notification_topic":"AudioJobPending",…}.
Poll until status becomes completed (a fresh job usually finishes in a second or two), then download and verify the bytes:
for i in $(seq 1 30); do
STATUS=$(curl -s "${AUD_URL}/v3/audio/jobs/${AUDIO_JOB_ID}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
echo "poll ${i}: ${STATUS}"
[ "${STATUS}" = "completed" ] && break
[ "${STATUS}" = "failed" ] && { echo "job failed"; exit 1; }
sleep 2
done
[ "${STATUS}" = "completed" ] || { echo "job did not complete in time"; exit 1; }
curl -sf -o "${WORKDIR}/tone.mp3" \
"${AUD_URL}/v3/audio/jobs/${AUDIO_JOB_ID}/download" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}"
file "${WORKDIR}/tone.mp3"
file "${WORKDIR}/tone.mp3" | grep -qi "MPEG\|MP3\|Audio" && echo "transcode verified"Expected — status transitions pending → processing → completed; the download is a real MP3 (file reports Audio file with ID3 … MPEG ADTS …). Trimming works the same way with POST /v3/audio/trim and camelCase float seconds: {"source_path", "inPoint": 2.0, "outPoint": 6.0, "tenant_id"}.
Step 3 — Resize and transcode images (sync: bytes come back directly)
Image operations return the transformed file as the response body. Save it with -o, then verify with Pillow. Resize the 1200×800 PNG to a 400-wide thumbnail (JPEG comes back regardless of input format):
curl -s -o "${WORKDIR}/thumb.jpg" -w 'HTTP %{http_code}\n' \
-X POST "${IMG_URL}/v3/images/resize" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"source_path\": \"${WORKDIR}/photo.png\", \"mode\": \"Single\", \"width\": 400, \"height\": 267, \"tenant_id\": \"${TENANT_ID}\"}"
python3 -c "
from PIL import Image
im = Image.open('${WORKDIR}/thumb.jpg')
assert im.format == 'JPEG' and im.size == (400, 267), (im.format, im.size)
print('resize verified:', im.format, im.size)
"Expected — HTTP 200, then resize verified: JPEG (400, 267). mode must be exactly "Single" (lowercase single is rejected), and the output dimensions follow the width/height you send.
Now transcode the same PNG to WebP (output format follows target_format — webp, jpeg, png, …):
curl -s -o "${WORKDIR}/photo.webp" -w 'HTTP %{http_code}\n' \
-X POST "${IMG_URL}/v3/images/transcode" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"source_path\": \"${WORKDIR}/photo.png\", \"target_format\": \"webp\", \"tenant_id\": \"${TENANT_ID}\"}"
python3 -c "
from PIL import Image
im = Image.open('${WORKDIR}/photo.webp')
assert im.format == 'WEBP' and im.size == (1200, 800), (im.format, im.size)
print('transcode verified:', im.format, im.size)
"Expected — HTTP 200, then transcode verified: WEBP (1200, 800). For scaling up (e.g. 2× super-resolution style upscaling), POST /v3/images/upscale takes an integer scale_factor and likewise returns bytes.
Step 4 — Trim a video and wait for the SSE completion event
Video has no REST poll endpoint. The completion signal is a Notifications SSE event on topic VideoJobCompleted (or VideoJobFailed), sub_topic = the job id, whose payload contains result_path. Subscribe before submitting, writing the stream to a file:
USER_TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g50-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g50-viewer\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
SSE_FILE=$(mktemp /tmp/g50-sse.XXXXXX)
curl -s -N -X POST "${NOTIF_URL}/v3/notifications/subscribe/multi" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-d "{\"tenant_id\": \"${TENANT_ID}\", \"subscriptions\": [{\"topic\": \"VideoJobCompleted\"}, {\"topic\": \"VideoJobFailed\"}]}" \
> "${SSE_FILE}" 2>&1 &
SSE_PID=$!
sleep 3
echo "subscribed (pid ${SSE_PID}), stream: ${SSE_FILE}"Expected — the subscribe call opens an SSE stream (a heartbeat/comment line arrives within a few seconds). It requires the key pair and a user JWT — transformers themselves need no JWT, only notifications do.
Now submit the trim job. Video bodies are snake_case and time points are strings ("HH:MM:SS"):
VIDEO_JOB=$(curl -s -X POST "${VID_URL}/v3/video/trim" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"source_path\": \"${WORKDIR}/clip.mp4\", \"mode\": \"segment\", \"in_point\": \"00:00:01\", \"out_point\": \"00:00:03\", \"tenant_id\": \"${TENANT_ID}\"}")
echo "${VIDEO_JOB}"
VIDEO_JOB_ID=$(echo "${VIDEO_JOB}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
test -n "${VIDEO_JOB_ID}" && echo "video job id: ${VIDEO_JOB_ID}"Expected response — 202 Accepted with the same JobResource shape as audio: {"id":"60OK…","status":"pending",…,"notification_topic":"VideoJobPending","notification_sub_topic":"60OK…"}. Note notification_sub_topic repeats the job id — that's your SSE filter.
Wait for the completion event, extract result_path, and verify the file:
RESULT_PATH=""
for i in $(seq 1 45); do
if grep -q "VideoJobCompleted" "${SSE_FILE}" 2>/dev/null; then
RESULT_PATH=$(grep "VideoJobCompleted" "${SSE_FILE}" | head -1 \
| python3 -c '
import json, sys
line = sys.stdin.read()
data = json.loads(line[line.index("data:") + 5:])
payload = json.loads(data["payload"])
assert data["sub_topic"] == "'"${VIDEO_JOB_ID}"'", data["sub_topic"]
print(payload["result_path"])
')
[ -n "${RESULT_PATH}" ] && break
fi
sleep 2
done
kill "${SSE_PID}" 2>/dev/null
test -n "${RESULT_PATH}" || { echo "no completion event received"; exit 1; }
test -s "${RESULT_PATH}" || { echo "result file missing: ${RESULT_PATH}"; exit 1; }
ffprobe -v error -show_entries format=format_name -of csv=p=0 "${RESULT_PATH}"
echo "video trim verified: ${RESULT_PATH}"Expected — within ~30–60 s the SSE stream carries data: {"id":"…","topic":"VideoJobCompleted","sub_topic":"<job id>","payload":"{\"job_id\":\"…\",\"result_path\":\"/…/video-transformer-output/<tenant>/<job>.mp4\"}"}, the file exists on disk, and ffprobe reports mov,mp4,m4a,3gp,3g2,mj2.
Step 5 — Error contract: missing source file
What happens when the input is wrong? For audio, the job is accepted and then fails asynchronously — the failure is in the polled job record, not the submit response:
BAD_JOB=$(curl -s -X POST "${AUD_URL}/v3/audio/transcode" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"source_path\": \"${WORKDIR}/does-not-exist.wav\", \"target_format\": \"mp3\", \"bitrate\": 128, \"tenant_id\": \"${TENANT_ID}\"}")
BAD_JOB_ID=$(echo "${BAD_JOB}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "accepted bad-source job: ${BAD_JOB_ID}"
for i in $(seq 1 15); do
JOB_JSON=$(curl -s "${AUD_URL}/v3/audio/jobs/${BAD_JOB_ID}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}")
echo "${JOB_JSON}" | python3 -c 'import json,sys; print("status:", json.load(sys.stdin)["status"])'
echo "${JOB_JSON}" | grep -q '"status":"failed"' && break
sleep 2
done
echo "${JOB_JSON}" | python3 -c '
import json, sys
job = json.load(sys.stdin)
assert job["status"] == "failed", job["status"]
assert "not found" in json.dumps(job.get("error", {})).lower(), job.get("error")
print("async failure surfaced in job record")
'Expected — the submit returns 202 (the path isn't validated up front); after a poll or two, status flips to failed and the job's error detail explains the source file was not found. For images (synchronous), the same mistake returns 400 immediately with an RFC 9457 application/problem+json body.
Putting it together
A single script that walks all three transformers end to end (assumes WORKDIR assets from Step 1 and USER_TOKEN from Step 4 exist):
# 1. Audio: transcode wav → mp3 192k, poll, download
JOB=$(curl -sf -X POST "${AUD_URL}/v3/audio/transcode" -H "Content-Type: application/json" \
"${KEYS[@]}" -d "{\"source_path\": \"${WORKDIR}/tone.wav\", \"target_format\": \"mp3\", \"bitrate\": 192, \"tenant_id\": \"${TENANT_ID}\"}")
ID=$(echo "$JOB" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
until curl -s "${AUD_URL}/v3/audio/jobs/${ID}" "${KEYS[@]}" | grep -q '"status":"completed"'; do sleep 2; done
curl -sf -o "${WORKDIR}/tone.mp3" "${AUD_URL}/v3/audio/jobs/${ID}/download" "${KEYS[@]}"
# 2. Image: 400x267 thumbnail, bytes returned directly
curl -sf -o "${WORKDIR}/thumb.jpg" -X POST "${IMG_URL}/v3/images/resize" \
-H "Content-Type: application/json" "${KEYS[@]}" \
-d "{\"source_path\": \"${WORKDIR}/photo.png\", \"mode\": \"Single\", \"width\": 400, \"height\": 267, \"tenant_id\": \"${TENANT_ID}\"}"
# 3. Video: trim, completion via SSE, read result_path from disk
curl -sf -N -X POST "${NOTIF_URL}/v3/notifications/subscribe/multi" \
-H "Authorization: Bearer ${USER_TOKEN}" -H "Content-Type: application/json" "${KEYS[@]}" \
-d "{\"tenant_id\": \"${TENANT_ID}\", \"subscriptions\": [{\"topic\": \"VideoJobCompleted\"}]}" \
> /tmp/g50-sse.txt &
curl -sf -X POST "${VID_URL}/v3/video/trim" -H "Content-Type: application/json" "${KEYS[@]}" \
-d "{\"source_path\": \"${WORKDIR}/clip.mp4\", \"mode\": \"segment\", \"in_point\": \"00:00:01\", \"out_point\": \"00:00:03\", \"tenant_id\": \"${TENANT_ID}\"}" \
| python3 -c 'import json,sys; print("video job:", json.load(sys.stdin)["id"])'
until grep -q VideoJobCompleted /tmp/g50-sse.txt 2>/dev/null; do sleep 2; done
grep VideoJobCompleted /tmp/g50-sse.txt | head -1(KEYS[@] is -H "x-api-key: …" -H "x-client-app-key: …"; AUD_URL/IMG_URL/VID_URL/NOTIF_URL are the transformer/notification base URLs — http://127.0.0.1:40162 / :40030 / :40160 / :40013 locally; AUTH_URL is http://127.0.0.1:40004; TENANT_ID is your tenant.)
Try it live
Open the Playground → Media — the Audio / Image / Video Transformer consoles exercise the same operations against the live services.
Copy agent prompt
You are working with the Inaya Labs media transformers. Audio Transformer is
at AUD_URL (:40162), Image Transformer at IMG_URL (:40030), Video
Transformer at VID_URL (:40160), Notifications at NOTIF_URL (:40013), Auth at
AUTH_URL (:40004); the tenant key pair is in INAYA_API_KEY / INAYA_APP_KEY and
TENANT_ID. Transformers authenticate with the key pair ONLY (no JWT), except
the notifications subscribe call which also needs a user JWT from POST
AUTH /v3/auth/qa/login {"device_id","locale":"en-US","tenant_id","username"}.
source_path is always a LOCAL FILESYSTEM PATH on the service host — fabricate
test assets with ffmpeg/Pillow in a fresh workdir first. Steps: (1) Create an
8 s sine WAV, a 5 s 640x360 testsrc MP4, and a 1200x800 PNG. (2) Audio:
POST AUD /v3/audio/transcode {"source_path","target_format":"mp3","bitrate":
192,"tenant_id"} — 202 JobResource; poll GET /v3/audio/jobs/{id} until
"completed"; download GET /v3/audio/jobs/{id}/download and verify with `file`
that it is an MP3. (3) Image: POST IMG /v3/images/resize {"source_path",
"mode":"Single","width":400,"height":267,"tenant_id"} — the RESPONSE BODY IS
THE FILE; save with -o and assert JPEG 400x267 with Pillow. Then transcode to
webp and assert WEBP. (4) Video: FIRST subscribe to SSE POST NOTIF
/v3/notifications/subscribe/multi {"tenant_id","subscriptions":[{"topic":
"VideoJobCompleted"},{"topic":"VideoJobFailed"}]} in the background writing
to a file (needs the JWT), THEN POST VID /v3/video/trim {"source_path",
"mode":"segment","in_point":"00:00:01","out_point":"00:00:03","tenant_id"} —
202 JobResource. Grep the SSE file for VideoJobCompleted with sub_topic equal
to the job id, parse payload.result_path, assert the file exists and ffprobe
reports an mp4. (5) Error contract: submit an audio transcode with a
nonexistent source_path — expect 202, then a polled status "failed" whose
error detail mentions the file not being found; the equivalent image call
fails synchronously with 400 problem+json. Report status codes and byte
counts per step; never log the JWT.API reference
Next journeys
- Scrubbing PII from text (G-51) — clean transcripts and captions before they leave your perimeter.
- Uploading files (G-02) — the upload flow that can place source files on the service host for you.
- Errors, retries & idempotency (G-04) — job polling and retry strategies in detail.