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
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 errorThe 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 errorsubtitles_parse of a .srt or .vtt file.
subtitles_shift(text: str, ms: int) → stror an errorThe 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 errorBrings 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 errorSRT or WebVTT text as WebVTT, for the <track> element of a web page (cue settings kept).
subtitles_to_srt(text: str) → stror an errorSRT 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 errorSubtitles 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 errorThe 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 errorThe 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.qweOr 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.srtThen, in your program, all of it or only what you call:
use subtitles.qweruse subtitles_parse, subtitles_read_file from subtitles // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// 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 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("&", "&").replace("<", "<").replace(">", ">").replace(" ", " ")}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"))}Related
markdown_toc.qwer · 1.0.0Tables of contents for Markdown: headings as a list or tree with GitHub-style anchors, a ready TOC, and HTML with heading ids.7 views · 8 downloadsHumanize humanize.qwer · 1.0.0Durations, file sizes and relative times as people write them and back: 2h 30m, 1.5 GB, 2 GiB, 3 days ago, in 2 hours.7 views · 11 downloadsEnglish dates dates_english.qwer · 1.0.0Dates typed in English read as times, in the user's time zone and US or UK style: next friday at 3pm, tomorrow 9am, in 2 weeks, 3 days ago.8 views · 10 downloadsThread 0 posts
On Subtitles: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.