mod dedup;
mod download;
mod extract;
mod pdf;
use anyhow::{Context, Result};
use clap::Parser;
use rayon::prelude::*;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
url: String,
#[arg(short, long, default_value = "output.pdf")]
output: PathBuf,
#[arg(long, default_value_t = 0.30)]
scene_threshold: f32,
#[arg(long, default_value_t = 2.0)]
fps: f32,
#[arg(long, default_value_t = 1280)]
max_width: u32,
#[arg(long, default_value_t = 20.0)]
dedup_threshold: f32,
#[arg(long)]
keep_workdir: bool,
#[arg(short, long)]
verbose: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
let tmp = tempfile::Builder::new()
.prefix("captube-")
.tempdir()
.context("failed to create temp dir")?;
let work = tmp.path().to_path_buf();
eprintln!("[captube] workdir: {}", work.display());
let t0 = std::time::Instant::now();
let video = download::fetch_video(&args.url, &work)
.context("failed to download video")?;
eprintln!("[captube] downloaded: {} ({:.1}s)", video.display(), t0.elapsed().as_secs_f32());
let t1 = std::time::Instant::now();
let candidates = extract::extract_scene_frames(
&video,
&work,
args.scene_threshold,
args.fps,
args.max_width,
)
.context("failed to extract scene frames")?;
eprintln!(
"[captube] extracted {} candidate keyframes ({:.1}s)",
candidates.len(),
t1.elapsed().as_secs_f32()
);
if candidates.is_empty() {
anyhow::bail!("no frames were extracted; try lowering --scene-threshold");
}
let t2 = std::time::Instant::now();
let kept = dedup::dedup_candidates(&candidates, args.dedup_threshold, args.verbose)
.context("failed to dedup candidates")?;
eprintln!(
"[captube] {} unique keyframes ({:.1}s)",
kept.len(),
t2.elapsed().as_secs_f32()
);
const SETTLE: f32 = 0.8;
let settled_dir = work.join("settled");
std::fs::create_dir_all(&settled_dir)?;
let t3 = std::time::Instant::now();
let settled: Vec<PathBuf> = kept
.par_iter()
.enumerate()
.map(|(i, c)| {
let out = settled_dir.join(format!("settled_{i:05}.jpg"));
match extract::capture_at(&video, c.pts + SETTLE, args.max_width, &out) {
Ok(()) if out.exists() => Ok::<PathBuf, anyhow::Error>(out),
_ => Ok(c.path.clone()),
}
})
.collect::<Result<Vec<_>>>()
.context("failed to re-extract settled frames")?;
eprintln!(
"[captube] re-extracted {} settled frames ({:.1}s)",
settled.len(),
t3.elapsed().as_secs_f32()
);
let t4 = std::time::Instant::now();
let frames = dedup::dedup_paths(&settled, 5.0, args.verbose)
.context("failed to dedup settled frames")?;
eprintln!(
"[captube] {} frames after settle-dedup ({:.1}s)",
frames.len(),
t4.elapsed().as_secs_f32()
);
let t5 = std::time::Instant::now();
pdf::build_pdf(&frames, &args.output).context("failed to build pdf")?;
eprintln!(
"[captube] wrote pdf: {} ({:.1}s)",
args.output.display(),
t5.elapsed().as_secs_f32()
);
if args.keep_workdir {
let kept = tmp.keep();
eprintln!("[captube] kept workdir: {}", kept.display());
}
Ok(())
}