diff --git a/Cargo.toml b/Cargo.toml
index d3b8f179b..e514fb6f6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -121,6 +121,7 @@ members = [
"write-debian-changelog",
"zip-or-dir",
"zip-or-dir/dir2zip",
+ "media-utils/video2srt",
]
exclude = ["led-box-firmware", "led-box-firmware-pico"]
diff --git a/media-utils/video2srt/Cargo.toml b/media-utils/video2srt/Cargo.toml
new file mode 100644
index 000000000..06d5365da
--- /dev/null
+++ b/media-utils/video2srt/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "video2srt"
+description = "Create a subtitle .srt file from video with Strand Cam timestamps"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+clap = { version = "4.3.4", features = ["derive"] }
+color-eyre = "0.6.2"
+
+tracing = "0.1.40"
+chrono = { version = "0.4.38", features = [
+ "libc",
+ "serde",
+ "std",
+], default-features = false }
+
+env-tracing-logger = { path = "../../env-tracing-logger" }
+frame-source = { path = "../frame-source" }
diff --git a/media-utils/video2srt/src/main.rs b/media-utils/video2srt/src/main.rs
new file mode 100644
index 000000000..c3b7da366
--- /dev/null
+++ b/media-utils/video2srt/src/main.rs
@@ -0,0 +1,115 @@
+use clap::Parser;
+use color_eyre::eyre::{self, WrapErr};
+use std::{io::Write, path::PathBuf};
+
+use frame_source::Timestamp;
+
+#[derive(Debug, Parser)]
+#[command(version, about)]
+struct Opt {
+ /// Input video filename.
+ #[arg(short, long)]
+ input: PathBuf,
+
+ /// Output srt filename. Defaults to ".srt"
+ #[arg(short, long)]
+ output: Option,
+}
+
+trait Srt {
+ fn srt(&self) -> String;
+}
+
+impl Srt for std::time::Duration {
+ fn srt(&self) -> String {
+ // from https://en.wikipedia.org/wiki/SubRip :
+ // "hours:minutes:seconds,milliseconds with time units fixed to two
+ // zero-padded digits and fractions fixed to three zero-padded digits
+ // (00:00:00,000). The fractional separator used is the comma, since the
+ // program was written in France."
+ let total_secs = self.as_secs();
+ let hours = total_secs / (60 * 60);
+ let minutes = (total_secs % (60 * 60)) / 60;
+ let seconds = total_secs % 60;
+ dbg!(total_secs);
+ dbg!(hours);
+ dbg!(minutes);
+ dbg!(seconds);
+ debug_assert_eq!(total_secs, hours * 60 * 60 + minutes * 60 + seconds);
+ let millis = self.subsec_millis();
+ format!("{hours:02}:{minutes:02}:{seconds:02},{millis:03}")
+ }
+}
+
+fn main() -> eyre::Result<()> {
+ if std::env::var_os("RUST_LOG").is_none() {
+ std::env::set_var("RUST_LOG", "info");
+ }
+ env_tracing_logger::init();
+ let opt = Opt::parse();
+
+ let output = opt.output;
+
+ let output = output.unwrap_or_else(|| {
+ let mut output = opt.input.as_os_str().to_owned();
+ output.push(".srt");
+ output.into()
+ });
+
+ let do_decode_h264 = false;
+ let mut src = frame_source::from_path(&opt.input, do_decode_h264)
+ .with_context(|| format!("while opening path {}", opt.input.display()))?;
+
+ let start_time = src
+ .frame0_time()
+ .ok_or_else(|| eyre::eyre!("no start time found"))?;
+
+ let mut out_fd = std::fs::File::create(&output)?;
+
+ let mut prev_data: Option<(std::time::Duration, _)> = None;
+
+ let mut count = 1;
+ for (_fno, frame) in src.iter().enumerate() {
+ let frame = frame?;
+ let pts = match frame.timestamp() {
+ Timestamp::Duration(pts) => pts,
+ _ => {
+ eyre::bail!(
+ "video has no PTS timestamps and framerate was not \
+ specified on the command line."
+ );
+ }
+ };
+ let frame_stamp = start_time + pts;
+ if let Some((prev_pts, prev_stamp)) = prev_data.take() {
+ out_fd.write_all(
+ format!(
+ "{}\n{} --> {}\n{}\n\n",
+ count,
+ prev_pts.srt(),
+ pts.srt(),
+ prev_stamp
+ )
+ .as_bytes(),
+ )?;
+ count += 1;
+ }
+ prev_data = Some((pts, frame_stamp));
+ }
+
+ if let Some((prev_pts, prev_stamp)) = prev_data.take() {
+ let pts = prev_pts + std::time::Duration::from_secs(1);
+ out_fd.write_all(
+ format!(
+ "{}\n{} --> {}\n{}\n\n",
+ count,
+ prev_pts.srt(),
+ pts.srt(),
+ prev_stamp
+ )
+ .as_bytes(),
+ )?;
+ }
+
+ Ok(())
+}