DubAnyVideo

Developer blog

Generate Subtitles and Transcripts From Any Video

The first three tutorials in this series were all about dubbing: one video in ten
lines
, a whole batch of
languages
, and a button inside your own
product
. This one is about the output people
forget the pipeline already produces: the words. Subtitles, captions, a plain
speaker-labelled transcript. Sometimes you want the text on screen, not a new voice
over the top, and that path is cheaper.

The problem

Text under a video is not a nice-to-have. Most of your feed autoplays on mute, so
captions are the difference between someone watching and someone scrolling past.
Screen-reader users and anyone in a noisy room need them. Search engines index the
transcript, not the pixels. And a viewer who reads your language better than they
hear it will follow along with subtitles when they would give up on audio.

Building that yourself is the same stack as dubbing minus the last step: run ASR,
line the words up against the audio, figure out who is speaking, and write it all
out as timed cues. You do all of that work and still do not have a dub. If all you
need is the text, paying for the full voice-over is overkill.

What you'll build

A script that takes a video URL and gets back an .srt subtitle file: timed,
speaker-labelled, in the source language. No models, no alignment code, one POST and
the same poll loop from tutorial #1. Then a second thing you get for free: any dub
job you already run also hands you subtitles in both languages, so you may not need
a separate call at all.

Two ways to get subtitles

The pipeline writes subtitles in two situations, and which one you want depends on
whether you also need the dub.

  • Transcribe only. POST /api/v1/transcribe runs the speech-to-text half on its
    own: no translation, no voice-over. You get a source-language SRT, billed at half
    the per-minute rate a dub costs. Reach for this when you want captions in the
    original language and nothing else.
  • Subtitles as a byproduct of a dub. Every finished dub job carries four
    downloads, not just the video. Two of them are subtitle files: translationSrtUrl
    (captions in the language you dubbed into) and transcriptSrtUrl (the original
    source transcript). If you are already dubbing the video, the subtitles are sitting
    there in the same response for no extra minutes.

The rest of this post covers the transcribe path first, then how to pull the two
SRTs out of a dub you already have.

Step 1: transcribe the video

Same shape as a dub, minus the target language. Transcribe does not translate, so
there is no target_lang here: you are asking for the words that are already spoken.

curl -s -X POST 'https://dubanyvideo.com/api/v1/transcribe' \
  -H "Authorization: Bearer $DV_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "source_type": "youtube", "input_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }'
# → 201 { "id": 7100, "state": "queued" }

You get back the same { id, state } a dub gives you. Grab the id and poll it
exactly like any other job. source_type also accepts "url" for a direct file or
an HLS playlist, not just YouTube.

Step 2: poll, then download the SRT

No webhooks, so you poll GET /api/v1/jobs/{id} on an interval until state is
done. Same gotcha as always: a failed job comes back as a 200 with
state: "failed" and an errorMessage, not an HTTP error, so branch on state.

For a transcribe job the SRT is the main output. It shows up in two places in the
status object, and both point at the same file:

  • archiveUrl is the primary download for the job, which for a transcribe job is
    the SRT itself.
  • artifacts.transcriptSrtUrl is the same file under a stable named key, if you
    prefer to read artifacts by name.
// GET /api/v1/jobs/7100 once it's done
{
  "id": 7100,
  "mode": "transcribe",
  "state": "done",
  "archiveUrl": "https://…/transcript.srt",
  "artifacts": {
    "dubbedVideoUrl": null,
    "dubbedAudioUrl": null,
    "translationSrtUrl": null,
    "transcriptSrtUrl": "https://…/transcript.srt"
  }
}

The other three artifact keys are null on a transcribe job. That is on purpose:
the four keys are always present so you can rely on the shape, and they fill in only
when the artifact exists. A dub fills all four; a transcribe fills one.

Full code

Set DV_API_KEY in your environment. Each version transcribes, polls, and writes a
.srt next to the script.

Node (18+, global fetch)

// Node 18+ (global fetch). Set DV_API_KEY in your environment.
import { writeFile } from 'node:fs/promises'

const KEY = process.env.DV_API_KEY
const base = 'https://dubanyvideo.com/api/v1'
const headers = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

const INPUT_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'

// 1. Start a transcribe job (no target language): 201 { id, state }
const { id } = await fetch(`${base}/transcribe`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ source_type: 'youtube', input_url: INPUT_URL }),
}).then((r) => r.json())

// 2. Poll until it's done.
let job
do {
  await sleep(5000)
  job = await fetch(`${base}/jobs/${id}`, { headers }).then((r) => r.json())
  console.log(job.state + (job.stage ? `${job.stage}` : ''))
} while (job.state !== 'done' && job.state !== 'failed')

if (job.state === 'failed') throw new Error(job.errorMessage)

// 3. Download the SRT (archiveUrl, or artifacts.transcriptSrtUrl — same file).
const srt = await fetch(job.artifacts.transcriptSrtUrl).then((r) => r.arrayBuffer())
await writeFile('transcript.srt', Buffer.from(srt))
console.log('saved transcript.srt')

Python (pip install requests)

# pip install requests. Set DV_API_KEY in your environment.
import os, time, requests

base = "https://dubanyvideo.com/api/v1"
headers = {"Authorization": f"Bearer {os.environ['DV_API_KEY']}"}

INPUT_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"

# 1. Start a transcribe job (no target language): 201 { "id", "state" }
job_id = requests.post(f"{base}/transcribe", headers=headers, json={
    "source_type": "youtube",
    "input_url": INPUT_URL,
}).json()["id"]

# 2. Poll until it's done.
while True:
    job = requests.get(f"{base}/jobs/{job_id}", headers=headers).json()
    print(job["state"])
    if job["state"] == "done":
        break
    if job["state"] == "failed":
        raise RuntimeError(job["errorMessage"])
    time.sleep(5)

# 3. Download the SRT (archiveUrl, or artifacts.transcriptSrtUrl — same file).
srt = requests.get(job["artifacts"]["transcriptSrtUrl"]).content
with open("transcript.srt", "wb") as f:
    f.write(srt)
print("saved transcript.srt")

Subtitles from a dub you already ran

If the video is getting dubbed anyway, you do not need a second call. A finished dub
job carries subtitles in both languages already. Point at a done dub job and read
its artifacts:

// GET /api/v1/jobs/{id} for a done DUB job (dubbed en → es)
{
  "state": "done",
  "targetLang": "es",
  "archiveUrl": "https://…/dubbed.mp4",
  "artifacts": {
    "dubbedVideoUrl": "https://…/dubbed.mp4",
    "dubbedAudioUrl": "https://…/dubbed.mp3",
    "translationSrtUrl": "https://…/es.srt",   // captions in the dubbed language
    "transcriptSrtUrl": "https://…/en.srt"     // the original source transcript
  }
}

So one dub gives you the dubbed video, the dubbed audio track, target-language
subtitles, and the source transcript in a single job. Ship the .srt alongside the
video and viewers can read along in either language.

What the SRT looks like, and where it goes

It is a standard SubRip (.srt) file: numbered cues, start and end timings, and
speaker labels on the lines. That format drops straight into VLC, Premiere, DaVinci
Resolve, a YouTube caption upload, or an ffmpeg burn-in.

One thing to know if you are putting captions on an HTML5 <video>: the browser
<track> element wants WebVTT, not SRT. The two are nearly identical. Converting is
a header line and a punctuation swap: add WEBVTT as the first line and change the
, before the milliseconds in each timestamp to a .. Any subtitle library does it
in one call, or you can do it with a two-line replace.

Cost & limits

  • Transcribe is billed from the same balance as everything else, at half the
    per-minute rate of a dub. A 10-minute video transcribed costs 5 minutes.
  • Subtitles that come out of a dub cost nothing extra. You already paid the dub's
    minutes; the two SRTs are part of that job.
  • Want the number before you commit? POST /api/v1/estimate is free, creates no
    job, and returns the source duration. Halve it for the transcribe cost. To check
    your remaining balance, GET /api/v1/me/usage returns your plan, monthly quota,
    minutes used, and top-up balance.
  • Max source length is 30 minutes per job, and input is by link only (YouTube, HLS,
    or direct URL).
  • Rate limits: 60 requests/min per key. New submissions are capped per minute by plan
    (free 2 · starter 5 · medium 10 · super 20).
  • Prefer pay-per-job over a subscription? The same engine is on the
    RapidAPI marketplace, billed per job.

Get your key

Grab a key and pull the text out of your next video:
dubanyvideo.com/api. That closes the loop on the pipeline: dub it,
localize it into a batch, put a button on it, and caption it, all from the same key.