diff --git a/README.md b/README.md index dc2122f..41a77b4 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,37 @@ removed, so it needs **no Python and no ML model** — just ffmpeg. ## Features - Drag-and-drop / browse to add videos, reorder, select which to merge. -- Stream-copy concat with honest real-time progress (and a `+faststart` - finalising pass so the output seeks instantly). +- Stream-copy concat with honest real-time progress. On local NVMe a merge runs + at ~95 % of plain file-copy speed — the disk is the limit, not the app. +- **History** tab: every merge (done, failed, stopped, or interrupted by a + crash) with its files, size, time and MB/s. Show the output in its folder, or + load the same files again with "Merge again". - BiliBili-Live filename helper (`[date][uploader][title]` → readable name). - Optionally delete the originals after a successful merge. +- Optional **Web-optimize MP4 (`+faststart`)**, off by default. It moves the + MP4 index to the front so playback can begin mid-download, but ffmpeg has to + re-read and re-write the whole file to do it — measured at 17–56 % extra time + locally and 60 % over a NAS. Local players don't need it. + +## Portable: everything stays in one folder + +Nothing is written outside the app folder — not to `%APPDATA%`, not to the +registry. Copy the folder to another PC and history, theme and options come +with it: + +``` +VideoConcat\ + Video Concat.exe + ffmpeg.exe, ffprobe.exe + data\ + history.jsonl <- merge history, one JSON line per job + running.json <- only while a merge runs; left behind = "interrupted" + webview\ <- WebView2 profile (theme, options) +``` + +`data\` is created on first run. If the folder isn't writable (say, under +Program Files), the app still works but shows a banner and keeps no history. +It needs the WebView2 runtime, which ships with Windows 10 and 11. ## Build a standalone (copy-paste) version @@ -28,15 +55,69 @@ dist-portable\VideoConcat\ ``` Copy that whole `VideoConcat` folder to any Windows PC and run it — nothing else -to install. +to install. To **upgrade** an existing copy, replace only `Video Concat.exe` +and keep its `data\` folder; history carries over. ### Build requirements (on the build machine only) - [Node.js](https://nodejs.org/) - [Rust](https://rustup.rs/) (`winget install Rustlang.Rustup`) - Visual Studio 2022 with the **Desktop development with C++** workload -- ffmpeg (in `C:\ffmpeg\bin` or on `PATH`) +- ffmpeg: in `C:\ffmpeg\bin`, on `PATH`, or point `FFMPEG_DIR` at any folder + holding `ffmpeg.exe` + `ffprobe.exe` (an existing portable copy works: + `set FFMPEG_DIR=%USERPROFILE%\Documents\VideoConcat`) + +Building from a network share is slow and fragile (thousands of small files in +`node_modules` and `target`). Copy the source to a local disk first. ## Develop Double-click **`dev.bat`** (hot-reload UI + Rust). Needs ffmpeg on `PATH`. +In dev mode the `data\` folder sits next to the debug exe in `src-tauri\target\debug\`. + +## Test + +``` +npm run build # the Rust tests embed dist/, so build the UI first +cd src-tauri && cargo test +``` + +Covers the BiliBili rename parser, concat-list path escaping (`\\?\` and UNC +prefixes, quotes), duration reuse, "never delete the output", and history +crash recovery. + +## Benchmark + +`tools/bench_concat.py` times each pipeline stage with the exact ffmpeg flags +the app uses, against the I/O floor of a plain file copy: + +``` +python tools/bench_concat.py --ffmpeg-dir --make 6 --src D:\bench --dst D:\bench\out +``` + +## Icons + +The app icon lives as vector art in `src-tauri/icons/icon.svg` — a rounded-square +gradient tile with the three-filmstrips-into-one-arrow mark. Everything else in +`src-tauri/icons/` is generated from it: + +``` +python tools/make_icons.py +``` + +That rewrites the PNGs and `icon.ico` with 8x supersampling and a real alpha +channel, dropping sub-pixel detail (sprocket holes below 48px, the play-triangle +knockout below 64px) rather than smearing it. Only the Windows/desktop icons are +regenerated; the `android/` and `ios/` subfolders are untouched. + +To fix the icon on an **already-built** exe without recompiling it: + +``` +python tools/patch_exe_icon.py "path\to\Video Concat.exe" +``` + +That rewrites only the `RT_ICON` / `RT_GROUP_ICON` resources — `.text`, `.rdata` +and `.data` come out byte-identical — and keeps a `.bak` alongside. Explorer +caches icons aggressively, so follow it with `ie4uinit.exe -show`. + +Both tools need `pillow` and `numpy`. diff --git a/build.bat b/build.bat index 6be379e..7fac502 100644 --- a/build.bat +++ b/build.bat @@ -16,13 +16,16 @@ cd /d "%~dp0" REM --- 1. Locate ffmpeg.exe + ffprobe.exe -------------------- set "FFMPEG=" set "FFPROBE=" -if exist "C:\ffmpeg\bin\ffmpeg.exe" set "FFMPEG=C:\ffmpeg\bin\ffmpeg.exe" -if exist "C:\ffmpeg\bin\ffprobe.exe" set "FFPROBE=C:\ffmpeg\bin\ffprobe.exe" +REM Optional override: set FFMPEG_DIR=C:\folder\holding\ffmpeg-and-ffprobe +if defined FFMPEG_DIR if exist "%FFMPEG_DIR%\ffmpeg.exe" set "FFMPEG=%FFMPEG_DIR%\ffmpeg.exe" +if defined FFMPEG_DIR if exist "%FFMPEG_DIR%\ffprobe.exe" set "FFPROBE=%FFMPEG_DIR%\ffprobe.exe" +if not defined FFMPEG if exist "C:\ffmpeg\bin\ffmpeg.exe" set "FFMPEG=C:\ffmpeg\bin\ffmpeg.exe" +if not defined FFPROBE if exist "C:\ffmpeg\bin\ffprobe.exe" set "FFPROBE=C:\ffmpeg\bin\ffprobe.exe" if not defined FFMPEG for /f "delims=" %%i in ('where ffmpeg 2^>nul') do if not defined FFMPEG set "FFMPEG=%%i" if not defined FFPROBE for /f "delims=" %%i in ('where ffprobe 2^>nul') do if not defined FFPROBE set "FFPROBE=%%i" -if not defined FFMPEG ( echo [ERROR] ffmpeg.exe not found. Install ffmpeg or place it in C:\ffmpeg\bin & pause & exit /b 1 ) -if not defined FFPROBE ( echo [ERROR] ffprobe.exe not found. Install ffmpeg or place it in C:\ffmpeg\bin & pause & exit /b 1 ) +if not defined FFMPEG ( echo [ERROR] ffmpeg.exe not found. Set FFMPEG_DIR, install ffmpeg, or place it in C:\ffmpeg\bin & pause & exit /b 1 ) +if not defined FFPROBE ( echo [ERROR] ffprobe.exe not found. Set FFMPEG_DIR, install ffmpeg, or place it in C:\ffmpeg\bin & pause & exit /b 1 ) echo Using ffmpeg: !FFMPEG! echo Using ffprobe: !FFPROBE! diff --git a/package.json b/package.json index 033d556..27da679 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "video-concat", "private": true, - "version": "5.0.0", + "version": "5.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ac59eee..fc9c7a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index fb54558..3a94cd8 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 8f14a5a..0168545 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index bd0195d..323ddf2 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 27080c7..3da2052 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index ea35225..f6af811 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 33b1c6b..a083f4f 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index b414c4f..f763801 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index f8e2351..76ad64c 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 5db2617..fa2bcea 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 8778cc2..ee1e7b5 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d38a802..9a81478 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 58032a7..20a3905 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 95b65a8..2d99402 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index fd0ac0c..d65a8ee 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index c316191..2466fe2 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e49a824..9121d7a 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg new file mode 100644 index 0000000..65cb6c2 --- /dev/null +++ b/src-tauri/icons/icon.svg @@ -0,0 +1,54 @@ + + Video Concat + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/src/concat.rs b/src-tauri/src/concat.rs index 583384c..07beabe 100644 --- a/src-tauri/src/concat.rs +++ b/src-tauri/src/concat.rs @@ -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, + 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, + #[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 { - 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 `-`, 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::().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 { + 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, +) -> Vec { + let missing: Vec = + (0..files.len()).filter(|&i| given.get(i).is_none_or(|d| *d <= 0.0)).collect(); + let mut out: Vec = (0..files.len()).map(|i| given.get(i).copied().unwrap_or(0.0)).collect(); + if !missing.is_empty() { + let paths: Vec = 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, - output: String, + req: ConcatRequest, ) -> Result { - 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> = 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::() { 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 = ["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 = [&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(); + } } diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs new file mode 100644 index 0000000..3bc1c17 --- /dev/null +++ b/src-tauri/src/history.rs @@ -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, + pub inputs: Vec, + 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::()) + } 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, +} + +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 { + let mut out: Vec = 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e3decb5..0d7cb75 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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>, pub cancel: AtomicBool, pub ffmpeg: Option, pub ffprobe: Option, + /// `/data`, or `None` when the app folder isn't writable. + pub data_dir: Option, + /// 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, ffprobe: Option, + data_dir: Option, + /// 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) -> ToolsInfo { - ToolsInfo { +fn app_info(state: State) -> 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, paths: Vec) -> Vec { - 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, - output: String, -) -> Result { +async fn start_concat(app: AppHandle, req: ConcatRequest) -> Result { tauri::async_runtime::spawn_blocking(move || { let state = app.state::(); 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) { - 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) { } #[tauri::command] -fn delete_files(paths: Vec) -> usize { - paths.iter().filter(|p| std::fs::remove_file(p).is_ok()).count() +fn history_list(state: State) -> Vec { + state.data_dir.as_deref().map(history::items).unwrap_or_default() +} + +#[tauri::command] +fn history_clear(state: State) -> 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 { 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 `/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 { + 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:?}"); + } + } +} diff --git a/src-tauri/src/portable.rs b/src-tauri/src/portable.rs new file mode 100644 index 0000000..f518cdb --- /dev/null +++ b/src-tauri/src/portable.rs @@ -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 { + std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf()) +} + +/// `/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 { + 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)); + } +} diff --git a/src-tauri/src/tools.rs b/src-tauri/src/tools.rs index eea12f6..a345da6 100644 --- a/src-tauri/src/tools.rs +++ b/src-tauri/src/tools.rs @@ -40,15 +40,10 @@ pub fn kill_process_tree(pid: u32) { } } -/// Directory the executable lives in (resources dir when bundled). -fn app_dir() -> Option { - 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 { 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 { + 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 = (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)); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7be83c2..bd49b03 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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, diff --git a/src/api.ts b/src/api.ts index d44a5be..04805f4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -3,21 +3,56 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -import { open } from "@tauri-apps/plugin-dialog"; +import { ask, open } from "@tauri-apps/plugin-dialog"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; -export interface Tools { ffmpeg: string | null; ffprobe: string | null; } +export interface AppInfo { + ffmpeg: string | null; + ffprobe: string | null; + data_dir: string | null; + /** History and the WebView profile both live inside the app folder. */ + portable: boolean; +} export interface DurationInfo { path: string; duration: number; } -export interface ConcatResult { output: string; size_mb: number; elapsed: number; } +export interface ConcatRequest { + files: string[]; + output: string; + durations: number[]; + faststart: boolean; + deleteOriginals: boolean; + renamed: boolean; +} +export interface ConcatResult { output: string; size_mb: number; elapsed: number; deleted: number; } export interface ConcatProgress { pct: number; status: string; elapsed: number; eta: number; speed: number; } -export const getTools = () => invoke("get_tools"); +export type HistoryStatus = "done" | "failed" | "cancelled" | "interrupted"; +export interface HistoryInput { path: string; size: number; duration: number; } +export interface HistoryItem { + id: string; + started: number; // unix ms + status: HistoryStatus; + error?: string; + inputs: HistoryInput[]; + output: string; + output_size: number; + faststart: boolean; + elapsed: number; + mb_per_s: number; + deleted_originals: number; + renamed: boolean; + output_exists: boolean; + /** Per input, in order: is the file still on disk? */ + inputs_exist: boolean[]; +} + +export const appInfo = () => invoke("app_info"); export const probeDurations = (paths: string[]) => invoke("probe_durations", { paths }); export const isVideo = (path: string) => invoke("is_video", { path }); -export const startConcat = (files: string[], output: string) => - invoke("start_concat", { files, output }); +export const pathExists = (path: string) => invoke("path_exists", { path }); +export const startConcat = (req: ConcatRequest) => invoke("start_concat", { req }); export const cancel = () => invoke("cancel"); -export const deleteFiles = (paths: string[]) => invoke("delete_files", { paths }); +export const historyList = () => invoke("history_list"); +export const historyClear = () => invoke("history_clear"); export const bililiveRename = (stem: string) => invoke("bililive_rename", { stem }); export const onConcatProgress = (cb: (p: ConcatProgress) => void): Promise => @@ -42,4 +77,7 @@ export async function pickFolder(): Promise { return typeof sel === "string" ? sel : null; } +export const confirmAsk = (message: string, title = "Video Concat") => + ask(message, { title, kind: "warning" }); + export const reveal = (path: string) => revealItemInDir(path); diff --git a/src/filelist.ts b/src/filelist.ts index be6fdff..92aadaf 100644 --- a/src/filelist.ts +++ b/src/filelist.ts @@ -49,6 +49,11 @@ export class FileList { return this.items.filter((i) => i.checked).map((i) => i.path); } + /** Durations for checkedPaths(), 0 where still unknown (the backend probes those). */ + checkedDurations(): number[] { + return this.items.filter((i) => i.checked).map((i) => Math.max(i.duration, 0)); + } + orderedPaths(): string[] { return this.items.map((i) => i.path); } diff --git a/src/main.ts b/src/main.ts index 78e78bc..21a660e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,56 +1,83 @@ -// Video Concat — app shell: title bar, theme, drag-drop. +// Video Concat — app shell: title bar, tabs, theme, drag-drop. import "./styles.css"; import { h } from "./dom"; import { toast } from "./ui"; -import { getTools, onFilesDropped } from "./api"; +import { appInfo, onFilesDropped, type AppInfo } from "./api"; import { buildConcat } from "./views/concat"; +import { buildHistory } from "./views/history"; async function boot() { // ── Theme (system default, persisted toggle) ──────────────────────────── - const saved = localStorage.getItem("theme"); + // localStorage lives in the WebView profile, which the backend keeps in + // /data/webview - so the theme travels with the app folder too. + let saved: string | null = null; + try { saved = localStorage.getItem("theme"); } catch { /* not persisted */ } const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches; let theme = saved ?? (prefersDark ? "dark" : "light"); const applyTheme = () => document.documentElement.setAttribute("data-theme", theme); applyTheme(); - // ── Single view: concat ───────────────────────────────────────────────── - const concat = buildConcat(); + let info: AppInfo | null = null; + try { info = await appInfo(); } + catch (e) { toast(`Backend not ready: ${e}`, "err"); } + + // ── Views ─────────────────────────────────────────────────────────────── + const history = buildHistory({ + enabled: () => !!info?.data_dir, + onMergeAgain: async (paths) => { show("merge"); await concat.load(paths); }, + }); + const concat = buildConcat({ onFinished: () => history.refresh() }); + + const tabs = { + merge: h("button", { class: "tab active" }, "Merge") as HTMLButtonElement, + history: h("button", { class: "tab" }, "History") as HTMLButtonElement, + }; + function show(which: keyof typeof tabs) { + concat.el.hidden = which !== "merge"; + history.el.hidden = which !== "history"; + tabs.merge.classList.toggle("active", which === "merge"); + tabs.history.classList.toggle("active", which === "history"); + if (which === "history") history.refresh(); + } + tabs.merge.addEventListener("click", () => show("merge")); + tabs.history.addEventListener("click", () => show("history")); const themeBtn = h("button", { class: "icon-btn", title: "Toggle theme" }, theme === "dark" ? "☀" : "☾") as HTMLButtonElement; themeBtn.addEventListener("click", () => { theme = theme === "dark" ? "light" : "dark"; - localStorage.setItem("theme", theme); + try { localStorage.setItem("theme", theme); } catch { /* not persisted */ } applyTheme(); themeBtn.textContent = theme === "dark" ? "☀" : "☾"; }); + const content = h("div", { class: "content" }, concat.el, history.el); const app = document.getElementById("app")!; app.append( h("div", { class: "titlebar" }, h("div", { class: "brand" }, h("div", { class: "logo" }, "🎬"), h("span", {}, "Video Concat")), + h("div", { class: "tabs", style: "margin:0 0 0 12px" }, tabs.merge, tabs.history), h("div", { class: "spacer" }), themeBtn, ), - h("div", { class: "content" }, concat.el), + content, ); - // ── Drag-and-drop routes to the concat view ───────────────────────────── - await onFilesDropped((paths) => concat.onDrop(paths)); + // ── Drag-and-drop always lands in Merge ───────────────────────────────── + await onFilesDropped((paths) => { show("merge"); concat.onDrop(paths); }); - // ── ffmpeg health check ───────────────────────────────────────────────── - try { - const tools = await getTools(); - if (!tools.ffmpeg) { - const banner = h("div", { class: "banner", style: "margin:0 22px 12px" }, - "⚠ ffmpeg was not found. Put ffmpeg.exe & ffprobe.exe next to the app or on your PATH."); - app.querySelector(".content")!.before(banner); - } - } catch (e) { - toast(`Backend not ready: ${e}`, "err"); + // ── Health checks ─────────────────────────────────────────────────────── + if (info && !info.ffmpeg) { + content.before(h("div", { class: "banner", style: "margin:0 22px 12px" }, + "⚠ ffmpeg was not found. Put ffmpeg.exe & ffprobe.exe next to the app or on your PATH.")); + } + if (info && info.data_dir && !info.portable) { + content.before(h("div", { class: "banner", style: "margin:0 22px 12px" }, + "⚠ Settings are being kept outside the app folder (WebView2 refused the data folder). " + + "History still works; theme and options won't travel with the folder.")); } } diff --git a/src/styles.css b/src/styles.css index 3ff2b8b..d899f22 100644 --- a/src/styles.css +++ b/src/styles.css @@ -305,3 +305,51 @@ button, input, select { font-family: inherit; font-size: inherit; color: inherit } .hint { font-size: 12px; color: var(--text-faint); } .rename-preview { font-size: 12px; color: var(--accent); min-height: 16px; } + +/* ── History ──────────────────────────────────────────────────────────── */ +.btn.sm { padding: 5px 10px; font-size: 12px; } +.btn.ghost.danger:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); } +.hist-list { display: flex; flex-direction: column; gap: 8px; } +.hist-row { + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface-2); padding: 10px 12px; + display: flex; flex-direction: column; gap: 6px; +} +.hist-top { display: flex; align-items: center; gap: 10px; } +.hist-name { flex: 1; min-width: 0; font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hist-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; + font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; } +.hist-error { + font-family: "Cascadia Code", "Consolas", monospace; font-size: 11.5px; + color: var(--danger); white-space: pre-wrap; user-select: text; + max-height: 4.6em; overflow-y: auto; +} +.hist-actions { display: flex; gap: 4px; margin-left: -8px; } +.hist-files { display: flex; flex-direction: column; gap: 2px; font-size: 12px; + border-top: 1px solid var(--border); padding-top: 6px; } +.hist-files[hidden] { display: none; } +.hist-file { display: grid; grid-template-columns: 26px 1fr auto 72px; gap: 8px; padding: 2px 0; } +.hist-file .idx { color: var(--text-faint); text-align: center; } +.hist-file .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hist-file .dur { text-align: right; color: var(--text-dim); font-variant-numeric: tabular-nums; } +.hist-file.gone .name { color: var(--text-faint); text-decoration: line-through; } +.hist-file.gone .dur:last-child { color: var(--danger); } +.empty { padding: 28px 12px; text-align: center; color: var(--text-faint); } + +.badge { + font-size: 11px; font-weight: 650; padding: 2px 8px; border-radius: 99px; + border: 1px solid transparent; white-space: nowrap; +} +.badge.done { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); } +.badge.failed { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.badge.cancelled { color: var(--text-dim); background: var(--surface-hover); } +.badge.interrupted { color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); } + +.tag { + font-size: 11px; padding: 1px 7px; border-radius: 6px; + border: 1px solid var(--border); color: var(--text-dim); background: var(--surface); +} +.tag.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); } +.tag.muted { color: var(--text-faint); font-style: italic; } +.banner[hidden] { display: none; } diff --git a/src/views/concat.ts b/src/views/concat.ts index 9e5378d..7ea14bd 100644 --- a/src/views/concat.ts +++ b/src/views/concat.ts @@ -4,17 +4,24 @@ import { h, stem, dirname, joinPath } from "../dom"; import { FileList } from "../filelist"; import { makeProgress, toast } from "../ui"; import { - pickVideos, pickFolder, startConcat, cancel, deleteFiles, bililiveRename, + pickVideos, pickFolder, startConcat, cancel, bililiveRename, pathExists, confirmAsk, onConcatProgress, reveal, } from "../api"; const EXTS = [".mp4", ".mkv", ".mov", ".avi", ".ts"]; +const FASTSTART_KEY = "faststart"; -export interface View { el: HTMLElement; onDrop: (paths: string[]) => void; } +export interface ConcatView { + el: HTMLElement; + onDrop: (paths: string[]) => void; + /** Replace the list with these files, in this order (History → Merge again). */ + load: (paths: string[]) => Promise; +} -export function buildConcat(): View { +export function buildConcat(opts: { onFinished?: () => void } = {}): ConcatView { let running = false; let lastOutput: string | null = null; + let renamedTo: string | null = null; // name the BiliBili rename produced, if applied const nameInput = h("input", { class: "input grow", placeholder: "output_concat" }) as HTMLInputElement; const extSelect = h("select", { class: "input", style: "width:96px" }, @@ -23,6 +30,11 @@ export function buildConcat(): View { const renamePreview = h("div", { class: "rename-preview" }); const renameBtn = h("button", { class: "btn accent-soft", disabled: true }, "✨ Rename") as HTMLButtonElement; const delChk = h("input", { type: "checkbox", class: "checkbox" }) as HTMLInputElement; + const fastChk = h("input", { type: "checkbox", class: "checkbox" }) as HTMLInputElement; + try { fastChk.checked = localStorage.getItem(FASTSTART_KEY) === "1"; } catch { /* default off */ } + fastChk.addEventListener("change", () => { + try { localStorage.setItem(FASTSTART_KEY, fastChk.checked ? "1" : "0"); } catch { /* not persisted */ } + }); const listEl = h("div", { class: "list" }); const totalEl = h("div", { class: "faint" }, "No files"); @@ -76,12 +88,20 @@ export function buildConcat(): View { renameBtn.addEventListener("click", async () => { const renamed = await bililiveRename(nameInput.value.trim()); - if (renamed) { nameInput.value = renamed; refresh(); } + if (renamed) { nameInput.value = renamed; renamedTo = renamed; refresh(); } }); nameInput.addEventListener("input", refresh); dirInput.addEventListener("input", refresh); extSelect.addEventListener("change", refresh); + async function load(paths: string[]) { + files.clearAll(); + nameInput.value = ""; + dirInput.value = ""; + renamedTo = null; + await addFiles(paths); + } + async function start() { const checked = files.checkedPaths(); const out = outPath(); @@ -90,6 +110,11 @@ export function buildConcat(): View { toast("Output name matches a source file — choose another.", "err"); return; } + // ffmpeg runs with -y, so without this an existing file is replaced silently + if (await pathExists(out)) { + const ok = await confirmAsk(`${out}\n\nalready exists. Replace it?`); + if (!ok) return; + } running = true; startBtn.disabled = true; @@ -102,16 +127,19 @@ export function buildConcat(): View { }); try { - const res = await startConcat(checked, out); + const res = await startConcat({ + files: checked, + output: out, + durations: files.checkedDurations(), + faststart: fastChk.checked, + deleteOriginals: delChk.checked, + renamed: renamedTo !== null && nameInput.value.trim() === renamedTo, + }); lastOutput = res.output; progress.set(1, `Done — ${res.size_mb.toFixed(1)} MB`); toast("Concatenation complete", "ok"); revealBtn.style.display = ""; - - if (delChk.checked) { - const n = await deleteFiles(checked); - if (n) toast(`Deleted ${n} original file(s)`, "ok"); - } + if (res.deleted) toast(`Deleted ${res.deleted} original file(s)`, "ok"); } catch (e) { const msg = String(e); if (msg.includes("__cancelled__")) { progress.set(0, "Stopped by user."); } @@ -121,6 +149,7 @@ export function buildConcat(): View { running = false; stopBtn.disabled = true; refresh(); + opts.onFinished?.(); } } @@ -171,6 +200,11 @@ export function buildConcat(): View { h("label", { class: "row", style: "cursor:pointer" }, delChk, h("span", {}, "Delete original files after a successful merge")), h("div", { class: "hint", style: "margin-top:4px;margin-left:27px" }, "⚠ Permanent — originals cannot be recovered."), + h("label", { class: "row", style: "cursor:pointer;margin-top:12px" }, fastChk, + h("span", {}, "Web-optimize MP4 (+faststart)")), + h("div", { class: "hint", style: "margin-top:4px;margin-left:27px" }, + "Moves the index to the front so playback can start mid-download. Rewrites the whole file a second time — " + + "about 2× slower. Not needed for local playback; MP4/MOV only."), ), // 5 · Run h("div", { class: "card" }, @@ -180,5 +214,5 @@ export function buildConcat(): View { ), ); - return { el, onDrop: addFiles }; + return { el, onDrop: addFiles, load }; } diff --git a/src/views/history.ts b/src/views/history.ts new file mode 100644 index 0000000..a1d2b9f --- /dev/null +++ b/src/views/history.ts @@ -0,0 +1,158 @@ +// History view — every merge the app has run, newest first. +// Stored by the backend in /data/history.jsonl, so it travels with the folder. + +import { h, clear, basename, fmtDur, fmtElapsed } from "../dom"; +import { toast } from "../ui"; +import { historyList, historyClear, confirmAsk, reveal, type HistoryItem, type HistoryStatus } from "../api"; + +const STATUS_LABEL: Record = { + done: "Done", + failed: "Failed", + cancelled: "Stopped", + interrupted: "Interrupted", +}; + +export interface HistoryView { + el: HTMLElement; + refresh: () => Promise; +} + +function fmtBytes(n: number): string { + if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`; + if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`; + if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${n} B`; +} + +function fmtWhen(ms: number): string { + const d = new Date(ms); + const p = (v: number) => String(v).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +export function buildHistory(opts: { + onMergeAgain: (paths: string[]) => void; + enabled: () => boolean; +}): HistoryView { + const listEl = h("div", { class: "hist-list" }); + const summaryEl = h("div", { class: "faint" }); + const offBanner = h("div", { class: "banner", hidden: true }, + "⚠ History is off — the app folder isn't writable. Move the VideoConcat folder somewhere you can write to " + + "(e.g. Documents) to keep a record of merges."); + const clearBtn = h("button", { class: "btn ghost danger" }, "Clear history") as HTMLButtonElement; + + clearBtn.addEventListener("click", async () => { + if (!(await confirmAsk("Clear the whole merge history? This only removes the record — no video files are touched."))) return; + if (await historyClear()) { toast("History cleared", "ok"); refresh(); } + else toast("Could not clear history", "err"); + }); + + function row(it: HistoryItem): HTMLElement { + const secs = it.inputs.reduce((a, i) => a + (i.duration > 0 ? i.duration : 0), 0); + const inputBytes = it.inputs.reduce((a, i) => a + i.size, 0); + const existing = it.inputs_exist.filter(Boolean).length; + const missing = it.inputs.length - existing; + + const meta: string[] = [ + `${it.inputs.length} file${it.inputs.length === 1 ? "" : "s"}`, + fmtDur(secs), + fmtBytes(it.status === "done" ? it.output_size : inputBytes), + ]; + if (it.elapsed > 0) meta.push(fmtElapsed(it.elapsed)); + if (it.status === "done" && it.mb_per_s > 0) meta.push(`${Math.round(it.mb_per_s)} MB/s`); + + const tags: HTMLElement[] = []; + if (it.faststart) tags.push(h("span", { class: "tag" }, "faststart")); + if (it.renamed) tags.push(h("span", { class: "tag" }, "renamed")); + if (it.deleted_originals) tags.push(h("span", { class: "tag warn" }, `originals deleted ×${it.deleted_originals}`)); + if (it.status === "done" && !it.output_exists) tags.push(h("span", { class: "tag muted" }, "output no longer on disk")); + + const files = h("div", { class: "hist-files", hidden: true }, + ...it.inputs.map((inp, i) => + h("div", { class: `hist-file ${it.inputs_exist[i] ? "" : "gone"}`, title: inp.path }, + h("span", { class: "idx" }, String(i + 1)), + h("span", { class: "name" }, basename(inp.path)), + h("span", { class: "dur" }, fmtDur(inp.duration)), + h("span", { class: "dur" }, it.inputs_exist[i] ? fmtBytes(inp.size) : "missing"), + )), + ); + const filesBtn = h("button", { class: "btn ghost sm" }, "Files ▾") as HTMLButtonElement; + filesBtn.addEventListener("click", () => { + files.hidden = !files.hidden; + filesBtn.textContent = files.hidden ? "Files ▾" : "Files ▴"; + }); + + const revealBtn = h("button", { + class: "btn ghost sm", + disabled: !it.output_exists, + title: it.output_exists ? it.output : "The output file is no longer there", + onclick: () => reveal(it.output), + }, "Show in folder"); + + const againBtn = h("button", { + class: "btn ghost sm", + disabled: existing === 0, + title: existing === 0 ? "None of the source files exist any more" + : missing ? `${missing} of ${it.inputs.length} source files are missing` : "Load these files into Merge", + onclick: () => { + const paths = it.inputs.filter((_, i) => it.inputs_exist[i]).map((i) => i.path); + if (missing) toast(`${missing} of ${it.inputs.length} files are missing — loaded the other ${paths.length}`, "err"); + opts.onMergeAgain(paths); + }, + }, "Merge again"); + + return h("div", { class: "hist-row" }, + h("div", { class: "hist-top" }, + h("span", { class: `badge ${it.status}` }, STATUS_LABEL[it.status] ?? it.status), + h("span", { class: "hist-name", title: it.output }, basename(it.output)), + h("span", { class: "faint" }, fmtWhen(it.started)), + ), + h("div", { class: "hist-meta" }, meta.join(" · "), ...tags), + it.error ? h("div", { class: "hist-error" }, it.error) : null, + it.status === "interrupted" + ? h("div", { class: "hint" }, "The app closed or crashed while this merge was running." + + (it.output_exists ? " The output file on disk is incomplete and probably won't play — delete it or merge again." : "")) + : null, + h("div", { class: "hist-actions" }, filesBtn, revealBtn, againBtn), + files, + ); + } + + async function refresh() { + const on = opts.enabled(); + offBanner.hidden = on; + clearBtn.disabled = !on; + clear(listEl); + if (!on) { summaryEl.textContent = ""; return; } + + let items: HistoryItem[] = []; + try { items = await historyList(); } + catch (e) { toast(`Could not read history: ${e}`, "err"); } + + clearBtn.disabled = !items.length; + const done = items.filter((i) => i.status === "done"); + summaryEl.textContent = !items.length ? "" + : `${items.length} merge${items.length === 1 ? "" : "s"} · ${done.length} done · ` + + `${fmtBytes(done.reduce((a, i) => a + i.output_size, 0))} written`; + + if (!items.length) { + listEl.append(h("div", { class: "empty" }, + "No merges yet. Finished, failed and stopped jobs will show up here.")); + return; + } + for (const it of items) listEl.append(row(it)); + } + + const el = h("div", { class: "view", hidden: true }, + offBanner, + h("div", { class: "card" }, + h("div", { class: "row", style: "justify-content:space-between;margin-bottom:12px" }, + h("h2", { style: "margin:0" }, "History"), + h("div", { class: "row" }, summaryEl, clearBtn), + ), + listEl, + ), + ); + + return { el, refresh }; +} diff --git a/tools/bench_concat.py b/tools/bench_concat.py new file mode 100644 index 0000000..5d75624 --- /dev/null +++ b/tools/bench_concat.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Benchmark Video Concat's pipeline stages on real disks. + +Runs ffmpeg with exactly the flags src-tauri/src/concat.rs uses, so the numbers +mean what the app will do. Nothing here touches the app itself. + + python tools/bench_concat.py --ffmpeg-dir "C:/Users/me/Documents/VideoConcat" --make 6 --src D:/bench --dst D:/bench/out + python tools/bench_concat.py --ffmpeg-dir ... --src D:/recordings/session --dst E:/out --reps 3 + +Stages: + probe_serial one ffprobe per file, one after another (the pre-5.1 behaviour) + probe_parallel bounded parallel probing + mp4_faststart -c copy -movflags +faststart -> .mp4 (the pre-5.1 default) + mp4_plain -c copy -> .mp4 (the 5.1 default) + mkv -c copy -> .mkv + raw_copy byte-for-byte copy of the same input bytes: the I/O floor + +Measured 2026-09-25, 6 x 60 s H.264/AAC FLV segments, 1.7 GB: + local NVMe -> NVMe faststart 1.77 s plain 1.47 s raw 1.39 s + NAS -> NAS (10GbE) faststart 27.4 s plain 10.9 s raw 6.9 s + +Mind the OS file cache: a second run over the same inputs reads them from +RAM. Compare variants within one run, not across runs. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import statistics +import subprocess +import threading +import time +from pathlib import Path + +NOWIN = 0x08000000 if os.name == "nt" else 0 + + +def tool(ffdir: str | None, name: str) -> str: + exe = name + (".exe" if os.name == "nt" else "") + if ffdir and (Path(ffdir) / exe).is_file(): + return str(Path(ffdir) / exe) + found = shutil.which(name) + if not found: + raise SystemExit(f"{name} not found - pass --ffmpeg-dir") + return found + + +def make_segments(ff: str, dst: Path, count: int, seconds: int) -> None: + """High-bitrate H.264/AAC FLV segments, shaped like a BiliBili recording.""" + dst.mkdir(parents=True, exist_ok=True) + seed = dst / "_seed.flv" + subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "10", + "-c:v", "libx264", "-preset", "ultrafast", "-b:v", "40M", "-minrate", "40M", + "-maxrate", "40M", "-bufsize", "40M", "-x264-params", "nal-hrd=cbr:force-cfr=1", + "-g", "60", "-c:a", "aac", "-b:a", "128k", "-f", "flv", str(seed)], + check=True, creationflags=NOWIN) + reps = max(1, seconds // 10) + lst = dst / "_seed.txt" + lst.write_text("".join(f"file '{seed.as_posix()}'\n" for _ in range(reps)), encoding="utf-8") + first = dst / "seg1.flv" + subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "0", + "-i", str(lst), "-c", "copy", "-f", "flv", str(first)], check=True, creationflags=NOWIN) + for i in range(2, count + 1): + shutil.copyfile(first, dst / f"seg{i}.flv") + seed.unlink() + lst.unlink() + + +def probe(fp: str, path: Path) -> float: + out = subprocess.run([fp, "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", str(path)], + capture_output=True, text=True, creationflags=NOWIN) + try: + return float(out.stdout.strip()) + except ValueError: + return 0.0 + + +def probe_parallel(fp: str, files: list[Path]) -> None: + ts = [threading.Thread(target=probe, args=(fp, f)) for f in files] + for t in ts: + t.start() + for t in ts: + t.join() + + +def concat(ff: str, files: list[Path], out: Path, faststart: bool) -> float: + lst = out.parent / f".bench_list_{os.getpid()}.txt" + lst.write_text("".join(f"file '{f.resolve().as_posix()}'\n" for f in files), encoding="utf-8") + args = [ff, "-y", "-f", "concat", "-safe", "0", "-i", str(lst), "-c:v", "copy", "-c:a", "copy"] + if faststart: + args += ["-movflags", "+faststart"] + args += ["-nostdin", "-hide_banner", "-loglevel", "error", str(out)] + t0 = time.perf_counter() + r = subprocess.run(args, capture_output=True, text=True, creationflags=NOWIN) + dt = time.perf_counter() - t0 + lst.unlink(missing_ok=True) + if r.returncode: + raise RuntimeError(r.stderr) + return dt + + +def raw_copy(files: list[Path], out: Path) -> float: + t0 = time.perf_counter() + with open(out, "wb") as o: + for f in files: + with open(f, "rb") as i: + shutil.copyfileobj(i, o, 8 * 1024 * 1024) + o.flush() + os.fsync(o.fileno()) + return time.perf_counter() - t0 + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--ffmpeg-dir") + ap.add_argument("--src", type=Path, required=True, help="folder with seg*.flv (or where --make writes them)") + ap.add_argument("--dst", type=Path, required=True, help="where outputs are written (deleted after each run)") + ap.add_argument("--make", type=int, default=0, metavar="N", help="first generate N segments in --src") + ap.add_argument("--seconds", type=int, default=60, help="length of each generated segment") + ap.add_argument("--reps", type=int, default=3) + ap.add_argument("--only", help="comma list of stages to run") + a = ap.parse_args() + + ff, fp = tool(a.ffmpeg_dir, "ffmpeg"), tool(a.ffmpeg_dir, "ffprobe") + if a.make: + print(f"generating {a.make} x {a.seconds}s segments in {a.src} ...") + make_segments(ff, a.src, a.make, a.seconds) + files = sorted(a.src.glob("seg*.flv")) + if not files: + raise SystemExit(f"no seg*.flv in {a.src} - use --make") + only = set(a.only.split(",")) if a.only else None + want = lambda s: only is None or s in only # noqa: E731 + a.dst.mkdir(parents=True, exist_ok=True) + total_mb = sum(f.stat().st_size for f in files) / 1048576 + print(f"inputs {len(files)} files, {total_mb:.0f} MB | {a.src} -> {a.dst} | median of {a.reps}\n") + + def timed(fn): + t0 = time.perf_counter() + fn() + return time.perf_counter() - t0 + + if want("probe_serial"): + t = statistics.median(timed(lambda: [probe(fp, f) for f in files]) for _ in range(a.reps)) + print(f"{'probe_serial':15s} {t * 1000:8.0f} ms") + if want("probe_parallel"): + t = statistics.median(timed(lambda: probe_parallel(fp, files)) for _ in range(a.reps)) + print(f"{'probe_parallel':15s} {t * 1000:8.0f} ms") + + results = {} + for name, ext, fs in (("mp4_faststart", ".mp4", True), ("mp4_plain", ".mp4", False), ("mkv", ".mkv", False)): + if not want(name): + continue + runs = [] + for _ in range(a.reps): + out = a.dst / f"bench_{name}{ext}" + runs.append(concat(ff, files, out, fs)) + out.unlink() + results[name] = t = statistics.median(runs) + print(f"{name:15s} {t:8.2f} s {total_mb / t:6.0f} MB/s") + if want("raw_copy"): + runs = [] + for _ in range(a.reps): + out = a.dst / "bench_raw.bin" + runs.append(raw_copy(files, out)) + out.unlink() + t = statistics.median(runs) + print(f"{'raw_copy':15s} {t:8.2f} s {total_mb / t:6.0f} MB/s <- I/O floor") + + if "mp4_faststart" in results and "mp4_plain" in results: + d = results["mp4_faststart"] - results["mp4_plain"] + print(f"\n+faststart costs {d:.2f} s = {100 * d / results['mp4_faststart']:.0f}% of a faststart run") + + +if __name__ == "__main__": + main() diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..19647b0 --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Regenerate every app icon from the vector source (src-tauri/icons/icon.svg). + +The artwork is described once, in 512x512 units, and rasterised per target size +with 8x supersampling plus a little per-size hinting: the filmstrip sprocket +holes and the play-triangle knockout are dropped at the sizes where they +would be sub-pixel and only turn to mush. + +Everything is produced with a real alpha channel - the rounded-square tile has +transparent corners, which is exactly what the old flattened .ico was missing. + + python tools/make_icons.py + +Requires: pillow, numpy. +""" +from __future__ import annotations + +import math +import re +import struct +from io import BytesIO +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parent.parent +ICONS = ROOT / "src-tauri" / "icons" +SVG = ICONS / "icon.svg" + +CANVAS = 512.0 +SS = 8 # supersampling factor + +# --- tile ------------------------------------------------------------------- +# The original raster left a 22px margin for a drop shadow. The vector version +# has no shadow, so the tile reclaims that space and reads larger in the taskbar. +TILE = (14.0, 14.0, 484.0, 484.0) # x, y, w, h +TILE_R = 99.0 +GRAD_SPAN = (22.0, 468.0) # gradient is still fitted against the original tile + +# --- filmstrips ------------------------------------------------------------- +STRIPS = [(113.0, 147.0), (96.0, 226.0), (113.0, 305.0)] +STRIP_W, STRIP_H, STRIP_R = 79.0, 60.0, 12.0 +HOLE_DX, HOLE_DY, HOLE_S, HOLE_R = 68.0, 4.0, 7.5, 1.8 +HOLE_PITCH, HOLE_N = 11.25, 5 + + +def parse_svg(svg_text): + """Pull the gradient stops and the glyph path back out of the vector source.""" + lin = re.search(r'id="tile".*?', svg_text, re.S).group(0) + lin_stops = [ + (float(o), tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))) + for o, h in re.findall(r'offset="([\d.]+)" stop-color="#([0-9A-Fa-f]{6})"', lin) + ] + rad = re.search(r'id="glow".*?', svg_text, re.S).group(0) + gc = re.search(r'cx="([-\d.]+)" cy="([-\d.]+)" r="([\d.]+)"', rad) + glow_xy = (float(gc.group(1)), float(gc.group(2))) + glow_r = float(gc.group(3)) + ghex = re.search(r'stop-color="#([0-9A-Fa-f]{6})"', rad).group(1) + glow_rgb = tuple(int(ghex[i:i + 2], 16) for i in (0, 2, 4)) + rad_stops = [ + (float(o), float(a)) + for o, a in re.findall( + r'offset="([\d.]+)" stop-color="#[0-9A-Fa-f]{6}" stop-opacity="([\d.]+)"', rad + ) + ] + path_d = re.search(r' 2: + polys.append(cur) + pos = start = (a[0], a[1]) + cur = [pos] + elif cmd == "L": + pos = (a[0], a[1]) + cur.append(pos) + elif cmd == "C": + p0, p1, p2, p3 = pos, (a[0], a[1]), (a[2], a[3]), (a[4], a[5]) + span = math.dist(p0, p1) + math.dist(p1, p2) + math.dist(p2, p3) + n = max(4, min(96, int(span * scale / 2.5))) + for i in range(1, n + 1): + t = i / n + mt = 1 - t + x = mt ** 3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0] + y = mt ** 3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1] + cur.append((x, y)) + pos = p3 + elif cmd == "Z": + if len(cur) > 2: + polys.append(cur) + cur, pos = [], start + if len(cur) > 2: + polys.append(cur) + return polys + + +def render(size, lin_stops, glow_xy, glow_r, glow_rgb, rad_stops, path_d): + n = size * SS + k = n / CANVAS # 512-space -> device pixels + + # per-size hinting: sub-pixel detail is dropped rather than smeared, and the + # glyph is nudged up a little where it would otherwise get lost + keep_holes = size >= 48 # sprocket holes are < 0.7px below this + keep_triangle = size >= 64 # the play knockout reads as a speck below this + gscale = 1.09 if size <= 32 else 1.0 + + def S(v): + return v * k + + def G(v): # glyph coordinate, scaled about the tile centre + return (CANVAS / 2 + (v - CANVAS / 2) * gscale) * k + + # the tile shape is the icon's whole alpha channel - corners stay transparent + tile = Image.new("L", (n, n), 0) + ImageDraw.Draw(tile).rounded_rectangle( + [S(TILE[0]), S(TILE[1]), S(TILE[0] + TILE[2]), S(TILE[1] + TILE[3])], + radius=S(TILE_R), + fill=255, + ) + + # background: diagonal purple-to-blue ramp with a soft highlight near the top + yy, xx = np.mgrid[0:n, 0:n] + ux = (xx + 0.5) / k + uy = (yy + 0.5) / k + t = np.clip(((ux + uy) - 2 * GRAD_SPAN[0]) / (2 * GRAD_SPAN[1]), 0, 1) + base = ramp(lin_stops, t) + g = np.clip(np.hypot(ux - glow_xy[0], uy - glow_xy[1]) / glow_r, 0, 1) + a = ramp1(rad_stops, g)[..., None] + rgb = base * (1 - a) + np.array(glow_rgb, dtype=np.float64) * a + + glyph = Image.new("L", (n, n), 0) + gd = ImageDraw.Draw(glyph) + polys = flatten_path(path_d, k) + outer, holes = polys[0], polys[1:] # outline first, play-triangle knockout after + gd.polygon([(G(x), G(y)) for x, y in outer], fill=255) + if keep_triangle: + for hole in holes: + gd.polygon([(G(x), G(y)) for x, y in hole], fill=0) + + for sx, sy in STRIPS: + gd.rounded_rectangle( + [G(sx), G(sy), G(sx + STRIP_W), G(sy + STRIP_H)], radius=S(STRIP_R * gscale), fill=255 + ) + if keep_holes: + for i in range(HOLE_N): + hx, hy = sx + HOLE_DX, sy + HOLE_DY + i * HOLE_PITCH + gd.rounded_rectangle( + [G(hx), G(hy), G(hx + HOLE_S), G(hy + HOLE_S)], + radius=S(HOLE_R * gscale), + fill=0, + ) + + gm = (np.asarray(glyph, dtype=np.float64) / 255.0)[..., None] + rgb = rgb * (1 - gm) + 255.0 * gm + + out = np.dstack([np.clip(rgb, 0, 255), np.asarray(tile, dtype=np.float64)]).astype(np.uint8) + return Image.fromarray(out, "RGBA").resize((size, size), Image.LANCZOS) + + +def write_ico(path, images): + """Write a PNG-compressed .ico (Vista+ format), preserving full alpha.""" + blobs = [] + for im in images: + buf = BytesIO() + im.save(buf, "PNG", optimize=True) + blobs.append(buf.getvalue()) + with open(path, "wb") as f: + f.write(struct.pack("= 256 else im.width + h = 0 if im.height >= 256 else im.height + f.write(struct.pack("= this are stored as PNG, smaller ones as DIB + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +kernel32.BeginUpdateResourceW.argtypes = [wintypes.LPCWSTR, wintypes.BOOL] +kernel32.BeginUpdateResourceW.restype = wintypes.HANDLE +kernel32.UpdateResourceW.argtypes = [ + wintypes.HANDLE, wintypes.LPCWSTR, wintypes.LPCWSTR, + wintypes.WORD, wintypes.LPVOID, wintypes.DWORD, +] +kernel32.UpdateResourceW.restype = wintypes.BOOL +kernel32.EndUpdateResourceW.argtypes = [wintypes.HANDLE, wintypes.BOOL] +kernel32.EndUpdateResourceW.restype = wintypes.BOOL + + +def _int_resource(value): + """MAKEINTRESOURCE: an integer id is passed where a string pointer is expected.""" + return ctypes.cast(ctypes.c_void_p(value), wintypes.LPCWSTR) + + +def _check(ok, what): + if not ok: + err = ctypes.get_last_error() + raise OSError(err, f"{what} failed: {ctypes.FormatError(err)}") + + +def to_dib(img: Image.Image) -> bytes: + """32bpp bottom-up BGRA DIB plus the 1bpp AND mask, as RT_ICON expects.""" + w, h = img.size + px = img.convert("RGBA").load() + + xor = bytearray() + for y in range(h - 1, -1, -1): + for x in range(w): + r, g, b, a = px[x, y] + xor += bytes((b, g, r, a)) + + stride = ((w + 31) // 32) * 4 + mask = bytearray() + for y in range(h - 1, -1, -1): + row = bytearray(stride) + for x in range(w): + if px[x, y][3] == 0: # 1 = transparent + row[x // 8] |= 0x80 >> (x % 8) + mask += row + + header = struct.pack( + " bytes: + buf = BytesIO() + img.save(buf, "PNG", optimize=True) + return buf.getvalue() + + +def load_sizes(ico_path: Path): + """Read every image out of a .ico, smallest first.""" + sizes = sorted(Image.open(ico_path).info["sizes"]) + out = [] + for size in sizes: + im = Image.open(ico_path) + im.size = size + out.append(im.convert("RGBA")) + return out + + +def build_group(images, first_id: int, blobs) -> bytes: + """GRPICONDIR + one GRPICONDIRENTRY per image.""" + data = struct.pack("= 256 else im.width + h = 0 if im.height >= 256 else im.height + data += struct.pack(" None: + images = load_sizes(ico) + blobs = [to_png(im) if im.width >= PNG_FROM else to_dib(im) for im in images] + + if backup: + bak = exe.with_suffix(exe.suffix + ".bak") + if bak.exists(): + print(f" backup already exists, leaving it alone: {bak.name}") + else: + shutil.copy2(exe, bak) + print(f" backup -> {bak.name}") + + handle = kernel32.BeginUpdateResourceW(str(exe), False) + if not handle: + _check(False, "BeginUpdateResource") + + try: + for i, (im, blob) in enumerate(zip(images, blobs)): + buf = ctypes.create_string_buffer(blob, len(blob)) + _check( + kernel32.UpdateResourceW( + handle, _int_resource(RT_ICON), _int_resource(i + 1), + LANG_EN_US, ctypes.cast(buf, wintypes.LPVOID), len(blob), + ), + f"UpdateResource RT_ICON #{i + 1}", + ) + kind = "png" if im.width >= PNG_FROM else "dib" + print(f" RT_ICON #{i + 1:<2} {im.width:>3}x{im.height:<3} {kind} {len(blob):>7,}b") + + group = build_group(images, 1, blobs) + buf = ctypes.create_string_buffer(group, len(group)) + _check( + kernel32.UpdateResourceW( + handle, _int_resource(RT_GROUP_ICON), _int_resource(group_id), + LANG_EN_US, ctypes.cast(buf, wintypes.LPVOID), len(group), + ), + "UpdateResource RT_GROUP_ICON", + ) + print(f" RT_GROUP_ICON #{group_id} {len(images)} entries") + except Exception: + kernel32.EndUpdateResourceW(handle, True) # discard + raise + + _check(kernel32.EndUpdateResourceW(handle, False), "EndUpdateResource") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("exe", type=Path) + ap.add_argument("--icon", type=Path, default=DEFAULT_ICO) + ap.add_argument("--group-id", type=int, default=32512, + help="RT_GROUP_ICON id the shell shows (default: 32512)") + ap.add_argument("--no-backup", action="store_true") + args = ap.parse_args() + + if sys.platform != "win32": + print("This tool uses the Windows resource API.", file=sys.stderr) + return 1 + for p in (args.exe, args.icon): + if not p.exists(): + print(f"not found: {p}", file=sys.stderr) + return 1 + + print(f"{args.exe}") + patch(args.exe, args.icon, args.group_id, not args.no_backup) + print("done - Explorer caches icons, so run `ie4uinit.exe -show` if the old one lingers") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())