v0.1.8
Menu
Download

FFmpeg Processing

ffmpeg and ffprobe ship as bundled sidecars for yt-dlp muxing, scrubber sprite sheets, and quiet metadata probes.

yt-dlp invokes ffmpeg when a download needs merge or post-process steps. RuForge watches stdout after the byte bar hits 100% and keeps the queue row in a Processing phase until the child process exits. Audio-only jobs skip video post-process paths.

Scrubber hover previews are local sprite sheets. Settings → Downloads auto scrubber previews (default on) tells downloader.rs to call extract_frames after a successful video finish. The ffmpeg filter chain samples one frame every five seconds into 160×90 tiles on a 10×10 grid (fps=1/5,scale=160:90,tile=10x10). Sheets land under .ruforge/thumbs/ beside the video.

useScrubberThumbs.ts loads sheet paths and maps hover position to a tile. Chapter scrubber and simple scrubber both use the same hook; Generate Previews in the library calls the same Rust command when auto mode is off. Completing a sheet emits scrub-sprites-updated so open players reload without restart.

ffprobe warms a disk cache (ffprobe-hints under app data). The player UI does not show codec strings today. Delete and replace flows cancel in-flight ffmpeg work via the per-file lock so files are not left locked on disk.

In the repo

async fn run_ffmpeg_sidecar_unlocked(
    app: &AppHandle,
    slot: &Arc<FfmpegVideoSlot>,
    args: Vec<&str>,
) -> Result<(), String> {
    let (mut rx, child) = app
        .shell()
        .sidecar("ffmpeg")
        .map_err(|e| e.to_string())?
        .args(args)
        .spawn()
        .map_err(|e| format!("Failed to start ffmpeg sidecar: {}", e))?;

    *slot.child.lock().await = Some(child);

    let mut success = false;
    while let Some(event) = rx.recv().await {
        match event {
            CommandEvent::Terminated(payload) => {
                success = payload.code == Some(0);
                break;
            }
            CommandEvent::Error(err) => {
                *slot.child.lock().await = None;
                return Err(format!("ffmpeg sidecar error: {}", err));
            }
            CommandEvent::Stdout(_) | CommandEvent::Stderr(_) => {}
            _ => {}
        }
    }

    *slot.child.lock().await = None;
    if success {
        Ok(())
    } else {
        Err("ffmpeg sidecar failed".into())
    }
}
    if !preview_sprites_complete(&thumb_dir, duration_secs) {
        let output_pattern = thumb_dir.join("sprite_%03d.jpg");

        run_ffmpeg_sidecar_unlocked(
            &app,
            &slot,
            vec![
                "-hide_banner",
                "-loglevel",
                "error",
                "-i",
                video_path.as_str(),
                "-vf",
                "fps=1/5,scale=160:90,tile=10x10",
                "-q:v",
                "5",
                output_pattern.to_str().ok_or("Bad sprite path")?,
            ],
        )
        .await
        .map_err(|e| format!("Failed to run ffmpeg sidecar: {}", e))?;

        let sprites = collect_sprite_paths(&thumb_dir);
        if sprites.is_empty() {
            return Err("ffmpeg sidecar produced no sprite sheets".to_string());
        }
    }

    if !poster_dest.is_file() {
        let _ = write_poster_jpeg(&app, &slot, &video_path, &poster_dest).await;
    }

    let out: Vec<String> = collect_sprite_paths(&thumb_dir)
        .into_iter()
        .map(|p| p.to_string_lossy().to_string())
        .collect();
    Ok(out)

Where it shows up

  • Sidecars binaries/ffmpeg-* and binaries/ffprobe-* in tauri.conf.json
  • src-tauri/src/commands/media.rs sprites, posters, ffprobe cache
  • useScrubberThumbs.ts, ChapterScrubber.tsx, ScrubHoverPreview.tsx
  • Settings → Downloads auto-preview toggle (autoScrubPreviews in store settings)