v0.2.1
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

        out.push(arg.to_string());
        if !inserted && arg == "-nostdin" {
            out.push("-threads".to_string());
            out.push("1".to_string());
            inserted = true;
        }
    }
    if !inserted {
        out.insert(0, "1".to_string());
        out.insert(0, "-threads".to_string());
    }
    out
}

fn ffmpeg_slot_map() -> &'static Mutex<HashMap<String, Arc<FfmpegVideoSlot>>> {
    FFMPEG_PER_VIDEO.get_or_init(|| Mutex::new(HashMap::new()))
}

// Matches TS mediaPathsMatch (backslashes + lowercase); Linux dev may fold distinct paths.
fn normalize_media_key(path: &str) -> String {
    path.replace('/', "\\").to_lowercase()
}

async fn ffmpeg_slot_for(video_path: &str) -> Arc<FfmpegVideoSlot> {
    let key = normalize_media_key(video_path);
    let mut map = ffmpeg_slot_map().lock().await;
    map.entry(key)
        .or_insert_with(|| {
            Arc::new(FfmpegVideoSlot {
                lock: Arc::new(Mutex::new(())),
                child: Arc::new(Mutex::new(None)),
            })
        })
        .clone()
}

/// Stop any in-flight RuForge ffmpeg sidecar for this file (preview sprites / poster).
pub async fn cancel_ffmpeg_for_video(video_path: &str) {
        app,
        slot,
        vec![
            "-hide_banner",
            "-nostdin",
            "-loglevel",
            "error",
            "-y",
            "-i",
            sheet_str,
            "-vf",
            &crop,
            "-q:v",
            "5",
            dest_str,
        ],
    )
    .await
}

async fn is_sprite_cell_populated(
    app: &AppHandle,
    slot: &Arc<FfmpegVideoSlot>,
    thumb_dir: &Path,
    sheet_path: &Path,
    overlay_x: u32,
    overlay_y: u32,
) -> Result<bool, String> {
    if !sheet_path.is_file() {
        return Ok(false);
    }
    let probe = thumb_dir.join("_cell_probe.jpg");
    crop_sprite_cell_jpeg(app, slot, sheet_path, overlay_x, overlay_y, &probe).await?;
    let populated = std::fs::metadata(&probe)
        .map(|m| m.len() > POPULATED_CELL_MIN_BYTES)
        .unwrap_or(false);
    let _ = std::fs::remove_file(&probe);

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)