API · Version 1

capWizard headless betreiben.

capWizard ist eine REST-API mit Browser-UI obendrauf. Du kannst alles, was im Studio passiert, auch direkt per HTTP machen — Upload, Transkription, Caption-Render, Download.

https://capwizard.app

Übersicht

Die capWizard-API folgt einem einfachen Request-Response-Muster über HTTPS. Alle Endpoints liefern application/json, außer Datei-Endpoints (Preview-JPG, Result-MP4) — die liefern den jeweiligen Binärtyp.

Der typische Flow ist:

  • Upload in 3 Stufen (init → chunk → finish) — chunked für große Dateien bis 10 GB
  • Transcribe startet einen asynchronen Job — Whisper läuft im Worker
  • Render startet einen zweiten Job, der die Captions ins Video brennt
  • Poll Job-Status, sobald stage="done" ist → Download via /api/result/{id}

Authentication

Aktuell sind die Endpoints öffentlich erreichbar — keine API-Keys, keine OAuth. Das passt zur Self-Service-Idee der Web-App, ist aber für Production-Pipelines ungenügend.

Phase-3-Ankündigung: Vor dem öffentlichen Launch werden Rate-Limiting (pro IP) und API-Keys eingeführt. Bestehende Calls bleiben kompatibel, du musst dann zusätzlich einen Authorization: Bearer <key>-Header schicken. Wenn du jetzt schon dagegen baust: API-Keys gibt's später unter /studio/.

Quickstart — 5 Schritte zum fertigen Video

Vollständiges End-to-End-Beispiel in Python. Setzt requests voraus (pip install requests).

import requests, time, pathlib BASE = "https://capwizard.app" video = pathlib.Path("my_video.mp4") # 1) Upload initialisieren r = requests.post(f"{BASE}/api/upload/init", params={"filename": video.name}).json() upload_id, ext = r["upload_id"], r["ext"] # 2) Chunked Upload (6 MB Blöcke) with video.open("rb") as f: while chunk := f.read(6 * 1024 * 1024): requests.post(f"{BASE}/api/upload/chunk", params={"upload_id": upload_id, "ext": ext}, data=chunk).raise_for_status() # 3) Finish — liefert Metadaten + Preview-URL meta = requests.post(f"{BASE}/api/upload/finish", params={"upload_id": upload_id, "ext": ext}).json() # 4) Transkribieren (asynchron) + Polling job = requests.post(f"{BASE}/api/transcribe", json={"upload_id": upload_id, "target": None}).json() while requests.get(f"{BASE}/api/jobs/{job['job_id']}").json()["stage"] != "done": time.sleep(2.5) words = requests.get(f"{BASE}/api/words/{job['job_id']}").json()["words"] # 5) Render mit Style + Download render = requests.post(f"{BASE}/api/render", json={ "upload_id": upload_id, "words": words, "style": {"mode": "single", "all_caps": True, "font_frac": 0.05}, }).json() while requests.get(f"{BASE}/api/jobs/{render['job_id']}").json()["stage"] != "done": time.sleep(3) pathlib.Path("captioned.mp4").write_bytes( requests.get(f"{BASE}/api/result/{render['job_id']}").content) print("✓ Fertig: captioned.mp4")
const BASE = "https://capwizard.app"; const sleep = ms => new Promise(r => setTimeout(r, ms)); async function caption(file) { // 1) Init const { upload_id, ext } = await fetch( `${BASE}/api/upload/init?filename=${encodeURIComponent(file.name)}`, { method: "POST" } ).then(r => r.json()); // 2) Chunks à 6 MB const CH = 6 * 1024 * 1024; for (let o = 0; o < file.size; o += CH) { await fetch(`${BASE}/api/upload/chunk?upload_id=${upload_id}&ext=${ext}`, { method: "POST", body: file.slice(o, o + CH) }); } // 3) Finish await fetch(`${BASE}/api/upload/finish?upload_id=${upload_id}&ext=${ext}`, { method: "POST" }); // 4) Transcribe + poll const tr = await fetch(`${BASE}/api/transcribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ upload_id, target: null }), }).then(r => r.json()); while ((await fetch(`${BASE}/api/jobs/${tr.job_id}`).then(r => r.json())).stage !== "done") { await sleep(2500); } const { words } = await fetch(`${BASE}/api/words/${tr.job_id}`).then(r => r.json()); // 5) Render + Download const rd = await fetch(`${BASE}/api/render`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ upload_id, words, style: { mode: "single", all_caps: true } }), }).then(r => r.json()); while ((await fetch(`${BASE}/api/jobs/${rd.job_id}`).then(r => r.json())).stage !== "done") { await sleep(3000); } return `${BASE}/api/result/${rd.job_id}`; }
# 1) Init INIT=$(curl -s -X POST "https://capwizard.app/api/upload/init?filename=video.mp4") UID=$(echo $INIT | jq -r .upload_id) EXT=$(echo $INIT | jq -r .ext) # 2) Chunked Upload (split in 6 MB Blöcke) split -b 6M video.mp4 chunk_ for c in chunk_*; do curl -s -X POST "https://capwizard.app/api/upload/chunk?upload_id=$UID&ext=$EXT" \ --data-binary @$c done # 3) Finish curl -s -X POST "https://capwizard.app/api/upload/finish?upload_id=$UID&ext=$EXT" # 4) Transcribe TJOB=$(curl -s -X POST "https://capwizard.app/api/transcribe" \ -H "Content-Type: application/json" \ -d "{\"upload_id\":\"$UID\"}" | jq -r .job_id) while [ "$(curl -s https://capwizard.app/api/jobs/$TJOB | jq -r .stage)" != "done" ]; do sleep 3; done WORDS=$(curl -s "https://capwizard.app/api/words/$TJOB") # 5) Render + Download RJOB=$(curl -s -X POST "https://capwizard.app/api/render" \ -H "Content-Type: application/json" \ -d "{\"upload_id\":\"$UID\",\"words\":$(echo $WORDS | jq .words),\"style\":{\"all_caps\":true}}" | jq -r .job_id) while [ "$(curl -s https://capwizard.app/api/jobs/$RJOB | jq -r .stage)" != "done" ]; do sleep 3; done curl "https://capwizard.app/api/result/$RJOB" -o captioned.mp4
GET/api/health
Liveness-Check. Liefert immer {"ok":true} wenn die API läuft.
200 Response{"ok": true}
GET/api/config
Liefert alle Sprachen, Presets, Schriftarten und Limits. Wird vom Browser-UI beim Start gerufen — du kannst es auch nutzen, um deine eigene Integration mit den selben Defaults aufzusetzen.
200 Response (gekürzt){ "suggested_language": "de", "languages": [ {"code": null, "name": "Wie Video (Originalsprache)"}, {"code": "en", "name": "Englisch"}, {"code": "de", "name": "Deutsch"} ], "presets": [ {"id": "movyng", "name": "Movyng (Ein Wort)", "style": {...}}, {"id": "bold_yellow", "name": "Bold Gelb", "style": {...}} ], "fonts": [{"family": "Montserrat Black", "weight": 900}], "limits": {"max_bytes": 10737418240, "max_videos_per_run": 1, "allowed_ext": [".m4v", ".mkv", ".mov", ".mp4", ".webm"]} }
POST/api/upload/init
Initialisiert einen neuen Upload. Liefert eine upload_id, mit der du danach Chunks streamst.

Query-Parameter

NameTypeBeschreibung
filenamestringOriginal-Dateiname (für Extension-Detection). Default: video.mp4
200 Response{"upload_id": "a8f3...", "ext": ".mp4"}
400 Bad Request{"detail": "Format .xyz nicht unterstützt"}
POST/api/upload/chunk
Streamt einen einzelnen Datenblock an den Upload. Body ist roher Binärinhalt, nicht JSON. Empfohlene Chunk-Größe: 6 MB.

Query-Parameter

NameTypeBeschreibung
upload_idstringrequiredVon /upload/init zurückgegeben
extstringrequiredDatei-Extension, ebenfalls aus init
Der Server hängt jeden Chunk an die existierende Datei. Reihenfolge ist also wichtig. Bei size > 10 GB wird der Upload abgebrochen (HTTP 413).
200 Response{"ok": true, "bytes": 6291456}
POST/api/upload/finish
Schließt den Upload ab. ffprobe validiert das Video, ein Standbild für die Vorschau wird extrahiert. Bei ungültigem Video → 400 + Cleanup.
200 Response{ "upload_id": "a8f3...", "ext": ".mp4", "width": 1080, "height": 1920, "duration": 24.3, "bytes": 18234567, "preview_url": "/api/preview/a8f3...", "suggested_language": "de" }
POST/api/transcribe
Startet einen Transkriptions-Job. Asynchron — du bekommst sofort eine job_id, dann pollst du /api/jobs/{id} bis stage="done".

Request Body (JSON)

FieldTypeBeschreibung
upload_idstringrequiredAus dem Upload-Flow
targetstring | nullnull/leer = Originalsprache (Whisper auto). "en" = Whisper-Translation (exakte Timing-Treue). "de", "es", "fr", "it", "pt" = lokale Übersetzung über CTranslate2 + proportionales Re-Timing.
200 Response{"job_id": "f02c..."}
GET/api/words/{job_id}
Holt das Transkriptions-Ergebnis als Wort-Array mit Timestamps. Erst verfügbar wenn der Transcribe-Job stage="done" ist.
200 Response{ "words": [ {"word": "Hallo,", "start": 0.04, "end": 0.42}, {"word": "Welt!", "start": 0.42, "end": 0.91}, {"word": "Das", "start": 1.02, "end": 1.18} ] }

Du kannst dieses Array editieren (Eigennamen, Fachbegriffe) bevor du es in /api/render schickst.

POST/api/render
Brennt Untertitel ins Video. Asynchron, gleiche Polling-Logik wie bei Transcribe.

Request Body (JSON)

FieldTypeBeschreibung
upload_idstringrequiredAus Upload-Flow
wordsarrayrequiredArray von {word, start, end} — siehe Words-Modell
styleobjectCaption-Stil (Font, Größe, Farbe, …). Siehe Style-Objekt. Default = Movyng-Preset.
targetstring | nullWie bei /api/transcribe — wenn du übersetzen lassen willst.
200 Response{"job_id": "3b91..."}
GET/api/jobs/{job_id}
Polling-Endpoint. Empfohlenes Intervall: 2–3 Sekunden.
200 Response{ "stage": "burning", "percent": 47.3, "t": 1709315712.85 }

Mögliche Werte für stage: siehe Job-Stages.

GET/api/result/{job_id}
Liefert das fertige MP4 als Datei-Download. Content-Type ist video/mp4 mit Content-Disposition: attachment. Filename: capwizard_<jobid>.mp4.
Der Link ist 24 Stunden gültig. Danach wird die Datei automatisch gelöscht. Plane deine Pipeline entsprechend.
GET/api/preview/{upload_id}
Standbild (JPG) aus dem Video bei ca. 1.5 s. Nützlich für UI-Vorschauen vor dem Rendern.
GET/api/video/{upload_id}
Streamt das hochgeladene (noch nicht gerenderte) Original-Video zurück. Praktisch für Frontend-Preview-Players.
POST/api/font/upload
Lädt eine eigene TTF/OTF-Schrift hoch (max 20 MB). Liefert eine font_id, die du im Style-Objekt bei font referenzieren kannst.

Query-Parameter

NameTypeBeschreibung
filenamestringOriginal-Dateiname (für die Family-Detection). Body ist die rohe Schrift-Datei.
200 Response{"family": "Inter", "font_id": "8d2f...", "font_url": "/api/font/8d2f..."}
POST/api/notify/subscribe
Hinterlegt eine E-Mail-Adresse für einen Job. Wenn der Render-Job fertig ist, schickt der Worker eine HTML-Mail mit Download-Link.

Request Body (JSON)

FieldTypeBeschreibung
job_idstringrequiredRender-Job-ID
emailstringrequiredRFC-konforme Adresse, max 254 Zeichen. Control-Chars werden rejected (Mail-Header-Injection-Schutz).
200 Response{"ok": true}

Modell: Style-Objekt

Wird an /api/render übergeben. Alle Größen sind als Anteil der Video-Höhe definiert — damit derselbe Style auf 9:16, 1:1 und 16:9 konsistent sitzt.

FieldTypeBeschreibung
mode"single" | "phrase"Ein Wort = klassisch TikTok-Stil. Phrase = 3 Wörter gleichzeitig, das aktive ist farbig hervorgehoben.
fontstringSchrift-Family (z.B. "Montserrat Black") oder Custom-Font-ID aus font/upload.
font_fracfloatSchriftgröße als Anteil der Höhe (0.05 = 5 %).
boldbooleanBold ja/nein.
all_capsbooleanGROSSBUCHSTABEN.
pos_fracfloatVertikale Position als Anteil der Höhe (0.78 = ca. 78 %, also unteres Drittel).
primaryASS-colorTextfarbe im Format &HAABBGGRR (BGR mit Alpha, ASS-Konvention). &H00FFFFFF = volles Weiß.
effect"outline" | "box"Outline = klassische Kontur. Box = Hintergrund-Rechteck hinter dem Text.
outline_colASS-colorKonturfarbe (nur bei effect="outline").
outline_fracfloatKonturdicke (Anteil der Höhe).
box_colASS-colorBox-Hintergrund (nur bei effect="box"). Alpha kontrolliert die Deckkraft.
shadow_fracfloatSchatten-Offset.
shadow_colASS-colorSchattenfarbe.
shadow_blurbooleanSoften via libass-Blur.
highlight_colASS-colorAktives Wort in Phrase-Mode.
phrase_wordsintegerWörter pro Fenster im Phrase-Mode (2–5, Default 3).

Modell: Words-Format

Array von Objekten mit drei Feldern. Zeiten in Sekunden, float.

[ {"word": "Hallo,", "start": 0.04, "end": 0.42}, {"word": "Welt!", "start": 0.42, "end": 0.91} ]

Du kannst die Reihenfolge oder Inhalte ändern (z.B. Eigennamen korrigieren) — Render baut die Caption-Spur entsprechend.

Modell: Job-Stages

/api/jobs/{id} liefert ein stage-Feld mit folgenden Werten:

StageBeschreibung
queuedJob wartet auf den Worker. Modell-Cold-Start beim ersten Job kann ein paar Sekunden dauern.
extractingAudio wird mit ffmpeg aus dem Video extrahiert.
transcribingWhisper läuft. percent ist 0–95.
translatingEU-Sprachen-Übersetzung läuft (nur wenn target gesetzt und ungleich Quellsprache).
burningffmpeg brennt Captions in das Video ein. percent 0–99.
doneFertig — Result kann abgerufen werden.
failedIrgendwas ging schief. percent: 0.

Limits

LimitWertHinweis
Dateigröße10 GBPro Video. Hard-Limit, danach HTTP 413.
Videos pro Run1Batch-Verarbeitung im Studio-UI, API: pro Job ein Video.
Schrift-Dateigröße20 MBTTF/OTF.
Allowed Extensions.mp4 .mov .m4v .webm .mkvAndere → 400.
Retention24 hAlle Upload-/Result-Dateien werden automatisch gelöscht.
Rate-LimitAktuell ungerated. Phase 3 bringt 5 Jobs/h pro IP.

Fehlercodes

CodeBedeutung
400Ungültiger Request (Format nicht unterstützt, ungültige E-Mail, kaputtes Video, …)
404Resource nicht gefunden (Upload-ID, Job-ID, Result, Schrift)
413Payload too large — Datei > 10 GB oder Schrift > 20 MB
422FastAPI Validation Error — Pydantic-Schema verletzt
500Server-Fehler — unerwartet, bitte Issue melden
503Service-Konfig fehlt (z.B. SMTP nicht konfiguriert bei notify/test)

Fehler-Body folgt FastAPI-Standard: {"detail": "<Klartext>"}.