Subtitles
Egami.Video reads and writes text subtitle files in the Egami.Video.Subtitles namespace: SubRip (.srt), WebVTT (.vtt),
Advanced SubStation Alpha (.ass) and SubStation Alpha v4 (.ssa, read only). It is fully managed, with no rendering: cues are
text with styling and placement, ready to edit, retime, convert or show.
- Reading is lenient, as players are. A malformed line is repaired and reported as
Subtitle.Repaired; aSubtitleReaderOptionswithStrictset throws aCorruptDataExceptioninstead. - WebVTT is parsed exactly as the W3C spec says, and passes the web-platform-tests parsing suite.
- An unchanged file saved in its own format gives back the same bytes. Edited cues are written in a canonical form; the rest of the file stays as it was.
- Converting reports what the target format cannot hold as
Subtitle.Dropped, once per kind of feature.
Loading and saving
SubtitleDocument.Load reads a file, stream or bytes, and Parse reads a string. The format is found from the content
(DetectFormat tells it alone); SubtitleReaderOptions.Format forces one. Save writes a file or stream, and ToText a
string: the SubtitleFormat comes from the argument, then the file's extension, then the document's Format.
SubtitleWriterOptions sets the byte order mark and the line ending; by default they follow the source file when the
format is the same, and are "\n" for WebVTT and "\r\n" for the others.
// SRT, WebVTT, ASS and SSA, told apart by their content. Saving an unchanged file gives back its exact bytes; the format
// comes from the argument, then the extension, then the format it was read as.
SubtitleDocument subtitles = SubtitleDocument.Load("movie.srt");
foreach (SubtitleCue cue in subtitles.Cues)
{
Console.WriteLine($"{cue.Start.ToTimeSpan():g} {cue.PlainText}");
}
subtitles.Save("movie.vtt");
subtitles.Save("movie.ass");
Cues and spans
A document's Cues are SubtitleCue objects: a Start and an End as exact MediaTime values, an Id, and Spans of
text. A SubtitleSpan is a run of text with one look: Bold, Italic, Underline, Strikeout, a Color, a Font and
FontSize, a WebVTT Voice, Classes, Language and Ruby, a karaoke Timestamp, ASS tags Egami does not model in
OtherTags, and IsDrawing for ASS vector drawings. PlainText joins the spans' text; line breaks are "\n" in every format.
Placement is the cue's Alignment, a SubtitleAlignment laid out like a phone keypad (BottomCenter is the default, 2),
and an exact Position (a SubtitlePosition in script pixels, ASS \pos). RawText keeps the cue's text as the file had
it.
The document retimes all cues, karaoke times included, without rounding: Shift adds a time, Scale multiplies by a
Rational (24000/25025 turns 23.976 fps timing into 25 fps), and Sync fixes an offset and a drift together from two
pairs of times. Clip keeps the cues inside a range and cuts the ones across its edges, Sort orders the cues, and
FixOverlaps ends each cue before the next one on the same layer starts. Times are rounded only when written: to
milliseconds for SRT and WebVTT, centiseconds for ASS.
// Cues hold spans of styled text. Times are exact: shifting by a frame or scaling 23.976 to 25 fps keeps the fraction.
subtitles.Cues.Add(new SubtitleCue(MediaTime.FromSeconds(7), MediaTime.FromSeconds(9),
[
new SubtitleSpan("Out "),
new SubtitleSpan("where?") { Bold = true, Color = new Rgba8(255, 255, 0, 255) },
]));
subtitles.Shift(MediaTime.FromSeconds(-0.5));
subtitles.Scale(new Rational(24000, 25025));
subtitles.Clip(MediaTime.Zero, MediaTime.FromSeconds(8));
subtitles.FixOverlaps();
string srt = subtitles.ToText(SubtitleFormat.Srt);
ASS and SSA
ASS documents keep their ScriptInfo lines, Styles (SubtitleStyle: font, colors, outline, shadow, margins and the
rest of a V4+ style line), embedded fonts and graphics as Attachments (SubtitleAttachment, decoded from the file's
uuencoding), and any other section as written. A cue has the event's Style, Actor, Layer, IsComment,
MarginLeft, MarginRight, MarginVertical and Effect.
The override tags \b, \i, \u, \s, \c and \1c, \fn, \fs and \k become span fields, and \an, \a and
\pos cue fields. Every other tag, such as \blur, \fad or \t, stays as written in the span's OtherTags, so an
animated script loaded, retimed and saved still plays the same. SSA files are read the same way, with their legacy
alignment numbers mapped; they are written as ASS.
// ASS keeps its styles, layers, margins and every override tag: the ones Egami understands become fields, the rest stay
// as written in OtherTags, so a script edited here and saved again still animates.
SubtitleDocument sign = SubtitleDocument.Parse(script);
SubtitleCue cue = sign.Cues[0];
Console.WriteLine($"{cue.Style} at {cue.Position}, {sign.Styles[0].FontSize} pt");
foreach (SubtitleSpan span in cue.Spans)
{
Console.WriteLine($"'{span.Text}' color {span.Color} tags {span.OtherTags}");
}
cue.End += MediaTime.FromSeconds(1);
string saved = sign.ToText();
WebVTT
A WebVTT cue's settings are in SubtitleCue.WebVtt, a WebVttCueSettings with the browser VTTCue's meaning: Vertical
(WebVttVertical), Line, SnapToLines and LineAlign (WebVttLineAlign), Position and PositionAlign
(WebVttPositionAlign), Size, Align (WebVttAlign) and Region. The document's Regions are SubtitleRegion
blocks, and its StyleSheets the STYLE blocks' CSS, kept as text. HeaderText is the text after WEBVTT on the first
line.
Cue text keeps voices (<v>), classes (<c.name>), languages (<lang>), ruby and karaoke timestamps. Named character
references use the full HTML table.
// WebVTT is read as the W3C spec says, the way browsers do: cue settings, regions, voices, classes, ruby and karaoke
// timestamps. Writing to another format reports what it cannot keep as Subtitle.Dropped, once per kind.
SubtitleDocument captions = SubtitleDocument.Parse(vtt);
WebVttCueSettings settings = captions.Cues[0].WebVtt!;
Console.WriteLine($"{settings.Region} {settings.Align}; {captions.Regions[0].Lines} lines");
Console.WriteLine($"{captions.Cues[0].Spans[0].Voice}: {captions.Cues[0].PlainText}");
captions.Cues[1].WebVtt = captions.Cues[1].WebVtt! with { Line = 85 };
string ass = captions.ToText(SubtitleFormat.Ass);
Converting between formats
Any document saves to any writable format. Alignment and positions carry across: a WebVTT cue placed with line and
position percentages becomes an ASS \an and \pos, and an ASS alignment becomes WebVTT line and align settings.
SRT keeps bold, italic, underline, colors and fonts as the common <b>, <i>, <u> and <font> tags and alignment as
{\an8}. An ASS style's bold, italics, underline and strikeout are part of its cues' spans, so they show in SRT and WebVTT
as tags; ASS comment events are left out. What a format cannot hold is left out and reported once per kind, such as ASS animation tags in SRT, or colors in
WebVTT.
| Feature | SRT | WebVTT | ASS |
|---|---|---|---|
| Bold, italic, underline | Yes | Yes | Yes |
| Strikeout, colors, fonts | Yes | No | Yes |
| Alignment | {\an} tag |
Settings | Yes |
| Exact position | No | Percentages | \pos |
| Voices, classes, languages, ruby | No | Yes | Actor only |
| Karaoke timing | No | Timestamps | \k |
| Styles, layers, margins, effects, other tags | No | No | Yes |
Subtitle tracks in containers
SubtitleDocument.ReadTrack reads a subtitle track from an IDemuxer, and FromPackets reads one from packets you already
hold, for example while remuxing. IsTextCodec tells which codecs they read. SubtitleTrackWriter turns a document into a
track: its Track goes to the muxer, and its Packets go to the muxer in time order with the other tracks' packets.
CodecIdFor picks the codec for a container:
| Container | Codecs | Notes |
|---|---|---|
| Matroska | CodecIds.SubRip, CodecIds.WebVtt, CodecIds.Ass (and CodecIds.Ssa, read only) |
Each cue is a block with its duration. ASS keeps its header in the track; WebVTT keeps its header, settings and identifiers. |
| WebM | CodecIds.WebMWebVtt |
The identifier, settings and text in each block, as WebM and FFmpeg store them. |
| MP4 | CodecIds.MovText, CodecIds.Mp4WebVtt |
Samples cannot overlap or leave gaps, so the writer adds empty samples and splits overlapping cues. The reader joins them again. |
mov_text keeps bold, italic, underline, colors and font sizes; alignment, positions and strikeout are reported as
Subtitle.Dropped. Tracks written by FFmpeg, MKVToolNix and GPAC MP4Box read back to the cues of their source file, and
those tools read the tracks Egami.Video writes.
The egami-video command keeps subtitle tracks in step when it cuts: trim clips a cue shown across --from or --to to the cut,
concat keeps each clip's cues inside that clip, and timestamps --shift moves cues with the other tracks.
// Read a subtitle track out of a container: mov_text, WebVTT, SRT and ASS tracks become a document.
using (IDemuxer movie = MediaFile.OpenDemuxer("subtitled.mp4"))
{
TrackInfo track = movie.Tracks.First(t => SubtitleDocument.IsTextCodec(t.CodecId));
SubtitleDocument.ReadTrack(movie, track).Save("subtitled.srt");
}
// Add one to a copy of a video: the writer makes the track and its packets, merged in time order with the video's.
using IDemuxer input = MediaFile.OpenDemuxer("movie.webm");
SubtitleTrackWriter subtitles = new(captions, input.Tracks.Count + 1, SubtitleTrackWriter.CodecIdFor("webm", captions.Format));
using (IMuxer output = MediaFile.CreateMuxer("captioned.webm", [.. input.Tracks, subtitles.Track with { Language = "eng" }]))
{
int next = 0;
while (input.ReadPacket() is { } packet)
{
using (packet)
{
for (; next < subtitles.Packets.Count && subtitles.Packets[next].Timestamp <= packet.Timestamp; next++)
{
output.WritePacket(subtitles.Packets[next]);
}
output.WritePacket(packet);
}
}
for (; next < subtitles.Packets.Count; next++)
{
output.WritePacket(subtitles.Packets[next]);
}
}
Encodings and diagnostics
Files are read by their byte order mark (UTF-8, UTF-16 little or big-endian), then as UTF-8 when the bytes are valid UTF-8,
then in SubtitleReaderOptions.Encoding when it is set. Without one, WebVTT is UTF-8 (as its spec says), and other files
are read as UTF-16 when they look like it, or else as Windows-1252, with a Subtitle.Encoding warning. Bytes that are not
valid in a file's own Unicode encoding are read as U+FFFD and reported the same way. Output is always UTF-8. The warnings go to the Diagnostics handler of the MediaContext's Egami context; see
Diagnostics.
// Files without a byte order mark that are not UTF-8 are read with the encoding you name, or else as Windows-1252, with a
// Subtitle.Encoding warning. Output is always UTF-8. Strict turns every repaired line into an exception.
MediaContext context = new() { Egami = EgamiContext.Default with { Diagnostics = d => log.Add($"{d.Id}: {d.Message}") } };
SubtitleDocument old = SubtitleDocument.Load("old.srt", context: context);
SubtitleDocument latin1 = SubtitleDocument.Load("old.srt", new SubtitleReaderOptions { Encoding = Encoding.Latin1 });
Console.WriteLine($"{old.Cues[0].PlainText} / {latin1.Cues[0].PlainText}");
try
{
SubtitleDocument.Parse("1\n00:00:01.000 --> 00:00:02.000\nHi\n", options: new SubtitleReaderOptions { Strict = true });
}
catch (CorruptDataException e)
{
Console.WriteLine(e.Message);
}
Compared with FFmpeg
Converting between SRT, WebVTT and ASS gives the same cues as FFmpeg's conversions, in times, text, bold, italics and underline, on a set of 50 conversions (tools/video/make-subtitle-references.py). Where they differ, FFmpeg loses or changes what the source says:
| Source | FFmpeg | Egami.Video |
|---|---|---|
| A no-break space, written as SRT | ASS's \h code |
The character |
SRT text < 3 >, written as WebVTT |
Copied, so it opens a tag and the text is lost | < 3 > |
| A time past 9:59:59.99, written as ASS | Clipped to 9:59:59.99 | Written as it is, such as 10:00:00.00 |
Reading a file of 20,000 cues and writing it as SRT takes Egami.Video 8 to 12 times less time than FFmpeg on an idle machine (tools/video/time-subtitles.py; FFmpeg's start-up is taken off its time).