English caption rules break down on German. A line cap of 24 characters, or three to five words, looks fine on a 9:16 talking-head when the words are short. It falls apart on Krankenversicherung, Geschäftsbedingungen, or Herausforderung. The line overflows the safe area, or it snaps in the middle of a compound and becomes harder to read than the audio it is supposed to replace.

This is a follow-up to my earlier guide on local auto-captions. That article still holds for the pipeline: extract audio, get word timestamps, write ASS with a live-word highlight, burn the file with FFmpeg. What changes here is the German (and compound-heavy) part: use the medium model, cap lines at about 16–18 characters, handle long compounds without mid-word breaks, and fix names with an initial_prompt plus a small word list.

The same wrapping rules help with Dutch, Finnish, and Turkish. The model choice and the prompt language change; the line math does not.

Why medium is the default for German

faster-whisper's small model is a reasonable laptop default for clean English. On German speech it drops endings, splits compounds in odd places, and invents a plausible name when it is unsure. tiny is fine for a pipeline demo. It is not fine for a name, a company, or a product you will highlight on screen.

medium is slower and heavier, and it is the first size I trust for German short clips. large-v3 is cleaner still, but on a CPU-only laptop it is often not worth the wait for a 30-second Short. On a GPU, medium with float16 is a comfortable default; on CPU, use int8.

from faster_whisper import WhisperModel # CPU: model = WhisperModel("medium", device="auto", compute_type="int8") # GPU alternative: WhisperModel("medium", device="cuda", compute_type="float16") 

Set language="de" (or nl, fi, tr) yourself. Auto-detect on a 20-second clip can flip after one English loanword or a show title.

Step 1: extract a 16 kHz mono track

Whisper does not need the stereo mix. Caption after you already have the 20–60 second clip, so the word times match the file you will publish.

ffmpeg -y -i clip.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le audio.wav 

If the clip is not 9:16 yet, scale or crop first, then caption, so the ASS PlayResX / PlayResY match the frame you burn into:

ffmpeg -y -i source.mp4 \ -vf "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920" \ -c:a copy clip.mp4 

That crop is centre-weighted. If the speaker sits off-centre, adjust the crop or frame the face first; do not caption a 1920×1080 source with a 1080×1920 ASS style and hope the text lands in the right place.

Step 2: transcribe with initial_prompt and a word list

initial_prompt is previous-text context, not an instruction. Write it as a short German sentence that already contains the names and rare words, spelled the way they should appear. Keep it short. A prompt that looks like a brief (“transcribe accurately, spell all names correctly”) is ignored or, worse, leaks into the transcript.

from faster_whisper import WhisperModel model = WhisperModel("medium", device="auto", compute_type="int8") PROMPT = ( "Gespräch mit Leonie Schneider und Dr. Markus Breuer " "von Kelvaro über die Krankenversicherung." ) segments, info = model.transcribe( "audio.wav", language="de", word_timestamps=True, beam_size=5, vad_filter=False, initial_prompt=PROMPT, condition_on_previous_text=False, ) words = [] for seg in segments: for w in seg.words: token = (w.word or "").strip() if token: words.append({"start": w.start, "end": w.end, "text": token}) 

condition_on_previous_text=False stops later windows from copying the prompt or an early error. On a 30-second clip you often have only one window anyway; it still costs nothing.

The prompt will not catch every typo. Apply a small word list afterwards, before you wrap lines. Fix tokens first, then two-word names, because Whisper often splits a first and last name:

TOKEN_MAP = { "leoni": "Leonie", "kelvaro": "Kelvaro", "mueller": "Müller", "müller": "Müller", } PHRASES = [ ("leonie schneider", "Leonie Schneider"), ("markus breuer", "Markus Breuer"), ] def apply_word_list(words, token_map=TOKEN_MAP, phrases=PHRASES): for w in words: key = w["text"].lower().strip(".,!?") if key in token_map: w["text"] = token_map[key] i = 0 while i < len(words) - 1: pair = f"{words[i]['text']} {words[i + 1]['text']}".lower() for src, dst in phrases: if pair == src: left, right = dst.split(" ", 1) words[i]["text"] = left words[i + 1]["text"] = right break i += 1 return words words = apply_word_list(words) 

Do not put generic words like schneider in TOKEN_MAP on their own if the episode might also use the common noun. Prefer the two-word phrase.

Step 3: wrap at 16–18 characters, without tearing compounds

On a 1080×1920 frame, with a bold ~72 px font and side margins around 80 px, 16–18 characters is a safer cap than 24. Character count is not pixel width (M is wider than i), so treat 16–18 as a starting point and watch one file. Two lines at most, lower third, current word highlighted.

Three rules that matter more than the number:

  1. If a single word is longer than the cap, it gets its own line. Do not split it in the middle of a syllable.
  2. Never leave a short function word (der, die, das, ein, eine, und, oder, in, im, am, zu, auf) alone at the end of a line. Take it with the next word, even if that slightly overruns the cap, or hyphenate the next compound so both lines stay short.
  3. Hyphenate a long compound only at a real morpheme: Kranken- / versicherung, not Krankenvs- / icherung.

A compact wrapper plus a conservative hyphenator:

MAX = 18 FUNCTION = { "der", "die", "das", "den", "dem", "des", "ein", "eine", "einer", "einem", "einen", "und", "oder", "aber", "weil", "in", "im", "am", "an", "zu", "auf", "von", "mit", } # Try these suffixes first; they cover a lot of German caption overflows. SUFFIXES = ( "versicherung", "bedingungen", "verhältnisse", "möglichkeiten", "gesellschaft", "entwicklung", "verwaltung", "einrichtung", "fähigkeit", "verfahren", "führung", ) def hyphenate(token, max_len=MAX): """Split once, only if the word itself exceeds the cap.""" raw = token.strip() if len(raw) <= max_len: return [raw] lower = raw.lower() for suf in SUFFIXES: if lower.endswith(suf) and len(raw) - len(suf) >= 4: cut = len(raw) - len(suf) return [raw[:cut] + "-", raw[cut:]] return [raw] # still too long: keep it whole, one line def wrap_words(words, max_len=MAX): units = [] for w in words: parts = hyphenate(w["text"], max_len) if len(parts) == 1: units.append({**w, "text": parts[0]}) else: mid = (w["start"] + w["end"]) / 2 units.append({**w, "end": mid, "text": parts[0]}) units.append({**w, "start": mid, "text": parts[1]}) lines, current, cur_len = [], [], 0 for unit in units: token = unit["text"] extra = (1 if current else 0) + len(token) if current and cur_len + extra > max_len: last = current[-1]["text"].lower().rstrip("-") if last in FUNCTION: overflow = current.pop() lines.append(current) current = [overflow, unit] cur_len = len(overflow["text"]) + 1 + len(token) else: lines.append(current) current = [unit] cur_len = len(token) else: current.append(unit) cur_len += extra if current: lines.append(current) return lines 

Krankenversicherung (20 characters) becomes Kranken- and versicherung. die Krankenversicherung does not leave die hanging on the previous line. A name that happens to be long stays in one piece, because it will not match a suffix.

Dutch and Finnish need their own suffix lists. The wrap-and-don't-abandon-a-function-word logic stays the same.

Step 4: write ASS and burn it in

ASS timestamps are H:MM:SS.cc (centiseconds). Colours are &HBBGGRR&. Keep the whole line visible in every event; only the active word gets the colour override. Escape {, }, and backslashes in spoken text.

def ass_time(seconds): cs = int(round(max(0.0, seconds) * 100)) h, cs = divmod(cs, 360000) m, cs = divmod(cs, 6000) s, cs = divmod(cs, 100) return f"{h}:{m:02d}:{s:02d}.{cs:02d}" def escape_ass(text): return text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}") def highlight(line_words, active): parts = [] for i, w in enumerate(line_words): token = escape_ass(w["text"]) if i == active: parts.append(r"{\c&H00E6C600&}" + token + r"{\c&H00FFFFFF&}") else: parts.append(token) return " ".join(parts) def write_ass(path, lines): header = """[Script Info] ScriptType: v4.00+ PlayResX: 1080 PlayResY: 1920 WrapStyle: 0 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Word,Poppins ExtraBold,72,&H00FFFFFF,&H00000000,&H00101010,&H80000000,-1,0,0,0,100,100,0,0,1,6,0,2,80,80,280,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ events = [] for line in lines: for i, w in enumerate(line): events.append( f"Dialogue: 0,{ass_time(w['start'])},{ass_time(w['end'])}," f"Word,,0,0,0,,{highlight(line, i)}" ) path.write_text(header + "\n".join(events) + "\n", encoding="utf-8") from pathlib import Path write_ass(Path("captions.ass"), wrap_words(words)) 

Burn the file with the ass filter, and point fontsdir at a folder that actually contains the font you named in the style. A missing font fails silently and shifts the layout.

ffmpeg -y -i clip.mp4 -vf "ass=captions.ass:fontsdir=./fonts" -c:a copy captioned.mp4 

MarginV=280 on a 1920-tall frame lifts the block above typical Shorts or TikTok chrome. Check it against a screenshot of the platform UI, not against an empty preview.

A short watch-through

Play the file once muted, then once with sound.

  • Names, companies, and product words: if they are still wrong, extend the word list and re-render. Do not paint over a burned-in typo in a timeline.
  • A compound that still overflows: add its suffix to SUFFIXES, or drop the font size for that event.
  • A line that starts mid-breath: nudge the first word's start forward rather than flashing text on a pause.
  • Only caption recordings you or your client have the rights to. A local model does not change copyright.

This still only captions a clip you already have. It does not pick the hook, drop silences, or keep a face in a 9:16 crop. Get those earlier stages right first; a wrong German name, highlighted, is more visible than a slightly late cut.

If you want a one-page checklist for whether a clip is worth captioning at all, plus copy-paste FFmpeg commands for the cut, 9:16, loudness and subtitles, there is a free German PDF here: https://whop.com/kelvaro-creator-kits/gratis-clip-checkliste/

The full local pipeline (silence removal, face framing, word-level captions including German, hook titles) is the Auto-Clipping-System, 29 € once, no subscription, Windows as the tested baseline. Launch offer: code LAUNCH40 = 40 % off, for the first 10 buyers only, until 16 November 2026: https://whop.com/kelvaro-creator-kits/auto-clipping-system

Disclosure: the paid kit is my own product. The checklist is free. No guarantee of views or revenue.