video_helper.subtitles module

Subtitles: one timed-text format layer, read and written in one place.

Why this lives here

Subtitles are a video concern. This package already burned them into frames (video_helper.burn_subtitles()) and already converted SubRip to WebVTT (video_helper.srt2vtt()), while the reading and writing of the formats themselves sat in sprezzature-audio, which had consolidated four writers and two readers of its own. That consolidation was right and its differences are preserved below; what was wrong was the address. A caption file is what a player shows over a picture, so the package that knows players owns the format, and the speech packages ask it rather than each keeping a writer.

What came with the move, and what did not

The format layer came: cues, timestamps, the reader, the WebVTT and SubRip writers. The narration bookkeeping did not. Counting samples while an engine concatenates audio is a fact about synthesis, not about subtitles, so Timeline and SpeechCue stay where the voices are.

The differences the earlier consolidation kept are kept here too, because each one exists for a reason:

  • Voice tags. A diarized caption track emits <v Name> once speakers are attributed. A translated track deliberately does not: W3C reserves voice tags for the same-language captions track, and a translated subtitle is dialogue. Hence voices=False rather than a guess from whether a speaker happens to be set.

  • Identifiers. The narration path writes each cue’s segment index as the WebVTT identifier, the line before the timings, which is what lets a page highlight the right block without matching on text. Nothing else needs them, so an unset identifier emits nothing.

  • Line structure. parse() keeps a cue’s newlines, and so does write_vtt(). They are what a renderer breaks lines on and what a line-width audit counts, and an older reader that glued them with a space could do neither: preserving them took 393 audit findings to zero across the 22 subtitle tracks of the suite’s films. A caller that wants one line asks for it with Cue.collapsed() – which is the narration path, where a cue is matched to a page element by its identifier and a blank line in the payload would become a second cue and shift every index after it.

    The two callers want opposite things and both are right, so the writer preserves what it is given and flattening is an explicit request. The decision belongs to the caller, never to the writer, and test_one_writer_serves_both_callers_and_neither_wins pins it.

Stdlib plus os-helper for logging: no ffmpeg, no OpenCV, nothing that makes importing a caption writer expensive.

Author

Warith HARCHAOUI, Ph.D.

video_helper.subtitles.ABBREVIATIONS: frozenset[str] = frozenset({'al', 'approx', 'art', 'av', 'bd', 'cf', 'chap', 'co', 'dept', 'dr', 'e.g', 'env', 'est', 'etc', 'ex', 'fig', 'i.e', 'inc', 'jr', 'ltd', 'm', 'max', 'min', 'mlle', 'mm', 'mme', 'mr', 'mrs', 'ms', 'no', 'p', 'pers', 'ph.d', 'pp', 'pr', 'prof', 'réf', 'sg', 'sr', 'st', 'ste', 'tél', 'vol', 'vs', 'éd'})

Tokens that end in a full stop without ending a sentence. Splitting on . alone turns “Dr. Rosling said so.” into two sentences and a highlight jumps mid-phrase, which is worse than not highlighting at all.

Bilingual on purpose, and the French list is not a translation of the English one: M. is an abbreviation in French and a sentence end in English (”…the M.”). Compared case-insensitively, matched on the token before the stop.

class video_helper.subtitles.Cue(start, end, text, speaker=None, identifier=None)[source]

Bases: object

One timed piece of text.

Parameters:
start, end

Seconds from the beginning of the media.

Type:

float

text

The caption. Newlines are significant: a renderer breaks lines where they are, and a line-width audit counts them.

Type:

str

speaker

Who is speaking, when that has been worked out. Written as a <v Name> tag only when the caller asks for voices.

Type:

str or None

identifier

The optional WebVTT cue identifier, written on the line before the timings. The narration path uses it to carry a segment index, so a page can follow along without matching on text.

Type:

str or None

collapsed()[source]

The same cue with its text on one line.

A blank line ends a cue, so a hard-wrapped payload would split one cue into two and shift every identifier after it. Callers that assemble text from a document rather than from a transcript want this before writing.

Return type:

Cue

property duration: float

Length of the cue in seconds.

end: float
identifier: str | None = None
property lines: list[str]

The cue’s lines, as a renderer will break them.

speaker: str | None = None
start: float
text: str
video_helper.subtitles.FORMAT_FOR_SUFFIX: dict[str, str] = {'.srt': 'srt', '.vtt': 'vtt', '.webvtt': 'vtt'}

Which writer an output extension asks for. The extension is the request: a caller naming captions.srt has already said what it wants, and making it repeat itself in a second argument is how the two come apart.

video_helper.subtitles.VOICE_CUE = '<v {name}>{text}'

How a voice tag is written back out.

video_helper.subtitles.VOICE_RE = re.compile('^<v\\s+([^>]+)>\\s*')

<v Ada Lovelace>Hello.

Type:

A voice tag introducing a speaker

video_helper.subtitles.WRITABLE_FORMATS: frozenset[str] = frozenset({'srt', 'vtt'})

Every format this module writes. burn_subtitles accepts two more, .ass and .ssa, which it reads and never writes.

video_helper.subtitles.format_timestamp(seconds, *, srt=False)[source]

Format seconds as HH:MM:SS.mmm (WebVTT) or HH:MM:SS,mmm (SubRip).

Parameters:
  • seconds (float) – Offset from the start of the media. Negative input clamps to zero: a negative timestamp is never meaningful and a renderer’s behaviour on one is undefined rather than obvious.

  • srt (bool, optional) – Use SubRip’s comma instead of WebVTT’s full stop. The separator is the only difference between the two spellings.

Returns:

The timestamp.

Return type:

str

Examples

>>> format_timestamp(61.34)
'00:01:01.340'
>>> format_timestamp(61.34, srt=True)
'00:01:01,340'
video_helper.subtitles.from_centiseconds(value)[source]

Convert a whisper.cpp timestamp (10-ms units) to seconds.

Parameters:

value (int)

Return type:

float

video_helper.subtitles.parse(text)[source]

Read a WebVTT or SubRip document into cues.

Both formats are accepted because this module writes both, and a reader holding one has no reason to convert it before doing anything with it.

Parameters:

text (str) – The document’s contents.

Returns:

Cues in time order.

Return type:

list of Cue

Notes

A <v Name> tag is lifted into Cue.speaker rather than left in the text, so a caller that re-writes the cue does not double-tag it, and one that does not want voices simply never asks for them.

Newlines inside a cue are preserved. Callers that want a single line call Cue.collapsed().

A byte-order mark and CRLF endings are tolerated. Both arrive routinely from Windows subtitle tools, and both used to land inside the first cue’s identifier, where they are invisible in a diff and fatal to a parser.

video_helper.subtitles.split_sentences(text)[source]

Split a block of prose into sentences.

Parameters:

text (str) – One block’s text, already cleaned of markup.

Returns:

One entry per sentence, whitespace-collapsed, never empty strings. Text with no terminator at all comes back as a single sentence.

Return type:

list of str

Notes

Deliberately a small rule set rather than a model: it has to give the same answer for a page and for its audio, run with no dependency, and be inspectable when it gets one wrong. It handles terminators with their trailing quotes, ellipses, decimals, initials, and the abbreviations in ABBREVIATIONS for English and French.

Sentence granularity is the unit a read-along is built on: a paragraph is too coarse to follow with the eye, and a word would need forced alignment against audio the pipeline generated itself, a statistical guess at something already known exactly.

Examples

>>> split_sentences("One. Two! Three?")
['One.', 'Two!', 'Three?']
>>> split_sentences("Dr. Rosling said so. Then he left.")
['Dr. Rosling said so.', 'Then he left.']
>>> split_sentences("It cost 3.14 euros. Exactly.")
['It cost 3.14 euros.', 'Exactly.']
>>> split_sentences("Released as v2.5 today.")
['Released as v2.5 today.']
>>> split_sentences("No terminator here")
['No terminator here']
video_helper.subtitles.vtt2srt(vtt_file_path, srt_file_path=None)[source]

Convert a WebVTT file to SubRip.

Parameters:
  • vtt_file_path (str) – Path to the input .vtt file.

  • srt_file_path (str, optional) – Path to the output .srt file. Defaults to <vtt_stem>.srt next to the input.

Returns:

Path to the file written.

Return type:

str

Notes

The other direction, video_helper.srt2vtt(), is not this function’s mirror and the asymmetry is deliberate: it also lifts <font color> tags into cue classes and writes the stylesheet that binds them, which has no counterpart going the other way because SubRip carries its colours inline. Going this way, a voice tag becomes the Name: prefix SubRip players render, and a WebVTT identifier is replaced by SubRip’s own numbering, which is what the format allows.

Why it exists at all: every platform that takes a caption upload takes SubRip, and plenty still do not take WebVTT. A package that could only convert towards WebVTT could not deliver a caption file to the places films actually go.

Examples

>>> vtt2srt("captions.vtt")
'captions.srt'
video_helper.subtitles.write(cues, output_path=None, *, subtitle_format='', voices=True)[source]

Render cues in whichever format was asked for, and write them if asked.

Parameters:
  • cues (list of Cue) – Timed text.

  • output_path (str or pathlib.Path or None, optional) – Where to write. The extension picks the format (.vtt, .srt). None renders without writing, and then subtitle_format has to say which format.

  • subtitle_format (str, optional) – "vtt" or "srt", overriding what the extension says. Needed only when there is no path, or when a caller insists on an extension that disagrees with its contents.

  • voices (bool, optional) – Forwarded to write_vtt(). SubRip has no voice tags, so it is ignored there rather than silently changing the output.

Returns:

The rendered document, whether or not it was written.

Return type:

str

Raises:

ValueError – If no format can be determined, or the one named is not written here.

Examples

>>> cues = [Cue(start=0.0, end=1.5, text="Bonjour.")]
>>> write(cues, subtitle_format="srt").splitlines()[0]
'1'
video_helper.subtitles.write_srt(cues)[source]

Render cues as a SubRip document.

Parameters:

cues (list of Cue) – Timed text. Cues with no text are dropped, as in write_vtt().

Returns:

A SubRip document, ready to write to a .srt file.

Return type:

str

Notes

SubRip has no voice tags, so a speaker is written as a plain prefix: the convention every player renders and no player hides. Cues are numbered from one, which is SubRip’s identifier, so a WebVTT identifier carrying a segment index does not survive the trip. That is the format’s limit and not a choice made here.

video_helper.subtitles.write_vtt(cues, *, voices=True)[source]

Render cues as a WebVTT document.

Parameters:
  • cues (list of Cue) – Timed text. Cues with no text are dropped: a timed blank box is on screen for its full duration and says nothing.

  • voices (bool, optional) – Write <v Name> tags for cues that carry a speaker. Default True. Pass False for a translated subtitle track: W3C reserves voice tags for the same-language captions track.

Returns:

A WebVTT document, ready to write to a .vtt file.

Return type:

str