v5.1.0: history tab, portable data folder, optional faststart, new icons & build tools
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 78 KiB |
@@ -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 |
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||