v5.1.0: history tab, portable data folder, optional faststart, new icons & build tools

This commit is contained in:
2026-10-10 18:00:01 +08:00
parent 096b99f376
commit 95592a3b78
36 changed files with 1959 additions and 201 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "video-concat"
version = "5.0.0"
version = "5.1.0"
description = "Video Concat — merge split stream recordings into one file"
authors = ["youfu"]
edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 78 KiB

+54
View File
@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<title>Video Concat</title>
<defs>
<linearGradient id="tile" x1="22" y1="22" x2="490" y2="490" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#00001B"/>
<stop offset="0.125" stop-color="#000044"/>
<stop offset="0.25" stop-color="#130469"/>
<stop offset="0.375" stop-color="#130F89"/>
<stop offset="0.5" stop-color="#1328AB"/>
<stop offset="0.625" stop-color="#0F4CCF"/>
<stop offset="0.75" stop-color="#006EE5"/>
<stop offset="0.875" stop-color="#0790EA"/>
<stop offset="1" stop-color="#009DE0"/>
</linearGradient>
<radialGradient id="glow" cx="280" cy="0" r="420" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFC9FF" stop-opacity="0.980"/>
<stop offset="0.15" stop-color="#FFC9FF" stop-opacity="0.672"/>
<stop offset="0.3" stop-color="#FFC9FF" stop-opacity="0.411"/>
<stop offset="0.5" stop-color="#FFC9FF" stop-opacity="0.228"/>
<stop offset="0.7" stop-color="#FFC9FF" stop-opacity="0.119"/>
<stop offset="1" stop-color="#FFC9FF" stop-opacity="0.037"/>
</radialGradient>
<clipPath id="tileClip">
<rect x="14" y="14" width="484" height="484" rx="99"/>
</clipPath>
</defs>
<g clip-path="url(#tileClip)">
<rect x="14" y="14" width="484" height="484" fill="url(#tile)"/>
<rect x="14" y="14" width="484" height="484" fill="url(#glow)"/>
</g>
<g fill="#FFFFFF" fill-rule="evenodd">
<path d="M201.44,359.5C202.78,358.83,207.2,354.36,208.52,352.33C210.51,349.27,212.34,345.71,213.21,343.17C214.14,340.45,215.85,334.76,216.58,331.92C217.51,328.35,219.71,320.72,220.79,317.33C221.75,314.32,226.6,304.45,227.99,302.67C228.46,302.07,229.36,300.87,229.99,300C234.04,294.42,241.08,288.94,249.67,284.68C255.6,281.74,262.32,279.43,266.14,279C267.02,278.9,268.31,278.66,269,278.46C270.25,278.09,270.25,278.09,333.58,278C403.12,277.9,398.06,278.03,402.63,276.19C412.49,272.22,418.07,260.84,415.2,250.58C413.19,243.38,409.17,238.25,403.72,235.91C399.12,233.94,404.66,234.09,334.58,234C271.42,233.91,271.42,233.91,270.16,233.54C269.47,233.34,268.2,233.1,267.33,233C263.44,232.56,253.4,229.19,248.75,226.76C245.27,224.94,240.82,222,238.49,219.95C237.71,219.28,236.48,218.29,235.75,217.75C228.02,212.09,221.63,199.89,217.09,182.08C212.96,165.93,209.75,159.42,202.96,153.5C196.94,148.25,190.74,148.38,195.59,153.65C198.5,156.81,198.53,157.04,198.66,176.83C198.83,202.41,198.83,202.43,195.53,208.01C194.07,210.48,194.09,210.69,196.08,212.83C196.55,213.34,197.26,214.19,197.65,214.72C198.04,215.25,199.02,216.42,199.82,217.32C200.63,218.22,201.54,219.33,201.84,219.77C202.14,220.22,202.89,221.22,203.5,222C204.11,222.78,204.86,223.79,205.17,224.24C205.48,224.7,206.23,225.64,206.83,226.33C207.44,227.02,208.37,228.18,208.9,228.92C209.44,229.65,211.31,231.7,213.06,233.47C216.19,236.64,217.54,237.77,222.55,241.46C226.25,244.18,227.32,245.19,227.11,245.75C226.75,246.68,219.96,244.82,214.68,242.34C213.35,241.71,211.86,241.04,211.39,240.85C210.2,240.39,206.85,238.69,205.31,237.78C204.61,237.37,202.62,236.31,200.89,235.44C199.16,234.57,196.69,233.22,195.4,232.44C189.56,228.93,183.73,227,178.97,227C175.52,227,175.49,228.27,178.84,231.89C182,235.32,182.8,238.53,182.83,247.87C182.83,249.8,182.93,251.66,183.09,252.74C183.33,254.34,183.33,254.65,183.09,256.32C182.73,258.71,182.66,270.13,182.98,272.42C183.19,273.93,183.12,275.12,182.49,280.25C182.05,283.93,183.03,284.57,187,283.23C188.94,282.57,195.5,279.32,197.83,277.85C198.89,277.19,201.78,275.63,204.25,274.38C206.72,273.14,209.35,271.78,210.08,271.37C215.49,268.31,226.22,264.9,226.62,266.11C226.77,266.56,223.89,269.04,220.75,271.17C220.29,271.48,219.2,272.39,218.33,273.18C217.46,273.97,216,275.18,215.08,275.87C213.18,277.3,211.99,278.5,210.59,280.42C210.05,281.15,209.15,282.28,208.58,282.92C208.02,283.56,207.02,284.8,206.37,285.67C205.72,286.54,204.75,287.78,204.22,288.44C203.68,289.09,202.41,290.81,201.38,292.27C199.52,294.9,194.87,299.8,191.37,302.8C189.69,304.24,189.73,304.95,191.64,307.12C192.35,307.92,193.32,309.16,193.79,309.86C194.27,310.56,195.11,311.65,195.66,312.28C197.05,313.85,198.27,316.32,198.5,318C199.11,322.59,199.16,323.16,199.06,324.34C199,325.06,199.04,326.21,199.16,327.01C199.32,328.2,199.31,328.73,199.08,330.42C198.63,333.64,198.67,341.83,199.14,344.24C199.56,346.38,199.59,347.8,199.23,349.42C198.72,351.77,198.71,359.06,199.22,359.57C199.78,360.13,200.22,360.11,201.44,359.5ZM243.71,272.12C243.52,271.92,243.28,271.45,243.18,271.08C242.93,270.16,242.93,242.68,243.18,241.51C243.61,239.5,245.13,239.38,247.71,241.13C248.1,241.4,249.62,242.35,251.08,243.25C252.55,244.15,255.62,246.12,257.92,247.63C260.21,249.14,263.25,251.09,264.67,251.96C270.66,255.64,270.78,256.21,266.21,259.18C265.04,259.94,262.17,261.81,259.83,263.34C257.5,264.86,255.1,266.4,254.5,266.75C253.9,267.11,251.96,268.36,250.19,269.54C245.49,272.66,244.55,273.03,243.71,272.12Z"/>
</g>
<g fill="#FFFFFF" fill-rule="evenodd">
<rect x="113" y="147" width="79" height="60" rx="12"/>
<rect x="181" y="151.00" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="162.25" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="173.50" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="184.75" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="196.00" width="7.5" height="7.5" rx="1.8"/>
<rect x="96" y="226" width="79" height="60" rx="12"/>
<rect x="164" y="230.00" width="7.5" height="7.5" rx="1.8"/>
<rect x="164" y="241.25" width="7.5" height="7.5" rx="1.8"/>
<rect x="164" y="252.50" width="7.5" height="7.5" rx="1.8"/>
<rect x="164" y="263.75" width="7.5" height="7.5" rx="1.8"/>
<rect x="164" y="275.00" width="7.5" height="7.5" rx="1.8"/>
<rect x="113" y="305" width="79" height="60" rx="12"/>
<rect x="181" y="309.00" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="320.25" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="331.50" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="342.75" width="7.5" height="7.5" rx="1.8"/>
<rect x="181" y="354.00" width="7.5" height="7.5" rx="1.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

+372 -113
View File
@@ -1,23 +1,47 @@
//! ffmpeg concat-demuxer worker with honest, real-time progress.
//! ffmpeg concat-demuxer worker with honest, real-time progress, plus a
//! history record for every job.
//!
//! Mirrors the proven logic from the Python original: stream-copy (`-c copy`),
//! `+faststart`, and a progress bar that is capped at 99 % until ffmpeg emits
//! `progress=end`, at which point it snaps to 100 % and shows "Finalising…".
//! Stream copy only (`-c copy`), so a merge is purely I/O bound: on local NVMe
//! it runs at ~95 % of a plain file copy. `+faststart` is opt-in because it
//! makes ffmpeg re-read and re-write the entire output after the copy - measured
//! at 17-56 % of a local run and 60 % of a NAS run - and only helps web
//! streaming, not local playback.
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use crate::tools::{fmt_dur, hide_window, probe_duration};
use crate::history::{self, Entry, InputFile};
use crate::tools::{fmt_dur, hide_window, probe_many};
use crate::AppState;
/// Error string the UI recognises as "stopped by the user".
pub const CANCELLED: &str = "__cancelled__";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConcatRequest {
pub files: Vec<String>,
pub output: String,
/// Durations the UI already probed, in `files` order. Missing or zero
/// entries are probed here; complete ones skip ffprobe entirely.
#[serde(default)]
pub durations: Vec<f64>,
#[serde(default)]
pub faststart: bool,
#[serde(default)]
pub delete_originals: bool,
#[serde(default)]
pub renamed: bool,
}
#[derive(Clone, Serialize)]
pub struct ConcatProgress {
pub pct: f64,
@@ -32,68 +56,208 @@ pub struct ConcatResult {
pub output: String,
pub size_mb: f64,
pub elapsed: f64,
pub deleted: usize,
}
fn emit(app: &AppHandle, p: ConcatProgress) {
let _ = app.emit("concat-progress", p);
}
/// Build the ffmpeg concat list file (UTF-8, forward slashes, escaped quotes).
fn write_list(files: &[String], out_dir: &Path) -> Result<std::path::PathBuf, String> {
let list_path = out_dir.join(format!(".stream_studio_{}.txt", std::process::id()));
fn unix_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// A path the way the concat demuxer wants it: forward slashes, single quotes
/// escaped, and the Windows extended-length prefixes canonicalize() adds
/// stripped. UNC paths come back as `\\?\UNC\server\share` -> `//?/UNC/server/share`,
/// which must collapse to `//server/share` (a real network path), not the
/// bare `UNC/server/share` a blanket strip would leave behind.
fn ffconcat_path(abs: &str) -> String {
abs.replace('\\', "/")
.replace('\'', r"'\''")
.replace("//?/UNC/", "//")
.replace("//?/", "")
}
/// Where the concat list of a job run by process `pid` lives.
fn list_path(out_dir: &Path, pid: u32) -> PathBuf {
out_dir.join(format!(".video_concat_{pid}.txt"))
}
/// A crash skips the list-file cleanup, leaving it hidden next to the output.
/// The history id is `<started>-<pid>`, which is enough to find exactly that
/// file and nothing else.
pub fn remove_stale_list(entry: &Entry) {
let pid = entry.id.rsplit('-').next().and_then(|p| p.parse::<u32>().ok());
if let (Some(pid), Some(dir)) = (pid, Path::new(&entry.output).parent()) {
let _ = std::fs::remove_file(list_path(dir, pid));
}
}
/// Build the ffmpeg concat list file next to the output.
fn write_list(files: &[String], out_dir: &Path) -> Result<PathBuf, String> {
let list_path = list_path(out_dir, std::process::id());
let mut body = String::new();
for f in files {
let abs = std::fs::canonicalize(f).unwrap_or_else(|_| Path::new(f).to_path_buf());
let s = abs
.to_string_lossy()
.replace('\\', "/")
.replace("'", r"'\''")
// strip the Windows extended-length prefixes canonicalize() adds.
// UNC paths come back as \\?\UNC\server\share -> //?/UNC/server/share,
// which must collapse to //server/share (a real network path), not the
// bare "UNC/server/share" that a blanket strip would leave behind.
.replace("//?/UNC/", "//")
.replace("//?/", "");
body.push_str(&format!("file '{s}'\n"));
body.push_str(&format!("file '{}'\n", ffconcat_path(&abs.to_string_lossy())));
}
std::fs::write(&list_path, body).map_err(|e| format!("Could not write list file: {e}"))?;
Ok(list_path)
}
/// Use the durations the UI already has; probe only the ones it doesn't.
fn resolve_durations(
files: &[String],
given: &[f64],
probe: impl Fn(&[String]) -> Vec<f64>,
) -> Vec<f64> {
let missing: Vec<usize> =
(0..files.len()).filter(|&i| given.get(i).is_none_or(|d| *d <= 0.0)).collect();
let mut out: Vec<f64> = (0..files.len()).map(|i| given.get(i).copied().unwrap_or(0.0)).collect();
if !missing.is_empty() {
let paths: Vec<String> = missing.iter().map(|&i| files[i].clone()).collect();
for (&i, d) in missing.iter().zip(probe(&paths)) {
out[i] = d;
}
}
out
}
/// Delete the originals after a successful merge - but never the output
/// itself, whatever path spelling it arrived under.
fn delete_originals(files: &[String], output: &str) -> usize {
let out = std::fs::canonicalize(output).ok();
files
.iter()
.filter(|f| {
let is_output = out.is_some() && std::fs::canonicalize(f).ok() == out;
!is_output && std::fs::remove_file(f).is_ok()
})
.count()
}
pub fn run(
app: &AppHandle,
state: &AppState,
ffmpeg: &str,
ffprobe: &str,
files: Vec<String>,
output: String,
req: ConcatRequest,
) -> Result<ConcatResult, String> {
state.cancel.store(false, std::sync::atomic::Ordering::SeqCst);
state.cancel.store(false, Ordering::SeqCst);
let out_dir = Path::new(&req.output).parent().ok_or("Invalid output path")?.to_path_buf();
let out_path = Path::new(&output);
let out_dir = out_path.parent().ok_or("Invalid output path")?.to_path_buf();
if req.durations.len() != req.files.len() || req.durations.iter().any(|d| *d <= 0.0) {
emit(app, ConcatProgress { pct: 0.0, status: "Scanning durations…".into(), elapsed: 0.0, eta: -1.0, speed: 0.0 });
}
let durations = resolve_durations(&req.files, &req.durations, |p| probe_many(ffprobe, p));
let total_secs: f64 = durations.iter().sum();
emit(app, ConcatProgress { pct: 0.0, status: "Scanning durations…".into(), elapsed: 0.0, eta: -1.0, speed: 0.0 });
let total_secs: f64 = files.iter().map(|f| probe_duration(ffprobe, f)).sum();
let started = unix_ms();
let mut entry = Entry {
id: format!("{started}-{}", std::process::id()),
started,
status: "interrupted".into(),
error: None,
inputs: req
.files
.iter()
.zip(&durations)
.map(|(p, d)| InputFile {
path: p.clone(),
size: std::fs::metadata(p).map(|m| m.len()).unwrap_or(0),
duration: *d,
})
.collect(),
output: req.output.clone(),
output_size: 0,
faststart: req.faststart,
elapsed: 0.0,
mb_per_s: 0.0,
deleted_originals: 0,
renamed: req.renamed,
};
if let Some(d) = &state.data_dir {
history::begin(d, &entry);
}
let list_path = write_list(&files, &out_dir)?;
let t0 = Instant::now();
let outcome = execute(app, state, ffmpeg, &req, &out_dir, total_secs, t0);
entry.elapsed = t0.elapsed().as_secs_f64();
let result = match outcome {
Ok(()) => {
entry.status = "done".into();
entry.output_size = std::fs::metadata(&req.output).map(|m| m.len()).unwrap_or(0);
let size_mb = entry.output_size as f64 / 1_048_576.0;
entry.mb_per_s = size_mb / entry.elapsed.max(1e-9);
if req.delete_originals && entry.output_size > 0 {
entry.deleted_originals = delete_originals(&req.files, &req.output);
}
Ok(ConcatResult {
output: req.output.clone(),
size_mb,
elapsed: entry.elapsed,
deleted: entry.deleted_originals,
})
}
Err(e) if e == CANCELLED => {
entry.status = "cancelled".into();
Err(e)
}
Err(e) => {
entry.status = "failed".into();
entry.set_error(&e);
Err(e)
}
};
if let Some(d) = &state.data_dir {
history::finish(d, &entry);
}
if let Ok(r) = &result {
emit(app, ConcatProgress {
pct: 1.0,
status: format!("Done — {} · {:.1} MB", fmt_dur(r.elapsed), r.size_mb),
elapsed: r.elapsed,
eta: 0.0,
speed: 0.0,
});
}
result
}
/// Run ffmpeg and stream its progress to the UI.
fn execute(
app: &AppHandle,
state: &AppState,
ffmpeg: &str,
req: &ConcatRequest,
out_dir: &Path,
total_secs: f64,
t0: Instant,
) -> Result<(), String> {
let list_path = write_list(&req.files, out_dir)?;
let mut cmd = Command::new(ffmpeg);
cmd.args([
"-y",
"-f", "concat", "-safe", "0",
"-i", &list_path.to_string_lossy(),
"-c:v", "copy", "-c:a", "copy",
"-movflags", "+faststart",
"-nostdin", "-hide_banner",
"-loglevel", "error",
"-progress", "pipe:1",
&output,
]);
cmd.args(["-y", "-f", "concat", "-safe", "0", "-i"])
.arg(&list_path)
.args(["-c:v", "copy", "-c:a", "copy"]);
if req.faststart {
cmd.args(["-movflags", "+faststart"]);
}
cmd.args(["-nostdin", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1"])
.arg(&req.output);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
hide_window(&mut cmd);
let mut child = cmd.spawn().map_err(|e| format!("Failed to launch ffmpeg: {e}"))?;
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let _ = std::fs::remove_file(&list_path);
return Err(format!("Failed to launch ffmpeg: {e}"));
}
};
let stdout = child.stdout.take().ok_or("No ffmpeg stdout")?;
let stderr = child.stderr.take();
@@ -101,73 +265,79 @@ pub fn run(
*state.proc.lock().unwrap() = Some(child);
emit(app, ConcatProgress { pct: 0.0, status: "Concatenating…".into(), elapsed: 0.0, eta: -1.0, speed: 0.0 });
let t0 = Instant::now();
let mut cur_us: i64 = 0;
// When ffmpeg finishes the copy it still has to relocate the moov atom for
// +faststart (a full-file rewrite that can take a while on large outputs).
// We hold the bar at 99 % and run this heartbeat so "Finalising…" stays
// visibly alive instead of looking frozen, and 100 % means *actually done*.
// With +faststart, ffmpeg goes silent while it rewrites the file to move
// the index to the front - and current builds only print progress=end
// *after* that rewrite, so waiting for it never works. Nor does "last
// report was past 95 %": reports come every ~0.5 s, so the last one before
// the silence can land anywhere from ~93 % up. Instead the watchdog
// extrapolates from the last position and speed: once ffmpeg must have
// passed the end and it's still quiet, it is finalising.
let fin = Arc::new(AtomicBool::new(false));
let mut heartbeat: Option<thread::JoinHandle<()>> = None;
let last_played = Arc::new(AtomicU64::new(0f64.to_bits()));
let last_speed = Arc::new(AtomicU64::new(0f64.to_bits()));
let last_tick = Arc::new(Mutex::new(Instant::now()));
let watchdog = {
let (appc, finc, tickc) = (app.clone(), fin.clone(), last_tick.clone());
let (playedc, speedc) = (last_played.clone(), last_speed.clone());
thread::spawn(move || {
while !finc.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(500));
let played = f64::from_bits(playedc.load(Ordering::SeqCst));
let speed = f64::from_bits(speedc.load(Ordering::SeqCst));
let quiet = tickc.lock().map(|t| t.elapsed()).unwrap_or_default();
let projected = played + speed * quiet.as_secs_f64();
if total_secs > 0.0
&& played > 0.0
&& projected >= total_secs
&& quiet >= Duration::from_millis(1000)
&& !finc.load(Ordering::SeqCst)
{
emit(&appc, ConcatProgress {
pct: 0.99,
status: "Finalising output file…".into(),
elapsed: t0.elapsed().as_secs_f64(),
eta: -1.0,
speed: 0.0,
});
}
}
})
};
let mut cur_us: i64 = 0;
for line in BufReader::new(stdout).lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let Ok(line) = line else { break };
let line = line.trim();
if let Some(v) = line.strip_prefix("out_time_us=").or_else(|| line.strip_prefix("out_time_ms=")) {
if let Ok(n) = v.parse::<i64>() {
cur_us = n; // ffmpeg reports microseconds under both keys
}
} else if line == "progress=end" {
// Copy is done; the +faststart rewrite happens now, during which
// ffmpeg emits nothing. Spawn a heartbeat to keep the UI honest.
if heartbeat.is_none() {
let appc = app.clone();
let finc = fin.clone();
heartbeat = Some(thread::spawn(move || {
while !finc.load(Ordering::SeqCst) {
let elapsed = t0.elapsed().as_secs_f64();
emit(&appc, ConcatProgress {
pct: 0.99,
status: "Finalising output file…".into(),
elapsed,
eta: -1.0,
speed: 0.0,
});
thread::sleep(Duration::from_millis(700));
}
}));
}
} else if line.starts_with("progress=continue") {
if total_secs > 0.0 && cur_us > 0 {
let played = cur_us as f64 / 1_000_000.0;
let pct = (played / total_secs).clamp(0.0, 0.99);
let elapsed = t0.elapsed().as_secs_f64();
let speed = played / elapsed.max(1e-9);
let eta = (total_secs - played) / speed.max(1e-9);
emit(app, ConcatProgress {
pct,
status: "Concatenating…".into(),
elapsed,
eta,
speed,
});
} else if line == "progress=continue" && total_secs > 0.0 && cur_us > 0 {
// progress=end only arrives after any faststart rewrite, right
// before exit - reporting "Concatenating…" then would be wrong
let played = cur_us as f64 / 1_000_000.0;
let pct = (played / total_secs).clamp(0.0, 0.99);
let elapsed = t0.elapsed().as_secs_f64();
let speed = played / elapsed.max(1e-9);
last_played.store(played.to_bits(), Ordering::SeqCst);
last_speed.store(speed.to_bits(), Ordering::SeqCst);
if let Ok(mut t) = last_tick.lock() {
*t = Instant::now();
}
emit(app, ConcatProgress {
pct,
status: "Concatenating…".into(),
elapsed,
eta: (total_secs - played) / speed.max(1e-9),
speed,
});
}
}
// ffmpeg's stdout has closed — the +faststart rewrite is done too. Stop the
// finalisation heartbeat before we reclaim the child and report 100 %.
fin.store(true, Ordering::SeqCst);
if let Some(h) = heartbeat.take() {
let _ = h.join();
}
let _ = watchdog.join();
// Reclaim the child and finish.
let mut child = state.proc.lock().unwrap().take().ok_or("Process vanished")?;
let status = child.wait().map_err(|e| format!("ffmpeg wait failed: {e}"))?;
let mut stderr_text = String::new();
@@ -175,29 +345,118 @@ pub fn run(
use std::io::Read;
let _ = se.read_to_string(&mut stderr_text);
}
let _ = std::fs::remove_file(&list_path);
if state.cancel.load(std::sync::atomic::Ordering::SeqCst) {
let _ = std::fs::remove_file(&output);
return Err("__cancelled__".into());
if state.cancel.load(Ordering::SeqCst) {
let _ = std::fs::remove_file(&req.output);
return Err(CANCELLED.into());
}
if !status.success() {
let _ = std::fs::remove_file(&output);
let _ = std::fs::remove_file(&req.output);
let msg = stderr_text.trim();
return Err(if msg.is_empty() { "Unknown ffmpeg error.".into() } else { msg.to_string() });
}
Ok(())
}
let elapsed = t0.elapsed().as_secs_f64();
let size_mb = std::fs::metadata(&output).map(|m| m.len() as f64 / 1_048_576.0).unwrap_or(0.0);
emit(app, ConcatProgress {
pct: 1.0,
status: format!("Done — {} · {:.1} MB", fmt_dur(elapsed), size_mb),
elapsed,
eta: 0.0,
speed: 0.0,
});
Ok(ConcatResult { output, size_mb, elapsed })
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
#[test]
fn ffconcat_path_normalises_plain_windows_paths() {
assert_eq!(ffconcat_path(r"D:\rec\a.flv"), "D:/rec/a.flv");
}
#[test]
fn ffconcat_path_strips_the_extended_length_prefix() {
assert_eq!(ffconcat_path(r"\\?\D:\rec\a.flv"), "D:/rec/a.flv");
}
#[test]
fn ffconcat_path_keeps_unc_paths_as_network_paths() {
assert_eq!(ffconcat_path(r"\\?\UNC\TRUENAS\share\a.flv"), "//TRUENAS/share/a.flv");
}
#[test]
fn ffconcat_path_escapes_single_quotes() {
assert_eq!(ffconcat_path(r"D:\rec\it's live.flv"), r"D:/rec/it'\''s live.flv");
}
#[test]
fn resolve_durations_skips_ffprobe_when_the_ui_has_them_all() {
let files = vec!["a".to_string(), "b".to_string()];
let calls = Cell::new(0);
let got = resolve_durations(&files, &[60.0, 30.5], |p| {
calls.set(calls.get() + 1);
vec![0.0; p.len()]
});
assert_eq!(got, [60.0, 30.5]);
assert_eq!(calls.get(), 0);
}
#[test]
fn resolve_durations_probes_only_the_gaps() {
let files: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
let got = resolve_durations(&files, &[60.0, 0.0, 45.0], |p| {
assert_eq!(p, ["b", "d"]);
vec![10.0, 20.0]
});
assert_eq!(got, [60.0, 10.0, 45.0, 20.0]);
}
#[test]
fn remove_stale_list_deletes_only_the_crashed_jobs_list() {
let d = std::env::temp_dir().join(format!("vc_stale_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let stale = list_path(&d, 4242);
let other = list_path(&d, 777); // a different job's list: leave it
let video = d.join("keep.mp4");
for p in [&stale, &other, &video] {
std::fs::write(p, b"x").unwrap();
}
let entry = Entry {
id: "1758780000000-4242".into(),
started: 0,
status: "interrupted".into(),
error: None,
inputs: vec![],
output: d.join("out.mp4").to_string_lossy().into_owned(),
output_size: 0,
faststart: false,
elapsed: 0.0,
mb_per_s: 0.0,
deleted_originals: 0,
renamed: false,
};
remove_stale_list(&entry);
assert!(!stale.exists());
assert!(other.exists() && video.exists());
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn delete_originals_never_deletes_the_output() {
let d = std::env::temp_dir().join(format!("vc_delete_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let a = d.join("a.flv");
let b = d.join("b.flv");
let out = d.join("out.mp4");
for p in [&a, &b, &out] {
std::fs::write(p, b"x").unwrap();
}
let files: Vec<String> = [&a, &b, &out].iter().map(|p| p.to_string_lossy().into_owned()).collect();
// the output listed again under a different spelling must survive too
let out_alias = d.join(".").join("out.mp4").to_string_lossy().into_owned();
let mut with_alias = files.clone();
with_alias.push(out_alias);
assert_eq!(delete_originals(&with_alias, &out.to_string_lossy()), 2);
assert!(!a.exists() && !b.exists());
assert!(out.exists(), "output was deleted");
std::fs::remove_dir_all(&d).unwrap();
}
}
+247
View File
@@ -0,0 +1,247 @@
//! Concat history, kept in `data/history.jsonl` inside the app folder.
//!
//! One JSON object per line, appended when a job ends. While a job runs, its
//! entry also sits in `data/running.json`; if the app dies mid-merge that file
//! survives, and the next launch files it as `interrupted`. Append-only lines
//! mean a crash can at worst lose the job in flight, never earlier history,
//! and a corrupt line is skipped instead of taking the whole file down.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const HISTORY: &str = "history.jsonl";
const RUNNING: &str = "running.json";
const MAX_ERROR: usize = 2000;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InputFile {
pub path: String,
pub size: u64,
pub duration: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub id: String,
/// Unix epoch, milliseconds.
pub started: u64,
/// `done` | `failed` | `cancelled` | `interrupted`
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub inputs: Vec<InputFile>,
pub output: String,
#[serde(default)]
pub output_size: u64,
#[serde(default)]
pub faststart: bool,
/// Seconds ffmpeg ran for.
#[serde(default)]
pub elapsed: f64,
/// Output bytes / elapsed, in MB/s. 0 unless the job finished.
#[serde(default)]
pub mb_per_s: f64,
/// How many originals were deleted after the merge.
#[serde(default)]
pub deleted_originals: usize,
/// Whether the BiliBili rename was applied to the output name.
#[serde(default)]
pub renamed: bool,
}
impl Entry {
pub fn set_error(&mut self, msg: &str) {
let msg = msg.trim();
self.error = Some(if msg.chars().count() > MAX_ERROR {
format!("{}…", msg.chars().take(MAX_ERROR).collect::<String>())
} else {
msg.to_string()
});
}
}
/// An entry as the UI sees it: what was recorded plus what is true right now.
#[derive(Serialize)]
pub struct Item {
#[serde(flatten)]
pub entry: Entry,
pub output_exists: bool,
/// Per input, in order: is the file still on disk?
pub inputs_exist: Vec<bool>,
}
fn history_path(dir: &Path) -> PathBuf {
dir.join(HISTORY)
}
fn running_path(dir: &Path) -> PathBuf {
dir.join(RUNNING)
}
/// Parse history text, skipping blank or corrupt lines. Newest first.
pub fn parse(text: &str) -> Vec<Entry> {
let mut out: Vec<Entry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
out.reverse();
out
}
pub fn append(dir: &Path, entry: &Entry) -> std::io::Result<()> {
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
let mut f = OpenOptions::new().create(true).append(true).open(history_path(dir))?;
writeln!(f, "{line}")
}
pub fn load(dir: &Path) -> Vec<Entry> {
std::fs::read_to_string(history_path(dir)).map(|t| parse(&t)).unwrap_or_default()
}
/// Mark a job as in flight. Its status is already `interrupted`, so if the
/// app never reaches `finish`, `recover` can file it without editing.
pub fn begin(dir: &Path, entry: &Entry) {
if let Ok(json) = serde_json::to_string(entry) {
let _ = std::fs::write(running_path(dir), json);
}
}
/// Record the finished job and clear the in-flight marker.
pub fn finish(dir: &Path, entry: &Entry) {
let _ = append(dir, entry);
let _ = std::fs::remove_file(running_path(dir));
}
/// On startup: file a job left over from a crash as `interrupted`.
pub fn recover(dir: &Path) -> Option<Entry> {
let path = running_path(dir);
let text = std::fs::read_to_string(&path).ok()?;
let _ = std::fs::remove_file(&path);
let mut entry: Entry = serde_json::from_str(&text).ok()?;
entry.status = "interrupted".into();
append(dir, &entry).ok()?;
Some(entry)
}
pub fn clear(dir: &Path) -> bool {
match std::fs::remove_file(history_path(dir)) {
Ok(()) => true,
Err(e) => e.kind() == std::io::ErrorKind::NotFound,
}
}
pub fn items(dir: &Path) -> Vec<Item> {
load(dir)
.into_iter()
.map(|entry| Item {
output_exists: Path::new(&entry.output).is_file(),
inputs_exist: entry.inputs.iter().map(|i| Path::new(&i.path).is_file()).collect(),
entry,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc_history_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn entry(id: &str) -> Entry {
Entry {
id: id.into(),
started: 1_758_780_000_000,
status: "interrupted".into(),
error: None,
inputs: vec![InputFile { path: r"D:\rec\a.flv".into(), size: 10, duration: 60.0 }],
output: r"D:\rec\a_concat.mp4".into(),
output_size: 0,
faststart: false,
elapsed: 0.0,
mb_per_s: 0.0,
deleted_originals: 0,
renamed: false,
}
}
#[test]
fn roundtrips_through_a_line() {
let mut e = entry("1");
e.status = "done".into();
e.output_size = 1_705_000_000;
e.mb_per_s = 1164.2;
let back = parse(&serde_json::to_string(&e).unwrap());
assert_eq!(back, vec![e]);
}
#[test]
fn parse_skips_junk_and_returns_newest_first() {
let a = serde_json::to_string(&entry("a")).unwrap();
let b = serde_json::to_string(&entry("b")).unwrap();
let text = format!("{a}\n\nnot json at all\n{{\"half\":\n{b}\n");
let ids: Vec<_> = parse(&text).into_iter().map(|e| e.id).collect();
assert_eq!(ids, ["b", "a"]);
}
#[test]
fn old_lines_missing_new_fields_still_load() {
let line = r#"{"id":"x","started":1,"status":"done","inputs":[],"output":"o.mp4"}"#;
let e = &parse(line)[0];
assert_eq!(e.id, "x");
assert!(!e.faststart);
assert_eq!(e.deleted_originals, 0);
}
#[test]
fn errors_are_capped() {
let mut e = entry("1");
e.set_error(&"x".repeat(5000));
assert_eq!(e.error.as_ref().unwrap().chars().count(), MAX_ERROR + 1);
}
#[test]
fn finish_appends_and_clears_the_running_marker() {
let d = tmp("finish");
let mut e = entry("1");
begin(&d, &e);
assert!(running_path(&d).exists());
e.status = "done".into();
finish(&d, &e);
assert!(!running_path(&d).exists());
assert_eq!(load(&d)[0].status, "done");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn a_crashed_job_is_recovered_as_interrupted() {
let d = tmp("recover");
let mut e = entry("crashed");
e.status = "done".into(); // whatever was written, recovery must not trust it
begin(&d, &e);
let got = recover(&d).expect("should recover");
assert_eq!(got.status, "interrupted");
assert!(!running_path(&d).exists());
assert_eq!(load(&d).len(), 1);
assert!(recover(&d).is_none(), "recovering twice must not duplicate");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn clear_empties_history_and_tolerates_no_file() {
let d = tmp("clear");
append(&d, &entry("1")).unwrap();
assert!(clear(&d));
assert!(load(&d).is_empty());
assert!(clear(&d), "clearing an empty history is not an error");
std::fs::remove_dir_all(&d).unwrap();
}
}
+124 -34
View File
@@ -1,25 +1,32 @@
//! Video Concat — Tauri backend.
//! A focused tool: stream-copy concat of split recordings via ffmpeg, with a
//! small command surface to the web UI.
//! small command surface to the web UI. Portable: everything it writes stays
//! inside the app folder (see `portable`).
mod concat;
mod history;
mod portable;
mod tools;
use std::path::PathBuf;
use std::process::Child;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::thread;
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use tauri::{AppHandle, Manager, State, WebviewWindowBuilder};
use concat::ConcatResult;
use concat::{ConcatRequest, ConcatResult};
pub struct AppState {
pub proc: Mutex<Option<Child>>,
pub cancel: AtomicBool,
pub ffmpeg: Option<String>,
pub ffprobe: Option<String>,
/// `<app>/data`, or `None` when the app folder isn't writable.
pub data_dir: Option<PathBuf>,
/// Whether the WebView profile (theme, preferences) lives in the app folder.
pub webview_portable: AtomicBool,
}
impl AppState {
@@ -29,14 +36,19 @@ impl AppState {
cancel: AtomicBool::new(false),
ffmpeg: tools::find_tool("ffmpeg"),
ffprobe: tools::find_tool("ffprobe"),
data_dir: portable::data_dir(),
webview_portable: AtomicBool::new(false),
}
}
}
#[derive(Serialize)]
struct ToolsInfo {
struct AppInfo {
ffmpeg: Option<String>,
ffprobe: Option<String>,
data_dir: Option<String>,
/// True when history *and* the WebView profile are stored in the app folder.
portable: bool,
}
#[derive(Serialize)]
@@ -48,31 +60,23 @@ struct DurationInfo {
// ── Commands ─────────────────────────────────────────────────────────────────
#[tauri::command]
fn get_tools(state: State<AppState>) -> ToolsInfo {
ToolsInfo {
fn app_info(state: State<AppState>) -> AppInfo {
AppInfo {
ffmpeg: state.ffmpeg.clone(),
ffprobe: state.ffprobe.clone(),
data_dir: state.data_dir.as_ref().map(|d| d.to_string_lossy().into_owned()),
portable: state.data_dir.is_some() && state.webview_portable.load(Ordering::SeqCst),
}
}
/// Probe several files in parallel; returns durations in seconds.
#[tauri::command]
fn probe_durations(state: State<AppState>, paths: Vec<String>) -> Vec<DurationInfo> {
let ffprobe = match &state.ffprobe {
Some(p) => p.clone(),
None => return paths.into_iter().map(|p| DurationInfo { path: p, duration: 0.0 }).collect(),
let durations = match &state.ffprobe {
Some(ff) => tools::probe_many(ff, &paths),
None => vec![0.0; paths.len()],
};
let handles: Vec<_> = paths
.into_iter()
.map(|p| {
let ff = ffprobe.clone();
thread::spawn(move || DurationInfo {
duration: tools::probe_duration(&ff, &p),
path: p,
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
paths.into_iter().zip(durations).map(|(path, duration)| DurationInfo { path, duration }).collect()
}
#[tauri::command]
@@ -80,21 +84,22 @@ fn is_video(path: String) -> bool {
tools::is_video(&path)
}
#[tauri::command]
fn path_exists(path: String) -> bool {
std::path::Path::new(&path).exists()
}
// Concat runs ffmpeg for a long time, so it MUST run off the main thread,
// otherwise the native window stops pumping messages and Windows paints the
// app "Not responding". `spawn_blocking` keeps the UI responsive while the
// command still resolves with the final result.
#[tauri::command]
async fn start_concat(
app: AppHandle,
files: Vec<String>,
output: String,
) -> Result<ConcatResult, String> {
async fn start_concat(app: AppHandle, req: ConcatRequest) -> Result<ConcatResult, String> {
tauri::async_runtime::spawn_blocking(move || {
let state = app.state::<AppState>();
let ffmpeg = state.ffmpeg.clone().ok_or("ffmpeg not found.")?;
let ffprobe = state.ffprobe.clone().unwrap_or_else(|| "ffprobe".into());
concat::run(&app, &state, &ffmpeg, &ffprobe, files, output)
concat::run(&app, &state, &ffmpeg, &ffprobe, req)
})
.await
.map_err(|e| format!("concat task failed: {e}"))?
@@ -102,7 +107,7 @@ async fn start_concat(
#[tauri::command]
fn cancel(state: State<AppState>) {
state.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
state.cancel.store(true, Ordering::SeqCst);
if let Some(child) = state.proc.lock().unwrap().as_mut() {
// Kill the whole tree, not just the direct child.
tools::kill_process_tree(child.id());
@@ -111,8 +116,13 @@ fn cancel(state: State<AppState>) {
}
#[tauri::command]
fn delete_files(paths: Vec<String>) -> usize {
paths.iter().filter(|p| std::fs::remove_file(p).is_ok()).count()
fn history_list(state: State<AppState>) -> Vec<history::Item> {
state.data_dir.as_deref().map(history::items).unwrap_or_default()
}
#[tauri::command]
fn history_clear(state: State<AppState>) -> bool {
state.data_dir.as_deref().is_some_and(history::clear)
}
/// Convert a BiliBili Live recording stem to a readable name, or null.
@@ -143,24 +153,104 @@ fn bililive_rename(stem: String) -> Option<String> {
Some(format!("{y}年{m}月{d}日【直播回放】{title}"))
}
/// Create the main window ourselves (it is `create: false` in tauri.conf.json)
/// so its WebView2 profile lands in `<app>/data/webview` instead of
/// `%LOCALAPPDATA%`. If that fails - read-only folder, or a location WebView2
/// refuses - fall back to the default profile so the app still opens.
fn create_main_window(app: &tauri::App, data_dir: Option<&PathBuf>) -> tauri::Result<bool> {
let cfg = app
.config()
.app
.windows
.iter()
.find(|w| w.label == "main")
.cloned()
.expect("tauri.conf.json must define the main window");
if let Some(dir) = data_dir {
let built = WebviewWindowBuilder::from_config(app.handle(), &cfg)?
.data_directory(dir.join("webview"))
.build();
if built.is_ok() {
return Ok(true);
}
}
WebviewWindowBuilder::from_config(app.handle(), &cfg)?.build()?;
Ok(false)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
app.manage(AppState::new());
let state = AppState::new();
if let Some(entry) = state.data_dir.as_deref().and_then(history::recover) {
concat::remove_stale_list(&entry);
}
let portable_web = create_main_window(app, state.data_dir.as_ref())?;
state.webview_portable.store(portable_web, Ordering::SeqCst);
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_tools,
app_info,
probe_durations,
is_video,
path_exists,
start_concat,
cancel,
delete_files,
history_list,
history_clear,
bililive_rename,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::bililive_rename as rename;
#[test]
fn renames_a_bililive_stem() {
assert_eq!(
rename("[2026-03-14 22-46-37][纳豆nado][今晚九点来捏!]".into()).as_deref(),
Some("2026年03月14日【直播回放】今晚九点来捏!")
);
}
#[test]
fn ignores_the_concat_suffix_and_padding() {
assert_eq!(
rename(" [2026-03-14 22-46-37][up][title]_concat".into()).as_deref(),
Some("2026年03月14日【直播回放】title")
);
}
#[test]
fn title_may_contain_brackets_of_its_own() {
assert_eq!(
rename("[2026-07-08 21-00-00][up][part [1] 【随机专场】]".into()).as_deref(),
Some("2026年07月08日【直播回放】part [1] 【随机专场】")
);
}
#[test]
fn rejects_anything_that_is_not_the_bililive_shape() {
for s in [
"",
"plain_name",
"[2026-03-14][up][title]", // no time part
"[26-03-14 22-46-37][up][title]", // 2-digit year
"[2026-3-14 22-46-37][up][title]", // 1-digit month
"[2026-ab-14 22-46-37][up][title]", // non-digit
"[2026-03-14 22-46-37][up][]", // empty title
"[2026-03-14 22-46-37][up]", // no title
"[2026-03-14 22-46-37][up]title", // title not bracketed
] {
assert_eq!(rename(s.into()), None, "should reject {s:?}");
}
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Portable layout: everything the app writes lives next to the exe, so the
//! whole folder can be copied to another machine and keeps working as-is.
//!
//! ```text
//! VideoConcat\
//! Video Concat.exe, ffmpeg.exe, ffprobe.exe
//! data\history.jsonl concat history, one JSON object per line
//! data\running.json the job in flight; left behind = interrupted
//! data\webview\ WebView2 profile (theme and UI preferences)
//! ```
use std::path::{Path, PathBuf};
/// Directory the executable lives in.
pub fn app_dir() -> Option<PathBuf> {
std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
}
/// `<app>/data`, created on demand. `None` when the app folder is read-only
/// (e.g. Program Files or a locked-down USB stick) - callers then run without
/// history rather than silently writing somewhere outside the folder.
pub fn data_dir() -> Option<PathBuf> {
let dir = app_dir()?.join("data");
std::fs::create_dir_all(&dir).ok()?;
writable(&dir).then_some(dir)
}
/// `create_dir_all` succeeding on an existing folder says nothing about write
/// access, so actually try writing.
pub fn writable(dir: &Path) -> bool {
let probe = dir.join(format!(".write_test_{}", std::process::id()));
let ok = std::fs::write(&probe, b"").is_ok();
let _ = std::fs::remove_file(&probe);
ok
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writable_detects_a_usable_folder() {
let dir = std::env::temp_dir().join(format!("vc_portable_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert!(writable(&dir));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "probe file left behind");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn writable_rejects_a_missing_folder() {
let missing = std::env::temp_dir().join(format!("vc_missing_{}", std::process::id())).join("nested");
assert!(!writable(&missing));
}
}
+54 -6
View File
@@ -40,15 +40,10 @@ pub fn kill_process_tree(pid: u32) {
}
}
/// Directory the executable lives in (resources dir when bundled).
fn app_dir() -> Option<PathBuf> {
std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
}
/// Locate ffmpeg / ffprobe: next to the exe, then on PATH.
pub fn find_tool(name: &str) -> Option<String> {
let exe = format!("{name}.exe");
if let Some(base) = app_dir() {
if let Some(base) = crate::portable::app_dir() {
for sub in ["", "bin", "resources", "_internal", ".."] {
let cand = base.join(sub).join(&exe);
if cand.exists() {
@@ -98,6 +93,26 @@ pub fn probe_duration(ffprobe: &str, path: &str) -> f64 {
}
}
/// Probe many files at once, in order. Bounded to a few ffprobe processes at a
/// time - spawning one per file is fine for 6 segments, not for 200.
pub fn probe_many(ffprobe: &str, paths: &[String]) -> Vec<f64> {
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8);
let mut out = vec![0.0; paths.len()];
for (chunk_idx, chunk) in paths.chunks(workers).enumerate() {
let handles: Vec<_> = chunk
.iter()
.map(|p| {
let (ff, p) = (ffprobe.to_string(), p.clone());
std::thread::spawn(move || probe_duration(&ff, &p))
})
.collect();
for (i, h) in handles.into_iter().enumerate() {
out[chunk_idx * workers + i] = h.join().unwrap_or(0.0);
}
}
out
}
/// Format seconds as H:MM:SS or M:SS.
pub fn fmt_dur(seconds: f64) -> String {
if seconds < 0.0 {
@@ -124,3 +139,36 @@ pub fn is_video(path: &str) -> bool {
.map(|e| EXTS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_dur_formats_minutes_and_hours() {
assert_eq!(fmt_dur(0.0), "0:00");
assert_eq!(fmt_dur(59.6), "1:00");
assert_eq!(fmt_dur(3599.0), "59:59");
assert_eq!(fmt_dur(3661.0), "1:01:01");
assert_eq!(fmt_dur(-1.0), "…");
}
#[test]
fn is_video_is_case_insensitive_and_strict() {
assert!(is_video(r"D:\rec\a.FLV"));
assert!(is_video("x.m2ts"));
assert!(!is_video("notes.txt"));
assert!(!is_video("no_extension"));
assert!(!is_video("video.mp4.part"));
}
#[test]
fn probe_many_keeps_order_and_survives_missing_files() {
// no ffprobe needed: a bogus binary makes every probe fail -> 0.0,
// which is exactly the contract callers rely on
let paths: Vec<String> = (0..20).map(|i| format!("missing_{i}.flv")).collect();
let got = probe_many("definitely-not-ffprobe", &paths);
assert_eq!(got.len(), 20);
assert!(got.iter().all(|&d| d == 0.0));
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Video Concat",
"version": "5.0.0",
"version": "5.1.0",
"identifier": "com.youfu.video-concat",
"build": {
"beforeDevCommand": "npm run dev",
@@ -13,6 +13,8 @@
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"create": false,
"title": "Video Concat",
"width": 960,
"height": 820,