fix: subtitles centred and quieter

The captions were pinned to one side and set for a television across a
room. Verified fixed in the running app: centred at the bottom of the
picture, smaller, on a translucent ground instead of solid black.

The position was not in the file — the embedded track extracts to
WebVTT with no cue settings at all. It was the element's in-band
rendering: WebKit hands a container's own subtitle stream to the media
pipeline, which places it wherever the container's text box points and
which no CSS can reach.

So the player reads the stream out of the file with ffmpeg — about
40ms, no re-encoding — and attaches it as an ordinary track. The
download stays one self-contained file; the cues become ours to place
and style. The file's own track is filtered out of the menu and left
disabled, or the same captions would be offered twice and the in-band
copy would render underneath.

Cue styling: 58% of WebKit's default size, on slate at 55% rather than
opaque black.
This commit is contained in:
vincent
2026-08-29 17:35:09 +02:00
parent 2121f3e475
commit 8dade59bb4
6 changed files with 123 additions and 25 deletions
+74
View File
@@ -1405,6 +1405,80 @@ async fn read_vtt_dir(dir: &Path) -> Vec<(String, String)> {
out
}
/// Subtitles muxed into a downloaded file, as (language, WebVTT text).
///
/// The file keeps them, but the player does not use the element's own in-band
/// rendering: WebKit hands those to the media pipeline, which places them
/// wherever the container's text box says — bottom right, in practice — and no
/// CSS can reach them. Read out as WebVTT they are ordinary cues, centred and
/// styleable like any other. Reading one costs about 40ms and no re-encoding.
#[tauri::command]
pub async fn embedded_subtitles(
video_id: String,
state: State<'_, AppState>,
) -> Result<Vec<(String, String)>, String> {
let Some(path) = state.db.lock().await.get_download_path(&video_id)? else {
return Ok(Vec::new());
};
if tokio::fs::metadata(&path).await.is_err() {
return Ok(Vec::new());
}
let probe = tokio::process::Command::new(bin("ffprobe"))
.args([
"-v", "error",
"-select_streams", "s",
"-show_entries", "stream=index:stream_tags=language",
"-of", "csv=p=0",
&path,
])
.output()
.await
.map_err(|e| format!("Could not read the file: {e}"))?;
let mut out: Vec<(String, String)> = Vec::new();
// ffprobe prints "<index>,<language>" per subtitle stream, and the index is
// the absolute stream index — the mapping below counts subtitle streams, so
// position in this list is what matters, not the number printed.
for (nth, line) in String::from_utf8_lossy(&probe.stdout).lines().enumerate() {
let lang = line
.split(',')
.nth(1)
.map(|l| l.trim())
.filter(|l| !l.is_empty() && *l != "und")
.unwrap_or("en")
.to_string();
let dump = tokio::process::Command::new(bin("ffmpeg"))
.args([
"-v", "error",
"-i", &path,
"-map", &format!("0:s:{nth}"),
"-f", "webvtt",
"-",
])
.output()
.await;
let Ok(dump) = dump else { continue };
let text = String::from_utf8_lossy(&dump.stdout).to_string();
if text.contains("-->") {
// ISO 639-2 is what a container stores; the player labels by the
// two-letter code everything else uses.
let short = match lang.as_str() {
"eng" => "en",
"nld" | "dut" => "nl",
"deu" | "ger" => "de",
"fra" | "fre" => "fr",
"spa" => "es",
"ita" => "it",
"por" => "pt",
other => other,
};
out.push((short.to_string(), tidy_vtt(&text)));
}
}
Ok(out)
}
/// Subtitles for a video, as (language, WebVTT text).
///
/// Streaming has no other source: YouTube's HLS manifest lists a dozen audio