captube 0.1.0

Turn a YouTube slide-lecture video into a PDF of its unique slides, using ffmpeg keyframes + perceptual dedup.
use anyhow::{Context, Result};
use printpdf::{
    ColorBits, ColorSpace, Image, ImageTransform, ImageXObject, Mm, PdfDocument, Px,
};
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};

/// Build a PDF where each page is one extracted frame, sized to the frame's
/// aspect ratio at a fixed long-edge in millimetres.
pub fn build_pdf(frames: &[PathBuf], output: &Path) -> Result<()> {
    // Long edge in mm for every page — roughly A4 landscape width.
    const LONG_EDGE_MM: f32 = 297.0;

    let (doc, first_page, first_layer) = {
        let (w_mm, h_mm) = page_size_for(&frames[0], LONG_EDGE_MM)?;
        let (doc, page, layer) =
            PdfDocument::new("captube", Mm(w_mm), Mm(h_mm), "Layer 1");
        add_frame_to_page(&doc, page, layer, &frames[0], w_mm, h_mm)?;
        (doc, page, layer)
    };
    let _ = (first_page, first_layer);

    for frame in frames.iter().skip(1) {
        let (w_mm, h_mm) = page_size_for(frame, LONG_EDGE_MM)?;
        let (page, layer) = doc.add_page(Mm(w_mm), Mm(h_mm), "Layer 1");
        add_frame_to_page(&doc, page, layer, frame, w_mm, h_mm)?;
    }

    let mut out = BufWriter::new(File::create(output).context("create pdf file")?);
    doc.save(&mut out).context("save pdf")?;
    Ok(())
}

fn page_size_for(path: &Path, long_edge_mm: f32) -> Result<(f32, f32)> {
    let dims = image::image_dimensions(path)
        .with_context(|| format!("read dimensions of {}", path.display()))?;
    let (w, h) = (dims.0 as f32, dims.1 as f32);
    let (w_mm, h_mm) = if w >= h {
        (long_edge_mm, long_edge_mm * h / w)
    } else {
        (long_edge_mm * w / h, long_edge_mm)
    };
    Ok((w_mm, h_mm))
}

fn add_frame_to_page(
    doc: &printpdf::PdfDocumentReference,
    page: printpdf::PdfPageIndex,
    layer: printpdf::PdfLayerIndex,
    path: &Path,
    page_w_mm: f32,
    page_h_mm: f32,
) -> Result<()> {
    let dyn_img = image::open(path)
        .with_context(|| format!("decode image {}", path.display()))?
        .to_rgb8();
    let (w, h) = (dyn_img.width(), dyn_img.height());
    let data = dyn_img.into_raw();

    let xobj = ImageXObject {
        width: Px(w as usize),
        height: Px(h as usize),
        color_space: ColorSpace::Rgb,
        bits_per_component: ColorBits::Bit8,
        interpolate: true,
        image_data: data,
        image_filter: None,
        clipping_bbox: None,
        smask: None,
    };
    let img = Image::from(xobj);

    // printpdf's ImageTransform scales are relative to 1.0 == 1px-per-mm at 72dpi
    // equivalent — we compute the dpi from the target physical size.
    let dpi_x = w as f32 / (page_w_mm / 25.4);
    let dpi_y = h as f32 / (page_h_mm / 25.4);
    // Use the smaller dpi so the image fits entirely on the page.
    let dpi = dpi_x.min(dpi_y);

    let layer_ref = doc.get_page(page).get_layer(layer);
    img.add_to_layer(
        layer_ref,
        ImageTransform {
            translate_x: Some(Mm(0.0)),
            translate_y: Some(Mm(0.0)),
            rotate: None,
            scale_x: None,
            scale_y: None,
            dpi: Some(dpi),
        },
    );
    Ok(())
}