Data formats

Subtitles

SRT and WebVTT subtitles: cues read, shifted or stretched back in sync, converted either way, the cue at a moment, and the words as a transcript.

Version 1.0.0 · 9 functions · 0 crates

#video#accessibility#text#time

Functions

What use subtitles.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use subtitles_parse, subtitles_read_file from subtitles (Quillo 0.6).

subtitles_parse(text: str) → listor an error

The cues of SRT or WebVTT text (told apart by the WEBVTT line): [{ index, id, start, end, start_ms, end_ms, settings, text, plain }], times as "00:01:02.500" and in milliseconds, settings WebVTT's ("align:start"...), plain the text without <i>/{\an8} tags. NOTE, STYLE and REGION blocks are skipped.

subtitles_read_file(path: str) → listor an error

subtitles_parse of a .srt or .vtt file.

subtitles_shift(text: str, ms: int) → stror an error

The same subtitles, every cue ms milliseconds later (negative: earlier), in the format they came in. Cues that would end before 0 are dropped, and one cut by 0 starts at 0.

subtitles_resync(text: str, first_ms: int, last_ms: int) → stror an error

Brings subtitles that drift back in sync from two points: the first cue to start at first_ms and the last at last_ms, every time between stretched to match (a file made for 25 frames a second on a 23.976 video, or a cut ad break). Same format out as in.

subtitles_to_vtt(text: str) → stror an error

SRT or WebVTT text as WebVTT, for the <track> element of a web page (cue settings kept).

subtitles_to_srt(text: str) → stror an error

SRT or WebVTT text as SRT, numbered from 1 (WebVTT's settings and NOTEs left out), for video editors and players.

subtitles_write(cues: list, format: str) → stror an error

Subtitles written from cues, each { start_ms, end_ms, text } (or start/end as "00:01:02.500"), in format "srt" or "vtt": captions from a transcript, a speech-to-text service or a database.

subtitles_at(text: str, ms: int) → listor an error

The cues showing at ms milliseconds into the video, as subtitles_parse gives them (usually one, none in a pause).

subtitles_text(text: str) → stror an error

The words alone, for a transcript or a search index: each cue's plain text, lines joined by spaces, one cue a line.

Get it

$ quillo install subtitles               # subtitles.qwer into this folder$ quillo install subtitles --example     # and its example, subtitles.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/subtitles.qwer$ curl -O https://quillolang.org/plugins/files/subtitles.qwe$ curl -O https://quillolang.org/plugins/files/subtitles_sample.srt

Then, in your program, all of it or only what you call:

main.qwe
use subtitles.qweruse subtitles_parse, subtitles_read_file from subtitles      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

subtitles.qwe
// A bakery video's captions: read, put back in sync, and served as WebVTT for the page's <track>.////   quillo run subtitles.qwe      then open http://localhost:8080/cuesuse "subtitles.qwer"let SRT = read_file("subtitles_sample.srt")route GET /cues = subtitles_parse(SRT)? | map((c) => { start: c.start, end: c.end, plain: c.plain })// The captions came out 1.5 s early: shifted, and as WebVTT for the browser.route GET /captions.vtt = respond(200, subtitles_to_vtt(subtitles_shift(SRT, 1500)?)?, { "content-type": "text/vtt; charset=utf-8" })route GET /sync {    let fixed = subtitles_resync(SRT, 2000, 140000)?    let cues = subtitles_parse(fixed)?    {        first: cues[0].start,        last: cues[4].start,        earlier: subtitles_shift(SRT, -3000)? | subtitles_parse | map((c) => c.start),        at_8s: subtitles_at(SRT, 8000)? | map((c) => c.plain),        transcript: subtitles_text(SRT)?,        written: subtitles_write([{ start_ms: 0, end_ms: 1200, text: "Hello" }, { start: "00:00:01.500", end: "00:00:03.000", text: "Bonjour" }], "srt")?,    }}
$ quillo run subtitles.qwe$ curl 'localhost:8080/cues'$ curl 'localhost:8080/captions.vtt'$ curl 'localhost:8080/sync'

Source

subtitles.qwer
//! Subtitles in SRT and WebVTT: cues read, shifted or stretched back in sync, converted from one format to the other, and as plain text for a transcript.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Value};const MAX: usize = 32 * 1024 * 1024;#[derive(Clone)]struct Cue {    id: String,    start: i64,    end: i64,    settings: String,    text: String,}fn parse_time(t: &str) -> Option<i64> {    let t = t.trim();    let (hms, frac) = t.rsplit_once([',', '.'])?;    if frac.len() != 3 || !frac.bytes().all(|b| b.is_ascii_digit()) {        return None;    }    let parts: Vec<&str> = hms.split(':').collect();    let nums: Option<Vec<i64>> = parts.iter().map(|p| if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) { p.parse().ok() } else { None }).collect();    let nums = nums?;    let (h, m, s) = match nums.as_slice() {        [h, m, s] => (*h, *m, *s),        [m, s] => (0, *m, *s),        _ => return None,    };    if m > 59 || s > 59 {        return None;    }    Some(((h * 60 + m) * 60 + s) * 1000 + frac.parse::<i64>().ok()?)}fn fmt_time(ms: i64, sep: char) -> String {    let ms = ms.max(0);    format!("{:02}:{:02}:{:02}{}{:03}", ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, sep, ms % 1000)}fn timing(line: &str) -> Option<(i64, i64, String)> {    let (a, rest) = line.split_once("-->")?;    let rest = rest.trim();    let (b, settings) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));    Some((parse_time(a)?, parse_time(b)?, settings.trim().to_string()))}fn is_vtt(text: &str) -> bool {    text.trim_start_matches('\u{feff}').starts_with("WEBVTT")}fn parse(text: &str) -> Result<Vec<Cue>, String> {    if text.len() > MAX {        return Err("the subtitles are over 32 MB".into());    }    let text = text.trim_start_matches('\u{feff}').replace("\r\n", "\n").replace('\r', "\n");    let vtt = is_vtt(&text);    let mut cues = Vec::new();    for (n, block) in text.split("\n\n").map(|b| b.trim_matches('\n')).filter(|b| !b.is_empty()).enumerate() {        let lines: Vec<&str> = block.lines().collect();        if vtt && (n == 0 && lines[0].starts_with("WEBVTT") || ["NOTE", "STYLE", "REGION"].iter().any(|k| lines[0] == *k || lines[0].starts_with(&format!("{} ", k)) || lines[0].starts_with(&format!("{}\t", k)))) {            continue;        }        let Some(at) = lines.iter().take(2).position(|l| l.contains("-->")) else {            return Err(format!("cue {}: no `start --> end` line in `{}`", cues.len() + 1, lines[0]));        };        let (start, end, settings) = timing(lines[at]).ok_or_else(|| format!("cue {}: `{}` is not a timing line", cues.len() + 1, lines[at]))?;        if end < start {            return Err(format!("cue {}: it ends before it starts", cues.len() + 1));        }        let id = if at == 1 { lines[0].trim().to_string() } else { String::new() };        cues.push(Cue { id, start, end, settings, text: lines[at + 1..].join("\n") });    }    Ok(cues)}fn write(cues: &[Cue], vtt: bool) -> String {    let mut out = String::new();    if vtt {        out.push_str("WEBVTT\n\n");    }    for (i, c) in cues.iter().enumerate() {        if vtt {            if !c.id.is_empty() && c.id.parse::<i64>().is_err() {                out.push_str(&c.id);                out.push('\n');            }            out.push_str(&format!("{} --> {}", fmt_time(c.start, '.'), fmt_time(c.end, '.')));            if !c.settings.is_empty() {                out.push(' ');                out.push_str(&c.settings);            }        } else {            out.push_str(&format!("{}\n{} --> {}", i + 1, fmt_time(c.start, ','), fmt_time(c.end, ',')));        }        out.push('\n');        out.push_str(&if vtt { drop_ass_tags(&c.text) } else { c.text.clone() });        out.push_str("\n\n");    }    out}/// SRT's `{\an8}`-style tags, which WebVTT does not know.fn drop_ass_tags(s: &str) -> String {    let mut out = String::new();    let mut rest = s;    while let Some(i) = rest.find("{\\") {        match rest[i..].find('}') {            Some(j) => {                out.push_str(&rest[..i]);                rest = &rest[i + j + 1..];            }            None => break,        }    }    out.push_str(rest);    out}fn strip_tags(s: &str) -> String {    let mut out = String::new();    let mut tag = false;    for ch in s.chars() {        match ch {            '<' | '{' if !tag => tag = true,            '>' | '}' if tag => tag = false,            _ if !tag => out.push(ch),            _ => {}        }    }    out.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&nbsp;", " ")}fn to_value(c: &Cue, i: usize) -> Value {    json!({        "index": i + 1,        "id": if c.id.is_empty() { Value::Null } else { Value::String(c.id.clone()) },        "start": fmt_time(c.start, '.'),        "end": fmt_time(c.end, '.'),        "start_ms": c.start,        "end_ms": c.end,        "settings": c.settings,        "text": c.text,        "plain": strip_tags(&c.text),    })}/// The cues of SRT or WebVTT text (told apart by the `WEBVTT` line): `[{ index, id, start, end, start_ms, end_ms,/// settings, text, plain }]`, times as "00:01:02.500" and in milliseconds, `settings` WebVTT's ("align:start"...),/// `plain` the text without `<i>`/`{\an8}` tags. NOTE, STYLE and REGION blocks are skipped.pub fn subtitles_parse(text: &str) -> Result<Vec<Value>, String> {    Ok(parse(text)?.iter().enumerate().map(|(i, c)| to_value(c, i)).collect())}/// `subtitles_parse` of a .srt or .vtt file.pub fn subtitles_read_file(path: &str) -> Result<Vec<Value>, String> {    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path, e))?;    subtitles_parse(&text)}/// The same subtitles, every cue `ms` milliseconds later (negative: earlier), in the format they came in. Cues that/// would end before 0 are dropped, and one cut by 0 starts at 0.pub fn subtitles_shift(text: &str, ms: i64) -> Result<String, String> {    let cues: Vec<Cue> = parse(text)?.into_iter().filter(|c| c.end + ms > 0).map(|mut c| { c.start = (c.start + ms).max(0); c.end += ms; c }).collect();    Ok(write(&cues, is_vtt(text)))}/// Brings subtitles that drift back in sync from two points: the first cue to start at `first_ms` and the last at/// `last_ms`, every time between stretched to match (a file made for 25 frames a second on a 23.976 video, or a/// cut ad break). Same format out as in.pub fn subtitles_resync(text: &str, first_ms: i64, last_ms: i64) -> Result<String, String> {    let cues = parse(text)?;    let (Some(a), Some(b)) = (cues.first().map(|c| c.start), cues.last().map(|c| c.start)) else {        return Ok(write(&cues, is_vtt(text)));    };    let k = if b > a { (last_ms - first_ms) as f64 / (b - a) as f64 } else { 1.0 };    let map = |t: i64| (first_ms as f64 + (t - a) as f64 * k).round() as i64;    let cues: Vec<Cue> = cues.into_iter().map(|mut c| { c.start = map(c.start).max(0); c.end = map(c.end).max(c.start); c }).collect();    Ok(write(&cues, is_vtt(text)))}/// SRT or WebVTT text as WebVTT, for the `<track>` element of a web page (cue settings kept).pub fn subtitles_to_vtt(text: &str) -> Result<String, String> {    Ok(write(&parse(text)?, true))}/// SRT or WebVTT text as SRT, numbered from 1 (WebVTT's settings and NOTEs left out), for video editors and players.pub fn subtitles_to_srt(text: &str) -> Result<String, String> {    Ok(write(&parse(text)?, false))}/// Subtitles written from `cues`, each `{ start_ms, end_ms, text }` (or `start`/`end` as "00:01:02.500"), in `format`/// "srt" or "vtt": captions from a transcript, a speech-to-text service or a database.pub fn subtitles_write(cues: Vec<serde_json::Value>, format: &str) -> Result<String, String> {    let vtt = match format.to_lowercase().as_str() {        "vtt" | "webvtt" => true,        "srt" => false,        f => return Err(format!("unknown format `{}`: srt or vtt", f)),    };    let time = |c: &Value, ms: &str, t: &str, i: usize| -> Result<i64, String> {        c[ms].as_i64().or_else(|| c[t].as_str().and_then(parse_time)).ok_or_else(|| format!("cue {}: no `{}` (or `{}` as \"00:01:02.500\")", i + 1, ms, t))    };    let mut out = Vec::new();    for (i, c) in cues.iter().enumerate() {        let (start, end) = (time(c, "start_ms", "start", i)?, time(c, "end_ms", "end", i)?);        if end < start {            return Err(format!("cue {}: it ends before it starts", i + 1));        }        let text = c["text"].as_str().ok_or_else(|| format!("cue {}: no `text`", i + 1))?;        out.push(Cue { id: c["id"].as_str().unwrap_or("").into(), start, end, settings: c["settings"].as_str().unwrap_or("").into(), text: text.replace("\n\n", "\n").trim().to_string() });    }    Ok(write(&out, vtt))}/// The cues showing at `ms` milliseconds into the video, as `subtitles_parse` gives them (usually one, none in a pause).pub fn subtitles_at(text: &str, ms: i64) -> Result<Vec<Value>, String> {    Ok(parse(text)?.iter().enumerate().filter(|(_, c)| c.start <= ms && ms < c.end).map(|(i, c)| to_value(c, i)).collect())}/// The words alone, for a transcript or a search index: each cue's plain text, lines joined by spaces, one cue a line.pub fn subtitles_text(text: &str) -> Result<String, String> {    Ok(parse(text)?.iter().map(|c| strip_tags(&c.text).split('\n').map(str::trim).collect::<Vec<_>>().join(" ")).collect::<Vec<_>>().join("\n"))}
Thread
75Useful? Like it, or add to its thread below.

Thread 0 posts

On Subtitles: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.