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

This commit is contained in:
2026-10-10 18:00:01 +08:00
parent 096b99f376
commit 95592a3b78
36 changed files with 1959 additions and 201 deletions
+85 -4
View File
@@ -10,10 +10,37 @@ removed, so it needs **no Python and no ML model** — just ffmpeg.
## Features ## Features
- Drag-and-drop / browse to add videos, reorder, select which to merge. - Drag-and-drop / browse to add videos, reorder, select which to merge.
- Stream-copy concat with honest real-time progress (and a `+faststart` - Stream-copy concat with honest real-time progress. On local NVMe a merge runs
finalising pass so the output seeks instantly). 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). - BiliBili-Live filename helper (`[date][uploader][title]` → readable name).
- Optionally delete the originals after a successful merge. - 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 ## 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 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) ### Build requirements (on the build machine only)
- [Node.js](https://nodejs.org/) - [Node.js](https://nodejs.org/)
- [Rust](https://rustup.rs/) (`winget install Rustlang.Rustup`) - [Rust](https://rustup.rs/) (`winget install Rustlang.Rustup`)
- Visual Studio 2022 with the **Desktop development with C++** workload - 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 ## Develop
Double-click **`dev.bat`** (hot-reload UI + Rust). Needs ffmpeg on `PATH`. 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 <folder with ffmpeg> --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`.
+7 -4
View File
@@ -16,13 +16,16 @@ cd /d "%~dp0"
REM --- 1. Locate ffmpeg.exe + ffprobe.exe -------------------- REM --- 1. Locate ffmpeg.exe + ffprobe.exe --------------------
set "FFMPEG=" set "FFMPEG="
set "FFPROBE=" set "FFPROBE="
if exist "C:\ffmpeg\bin\ffmpeg.exe" set "FFMPEG=C:\ffmpeg\bin\ffmpeg.exe" REM Optional override: set FFMPEG_DIR=C:\folder\holding\ffmpeg-and-ffprobe
if exist "C:\ffmpeg\bin\ffprobe.exe" set "FFPROBE=C:\ffmpeg\bin\ffprobe.exe" 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 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 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 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. 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 ffmpeg: !FFMPEG!
echo Using ffprobe: !FFPROBE! echo Using ffprobe: !FFPROBE!
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "video-concat", "name": "video-concat",
"private": true, "private": true,
"version": "5.0.0", "version": "5.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "video-concat" name = "video-concat"
version = "5.0.0" version = "5.1.0"
description = "Video Concat — merge split stream recordings into one file" description = "Video Concat — merge split stream recordings into one file"
authors = ["youfu"] authors = ["youfu"]
edition = "2021" edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 78 KiB

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

After

Width:  |  Height:  |  Size: 6.1 KiB

+372 -113
View File
@@ -1,23 +1,47 @@
//! ffmpeg concat-demuxer worker with honest, real-time progress. //! ffmpeg concat-demuxer worker with honest, real-time progress, plus a
//! history record for every job.
//! //!
//! Mirrors the proven logic from the Python original: stream-copy (`-c copy`), //! Stream copy only (`-c copy`), so a merge is purely I/O bound: on local NVMe
//! `+faststart`, and a progress bar that is capped at 99 % until ffmpeg emits //! it runs at ~95 % of a plain file copy. `+faststart` is opt-in because it
//! `progress=end`, at which point it snaps to 100 % and shows "Finalising…". //! 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::io::{BufRead, BufReader};
use std::path::Path; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::thread; 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 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; 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)] #[derive(Clone, Serialize)]
pub struct ConcatProgress { pub struct ConcatProgress {
pub pct: f64, pub pct: f64,
@@ -32,68 +56,208 @@ pub struct ConcatResult {
pub output: String, pub output: String,
pub size_mb: f64, pub size_mb: f64,
pub elapsed: f64, pub elapsed: f64,
pub deleted: usize,
} }
fn emit(app: &AppHandle, p: ConcatProgress) { fn emit(app: &AppHandle, p: ConcatProgress) {
let _ = app.emit("concat-progress", p); let _ = app.emit("concat-progress", p);
} }
/// Build the ffmpeg concat list file (UTF-8, forward slashes, escaped quotes). fn unix_ms() -> u64 {
fn write_list(files: &[String], out_dir: &Path) -> Result<std::path::PathBuf, String> { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
let list_path = out_dir.join(format!(".stream_studio_{}.txt", std::process::id())); }
/// 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(); let mut body = String::new();
for f in files { for f in files {
let abs = std::fs::canonicalize(f).unwrap_or_else(|_| Path::new(f).to_path_buf()); let abs = std::fs::canonicalize(f).unwrap_or_else(|_| Path::new(f).to_path_buf());
let s = abs body.push_str(&format!("file '{}'\n", ffconcat_path(&abs.to_string_lossy())));
.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"));
} }
std::fs::write(&list_path, body).map_err(|e| format!("Could not write list file: {e}"))?; std::fs::write(&list_path, body).map_err(|e| format!("Could not write list file: {e}"))?;
Ok(list_path) 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( pub fn run(
app: &AppHandle, app: &AppHandle,
state: &AppState, state: &AppState,
ffmpeg: &str, ffmpeg: &str,
ffprobe: &str, ffprobe: &str,
files: Vec<String>, req: ConcatRequest,
output: String,
) -> Result<ConcatResult, String> { ) -> 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); if req.durations.len() != req.files.len() || req.durations.iter().any(|d| *d <= 0.0) {
let out_dir = out_path.parent().ok_or("Invalid output path")?.to_path_buf(); 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 started = unix_ms();
let total_secs: f64 = files.iter().map(|f| probe_duration(ffprobe, f)).sum(); 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); let mut cmd = Command::new(ffmpeg);
cmd.args([ cmd.args(["-y", "-f", "concat", "-safe", "0", "-i"])
"-y", .arg(&list_path)
"-f", "concat", "-safe", "0", .args(["-c:v", "copy", "-c:a", "copy"]);
"-i", &list_path.to_string_lossy(), if req.faststart {
"-c:v", "copy", "-c:a", "copy", cmd.args(["-movflags", "+faststart"]);
"-movflags", "+faststart", }
"-nostdin", "-hide_banner", cmd.args(["-nostdin", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1"])
"-loglevel", "error", .arg(&req.output);
"-progress", "pipe:1",
&output,
]);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
hide_window(&mut cmd); 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 stdout = child.stdout.take().ok_or("No ffmpeg stdout")?;
let stderr = child.stderr.take(); let stderr = child.stderr.take();
@@ -101,73 +265,79 @@ pub fn run(
*state.proc.lock().unwrap() = Some(child); *state.proc.lock().unwrap() = Some(child);
emit(app, ConcatProgress { pct: 0.0, status: "Concatenating…".into(), elapsed: 0.0, eta: -1.0, speed: 0.0 }); 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 // With +faststart, ffmpeg goes silent while it rewrites the file to move
// +faststart (a full-file rewrite that can take a while on large outputs). // the index to the front - and current builds only print progress=end
// We hold the bar at 99 % and run this heartbeat so "Finalising…" stays // *after* that rewrite, so waiting for it never works. Nor does "last
// visibly alive instead of looking frozen, and 100 % means *actually done*. // 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 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() { for line in BufReader::new(stdout).lines() {
let line = match line { let Ok(line) = line else { break };
Ok(l) => l,
Err(_) => break,
};
let line = line.trim(); let line = line.trim();
if let Some(v) = line.strip_prefix("out_time_us=").or_else(|| line.strip_prefix("out_time_ms=")) { 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>() { if let Ok(n) = v.parse::<i64>() {
cur_us = n; // ffmpeg reports microseconds under both keys cur_us = n; // ffmpeg reports microseconds under both keys
} }
} else if line == "progress=end" { } else if line == "progress=continue" && total_secs > 0.0 && cur_us > 0 {
// Copy is done; the +faststart rewrite happens now, during which // progress=end only arrives after any faststart rewrite, right
// ffmpeg emits nothing. Spawn a heartbeat to keep the UI honest. // before exit - reporting "Concatenating…" then would be wrong
if heartbeat.is_none() { let played = cur_us as f64 / 1_000_000.0;
let appc = app.clone(); let pct = (played / total_secs).clamp(0.0, 0.99);
let finc = fin.clone(); let elapsed = t0.elapsed().as_secs_f64();
heartbeat = Some(thread::spawn(move || { let speed = played / elapsed.max(1e-9);
while !finc.load(Ordering::SeqCst) { last_played.store(played.to_bits(), Ordering::SeqCst);
let elapsed = t0.elapsed().as_secs_f64(); last_speed.store(speed.to_bits(), Ordering::SeqCst);
emit(&appc, ConcatProgress { if let Ok(mut t) = last_tick.lock() {
pct: 0.99, *t = Instant::now();
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,
});
} }
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); fin.store(true, Ordering::SeqCst);
if let Some(h) = heartbeat.take() { let _ = watchdog.join();
let _ = h.join();
}
// Reclaim the child and finish.
let mut child = state.proc.lock().unwrap().take().ok_or("Process vanished")?; 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 status = child.wait().map_err(|e| format!("ffmpeg wait failed: {e}"))?;
let mut stderr_text = String::new(); let mut stderr_text = String::new();
@@ -175,29 +345,118 @@ pub fn run(
use std::io::Read; use std::io::Read;
let _ = se.read_to_string(&mut stderr_text); let _ = se.read_to_string(&mut stderr_text);
} }
let _ = std::fs::remove_file(&list_path); let _ = std::fs::remove_file(&list_path);
if state.cancel.load(std::sync::atomic::Ordering::SeqCst) { if state.cancel.load(Ordering::SeqCst) {
let _ = std::fs::remove_file(&output); let _ = std::fs::remove_file(&req.output);
return Err("__cancelled__".into()); return Err(CANCELLED.into());
} }
if !status.success() { if !status.success() {
let _ = std::fs::remove_file(&output); let _ = std::fs::remove_file(&req.output);
let msg = stderr_text.trim(); let msg = stderr_text.trim();
return Err(if msg.is_empty() { "Unknown ffmpeg error.".into() } else { msg.to_string() }); return Err(if msg.is_empty() { "Unknown ffmpeg error.".into() } else { msg.to_string() });
} }
Ok(())
}
let elapsed = t0.elapsed().as_secs_f64(); #[cfg(test)]
let size_mb = std::fs::metadata(&output).map(|m| m.len() as f64 / 1_048_576.0).unwrap_or(0.0); mod tests {
emit(app, ConcatProgress { use super::*;
pct: 1.0, use std::cell::Cell;
status: format!("Done — {} · {:.1} MB", fmt_dur(elapsed), size_mb),
elapsed, #[test]
eta: 0.0, fn ffconcat_path_normalises_plain_windows_paths() {
speed: 0.0, assert_eq!(ffconcat_path(r"D:\rec\a.flv"), "D:/rec/a.flv");
}); }
Ok(ConcatResult { output, size_mb, elapsed }) #[test]
fn ffconcat_path_strips_the_extended_length_prefix() {
assert_eq!(ffconcat_path(r"\\?\D:\rec\a.flv"), "D:/rec/a.flv");
}
#[test]
fn ffconcat_path_keeps_unc_paths_as_network_paths() {
assert_eq!(ffconcat_path(r"\\?\UNC\TRUENAS\share\a.flv"), "//TRUENAS/share/a.flv");
}
#[test]
fn ffconcat_path_escapes_single_quotes() {
assert_eq!(ffconcat_path(r"D:\rec\it's live.flv"), r"D:/rec/it'\''s live.flv");
}
#[test]
fn resolve_durations_skips_ffprobe_when_the_ui_has_them_all() {
let files = vec!["a".to_string(), "b".to_string()];
let calls = Cell::new(0);
let got = resolve_durations(&files, &[60.0, 30.5], |p| {
calls.set(calls.get() + 1);
vec![0.0; p.len()]
});
assert_eq!(got, [60.0, 30.5]);
assert_eq!(calls.get(), 0);
}
#[test]
fn resolve_durations_probes_only_the_gaps() {
let files: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
let got = resolve_durations(&files, &[60.0, 0.0, 45.0], |p| {
assert_eq!(p, ["b", "d"]);
vec![10.0, 20.0]
});
assert_eq!(got, [60.0, 10.0, 45.0, 20.0]);
}
#[test]
fn remove_stale_list_deletes_only_the_crashed_jobs_list() {
let d = std::env::temp_dir().join(format!("vc_stale_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let stale = list_path(&d, 4242);
let other = list_path(&d, 777); // a different job's list: leave it
let video = d.join("keep.mp4");
for p in [&stale, &other, &video] {
std::fs::write(p, b"x").unwrap();
}
let entry = Entry {
id: "1758780000000-4242".into(),
started: 0,
status: "interrupted".into(),
error: None,
inputs: vec![],
output: d.join("out.mp4").to_string_lossy().into_owned(),
output_size: 0,
faststart: false,
elapsed: 0.0,
mb_per_s: 0.0,
deleted_originals: 0,
renamed: false,
};
remove_stale_list(&entry);
assert!(!stale.exists());
assert!(other.exists() && video.exists());
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn delete_originals_never_deletes_the_output() {
let d = std::env::temp_dir().join(format!("vc_delete_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let a = d.join("a.flv");
let b = d.join("b.flv");
let out = d.join("out.mp4");
for p in [&a, &b, &out] {
std::fs::write(p, b"x").unwrap();
}
let files: Vec<String> = [&a, &b, &out].iter().map(|p| p.to_string_lossy().into_owned()).collect();
// the output listed again under a different spelling must survive too
let out_alias = d.join(".").join("out.mp4").to_string_lossy().into_owned();
let mut with_alias = files.clone();
with_alias.push(out_alias);
assert_eq!(delete_originals(&with_alias, &out.to_string_lossy()), 2);
assert!(!a.exists() && !b.exists());
assert!(out.exists(), "output was deleted");
std::fs::remove_dir_all(&d).unwrap();
}
} }
+247
View File
@@ -0,0 +1,247 @@
//! Concat history, kept in `data/history.jsonl` inside the app folder.
//!
//! One JSON object per line, appended when a job ends. While a job runs, its
//! entry also sits in `data/running.json`; if the app dies mid-merge that file
//! survives, and the next launch files it as `interrupted`. Append-only lines
//! mean a crash can at worst lose the job in flight, never earlier history,
//! and a corrupt line is skipped instead of taking the whole file down.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const HISTORY: &str = "history.jsonl";
const RUNNING: &str = "running.json";
const MAX_ERROR: usize = 2000;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InputFile {
pub path: String,
pub size: u64,
pub duration: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub id: String,
/// Unix epoch, milliseconds.
pub started: u64,
/// `done` | `failed` | `cancelled` | `interrupted`
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub inputs: Vec<InputFile>,
pub output: String,
#[serde(default)]
pub output_size: u64,
#[serde(default)]
pub faststart: bool,
/// Seconds ffmpeg ran for.
#[serde(default)]
pub elapsed: f64,
/// Output bytes / elapsed, in MB/s. 0 unless the job finished.
#[serde(default)]
pub mb_per_s: f64,
/// How many originals were deleted after the merge.
#[serde(default)]
pub deleted_originals: usize,
/// Whether the BiliBili rename was applied to the output name.
#[serde(default)]
pub renamed: bool,
}
impl Entry {
pub fn set_error(&mut self, msg: &str) {
let msg = msg.trim();
self.error = Some(if msg.chars().count() > MAX_ERROR {
format!("{}…", msg.chars().take(MAX_ERROR).collect::<String>())
} else {
msg.to_string()
});
}
}
/// An entry as the UI sees it: what was recorded plus what is true right now.
#[derive(Serialize)]
pub struct Item {
#[serde(flatten)]
pub entry: Entry,
pub output_exists: bool,
/// Per input, in order: is the file still on disk?
pub inputs_exist: Vec<bool>,
}
fn history_path(dir: &Path) -> PathBuf {
dir.join(HISTORY)
}
fn running_path(dir: &Path) -> PathBuf {
dir.join(RUNNING)
}
/// Parse history text, skipping blank or corrupt lines. Newest first.
pub fn parse(text: &str) -> Vec<Entry> {
let mut out: Vec<Entry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
out.reverse();
out
}
pub fn append(dir: &Path, entry: &Entry) -> std::io::Result<()> {
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
let mut f = OpenOptions::new().create(true).append(true).open(history_path(dir))?;
writeln!(f, "{line}")
}
pub fn load(dir: &Path) -> Vec<Entry> {
std::fs::read_to_string(history_path(dir)).map(|t| parse(&t)).unwrap_or_default()
}
/// Mark a job as in flight. Its status is already `interrupted`, so if the
/// app never reaches `finish`, `recover` can file it without editing.
pub fn begin(dir: &Path, entry: &Entry) {
if let Ok(json) = serde_json::to_string(entry) {
let _ = std::fs::write(running_path(dir), json);
}
}
/// Record the finished job and clear the in-flight marker.
pub fn finish(dir: &Path, entry: &Entry) {
let _ = append(dir, entry);
let _ = std::fs::remove_file(running_path(dir));
}
/// On startup: file a job left over from a crash as `interrupted`.
pub fn recover(dir: &Path) -> Option<Entry> {
let path = running_path(dir);
let text = std::fs::read_to_string(&path).ok()?;
let _ = std::fs::remove_file(&path);
let mut entry: Entry = serde_json::from_str(&text).ok()?;
entry.status = "interrupted".into();
append(dir, &entry).ok()?;
Some(entry)
}
pub fn clear(dir: &Path) -> bool {
match std::fs::remove_file(history_path(dir)) {
Ok(()) => true,
Err(e) => e.kind() == std::io::ErrorKind::NotFound,
}
}
pub fn items(dir: &Path) -> Vec<Item> {
load(dir)
.into_iter()
.map(|entry| Item {
output_exists: Path::new(&entry.output).is_file(),
inputs_exist: entry.inputs.iter().map(|i| Path::new(&i.path).is_file()).collect(),
entry,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc_history_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn entry(id: &str) -> Entry {
Entry {
id: id.into(),
started: 1_758_780_000_000,
status: "interrupted".into(),
error: None,
inputs: vec![InputFile { path: r"D:\rec\a.flv".into(), size: 10, duration: 60.0 }],
output: r"D:\rec\a_concat.mp4".into(),
output_size: 0,
faststart: false,
elapsed: 0.0,
mb_per_s: 0.0,
deleted_originals: 0,
renamed: false,
}
}
#[test]
fn roundtrips_through_a_line() {
let mut e = entry("1");
e.status = "done".into();
e.output_size = 1_705_000_000;
e.mb_per_s = 1164.2;
let back = parse(&serde_json::to_string(&e).unwrap());
assert_eq!(back, vec![e]);
}
#[test]
fn parse_skips_junk_and_returns_newest_first() {
let a = serde_json::to_string(&entry("a")).unwrap();
let b = serde_json::to_string(&entry("b")).unwrap();
let text = format!("{a}\n\nnot json at all\n{{\"half\":\n{b}\n");
let ids: Vec<_> = parse(&text).into_iter().map(|e| e.id).collect();
assert_eq!(ids, ["b", "a"]);
}
#[test]
fn old_lines_missing_new_fields_still_load() {
let line = r#"{"id":"x","started":1,"status":"done","inputs":[],"output":"o.mp4"}"#;
let e = &parse(line)[0];
assert_eq!(e.id, "x");
assert!(!e.faststart);
assert_eq!(e.deleted_originals, 0);
}
#[test]
fn errors_are_capped() {
let mut e = entry("1");
e.set_error(&"x".repeat(5000));
assert_eq!(e.error.as_ref().unwrap().chars().count(), MAX_ERROR + 1);
}
#[test]
fn finish_appends_and_clears_the_running_marker() {
let d = tmp("finish");
let mut e = entry("1");
begin(&d, &e);
assert!(running_path(&d).exists());
e.status = "done".into();
finish(&d, &e);
assert!(!running_path(&d).exists());
assert_eq!(load(&d)[0].status, "done");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn a_crashed_job_is_recovered_as_interrupted() {
let d = tmp("recover");
let mut e = entry("crashed");
e.status = "done".into(); // whatever was written, recovery must not trust it
begin(&d, &e);
let got = recover(&d).expect("should recover");
assert_eq!(got.status, "interrupted");
assert!(!running_path(&d).exists());
assert_eq!(load(&d).len(), 1);
assert!(recover(&d).is_none(), "recovering twice must not duplicate");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn clear_empties_history_and_tolerates_no_file() {
let d = tmp("clear");
append(&d, &entry("1")).unwrap();
assert!(clear(&d));
assert!(load(&d).is_empty());
assert!(clear(&d), "clearing an empty history is not an error");
std::fs::remove_dir_all(&d).unwrap();
}
}
+124 -34
View File
@@ -1,25 +1,32 @@
//! Video Concat — Tauri backend. //! Video Concat — Tauri backend.
//! A focused tool: stream-copy concat of split recordings via ffmpeg, with a //! 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 concat;
mod history;
mod portable;
mod tools; mod tools;
use std::path::PathBuf;
use std::process::Child; use std::process::Child;
use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::thread;
use serde::Serialize; 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 struct AppState {
pub proc: Mutex<Option<Child>>, pub proc: Mutex<Option<Child>>,
pub cancel: AtomicBool, pub cancel: AtomicBool,
pub ffmpeg: Option<String>, pub ffmpeg: Option<String>,
pub ffprobe: 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 { impl AppState {
@@ -29,14 +36,19 @@ impl AppState {
cancel: AtomicBool::new(false), cancel: AtomicBool::new(false),
ffmpeg: tools::find_tool("ffmpeg"), ffmpeg: tools::find_tool("ffmpeg"),
ffprobe: tools::find_tool("ffprobe"), ffprobe: tools::find_tool("ffprobe"),
data_dir: portable::data_dir(),
webview_portable: AtomicBool::new(false),
} }
} }
} }
#[derive(Serialize)] #[derive(Serialize)]
struct ToolsInfo { struct AppInfo {
ffmpeg: Option<String>, ffmpeg: Option<String>,
ffprobe: 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)] #[derive(Serialize)]
@@ -48,31 +60,23 @@ struct DurationInfo {
// ── Commands ───────────────────────────────────────────────────────────────── // ── Commands ─────────────────────────────────────────────────────────────────
#[tauri::command] #[tauri::command]
fn get_tools(state: State<AppState>) -> ToolsInfo { fn app_info(state: State<AppState>) -> AppInfo {
ToolsInfo { AppInfo {
ffmpeg: state.ffmpeg.clone(), ffmpeg: state.ffmpeg.clone(),
ffprobe: state.ffprobe.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. /// Probe several files in parallel; returns durations in seconds.
#[tauri::command] #[tauri::command]
fn probe_durations(state: State<AppState>, paths: Vec<String>) -> Vec<DurationInfo> { fn probe_durations(state: State<AppState>, paths: Vec<String>) -> Vec<DurationInfo> {
let ffprobe = match &state.ffprobe { let durations = match &state.ffprobe {
Some(p) => p.clone(), Some(ff) => tools::probe_many(ff, &paths),
None => return paths.into_iter().map(|p| DurationInfo { path: p, duration: 0.0 }).collect(), None => vec![0.0; paths.len()],
}; };
let handles: Vec<_> = paths paths.into_iter().zip(durations).map(|(path, duration)| DurationInfo { path, duration }).collect()
.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()
} }
#[tauri::command] #[tauri::command]
@@ -80,21 +84,22 @@ fn is_video(path: String) -> bool {
tools::is_video(&path) 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, // 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 // otherwise the native window stops pumping messages and Windows paints the
// app "Not responding". `spawn_blocking` keeps the UI responsive while the // app "Not responding". `spawn_blocking` keeps the UI responsive while the
// command still resolves with the final result. // command still resolves with the final result.
#[tauri::command] #[tauri::command]
async fn start_concat( async fn start_concat(app: AppHandle, req: ConcatRequest) -> Result<ConcatResult, String> {
app: AppHandle,
files: Vec<String>,
output: String,
) -> Result<ConcatResult, String> {
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let ffmpeg = state.ffmpeg.clone().ok_or("ffmpeg not found.")?; let ffmpeg = state.ffmpeg.clone().ok_or("ffmpeg not found.")?;
let ffprobe = state.ffprobe.clone().unwrap_or_else(|| "ffprobe".into()); 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 .await
.map_err(|e| format!("concat task failed: {e}"))? .map_err(|e| format!("concat task failed: {e}"))?
@@ -102,7 +107,7 @@ async fn start_concat(
#[tauri::command] #[tauri::command]
fn cancel(state: State<AppState>) { 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() { if let Some(child) = state.proc.lock().unwrap().as_mut() {
// Kill the whole tree, not just the direct child. // Kill the whole tree, not just the direct child.
tools::kill_process_tree(child.id()); tools::kill_process_tree(child.id());
@@ -111,8 +116,13 @@ fn cancel(state: State<AppState>) {
} }
#[tauri::command] #[tauri::command]
fn delete_files(paths: Vec<String>) -> usize { fn history_list(state: State<AppState>) -> Vec<history::Item> {
paths.iter().filter(|p| std::fs::remove_file(p).is_ok()).count() 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. /// 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}")) 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.setup(|app| { .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(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_tools, app_info,
probe_durations, probe_durations,
is_video, is_video,
path_exists,
start_concat, start_concat,
cancel, cancel,
delete_files, history_list,
history_clear,
bililive_rename, bililive_rename,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
#[cfg(test)]
mod tests {
use super::bililive_rename as rename;
#[test]
fn renames_a_bililive_stem() {
assert_eq!(
rename("[2026-03-14 22-46-37][纳豆nado][今晚九点来捏!]".into()).as_deref(),
Some("2026年03月14日【直播回放】今晚九点来捏!")
);
}
#[test]
fn ignores_the_concat_suffix_and_padding() {
assert_eq!(
rename(" [2026-03-14 22-46-37][up][title]_concat".into()).as_deref(),
Some("2026年03月14日【直播回放】title")
);
}
#[test]
fn title_may_contain_brackets_of_its_own() {
assert_eq!(
rename("[2026-07-08 21-00-00][up][part [1] 【随机专场】]".into()).as_deref(),
Some("2026年07月08日【直播回放】part [1] 【随机专场】")
);
}
#[test]
fn rejects_anything_that_is_not_the_bililive_shape() {
for s in [
"",
"plain_name",
"[2026-03-14][up][title]", // no time part
"[26-03-14 22-46-37][up][title]", // 2-digit year
"[2026-3-14 22-46-37][up][title]", // 1-digit month
"[2026-ab-14 22-46-37][up][title]", // non-digit
"[2026-03-14 22-46-37][up][]", // empty title
"[2026-03-14 22-46-37][up]", // no title
"[2026-03-14 22-46-37][up]title", // title not bracketed
] {
assert_eq!(rename(s.into()), None, "should reject {s:?}");
}
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Portable layout: everything the app writes lives next to the exe, so the
//! whole folder can be copied to another machine and keeps working as-is.
//!
//! ```text
//! VideoConcat\
//! Video Concat.exe, ffmpeg.exe, ffprobe.exe
//! data\history.jsonl concat history, one JSON object per line
//! data\running.json the job in flight; left behind = interrupted
//! data\webview\ WebView2 profile (theme and UI preferences)
//! ```
use std::path::{Path, PathBuf};
/// Directory the executable lives in.
pub fn app_dir() -> Option<PathBuf> {
std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
}
/// `<app>/data`, created on demand. `None` when the app folder is read-only
/// (e.g. Program Files or a locked-down USB stick) - callers then run without
/// history rather than silently writing somewhere outside the folder.
pub fn data_dir() -> Option<PathBuf> {
let dir = app_dir()?.join("data");
std::fs::create_dir_all(&dir).ok()?;
writable(&dir).then_some(dir)
}
/// `create_dir_all` succeeding on an existing folder says nothing about write
/// access, so actually try writing.
pub fn writable(dir: &Path) -> bool {
let probe = dir.join(format!(".write_test_{}", std::process::id()));
let ok = std::fs::write(&probe, b"").is_ok();
let _ = std::fs::remove_file(&probe);
ok
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writable_detects_a_usable_folder() {
let dir = std::env::temp_dir().join(format!("vc_portable_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert!(writable(&dir));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "probe file left behind");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn writable_rejects_a_missing_folder() {
let missing = std::env::temp_dir().join(format!("vc_missing_{}", std::process::id())).join("nested");
assert!(!writable(&missing));
}
}
+54 -6
View File
@@ -40,15 +40,10 @@ pub fn kill_process_tree(pid: u32) {
} }
} }
/// Directory the executable lives in (resources dir when bundled).
fn app_dir() -> Option<PathBuf> {
std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
}
/// Locate ffmpeg / ffprobe: next to the exe, then on PATH. /// Locate ffmpeg / ffprobe: next to the exe, then on PATH.
pub fn find_tool(name: &str) -> Option<String> { pub fn find_tool(name: &str) -> Option<String> {
let exe = format!("{name}.exe"); 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", ".."] { for sub in ["", "bin", "resources", "_internal", ".."] {
let cand = base.join(sub).join(&exe); let cand = base.join(sub).join(&exe);
if cand.exists() { 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. /// Format seconds as H:MM:SS or M:SS.
pub fn fmt_dur(seconds: f64) -> String { pub fn fmt_dur(seconds: f64) -> String {
if seconds < 0.0 { if seconds < 0.0 {
@@ -124,3 +139,36 @@ pub fn is_video(path: &str) -> bool {
.map(|e| EXTS.contains(&e.to_lowercase().as_str())) .map(|e| EXTS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false) .unwrap_or(false)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_dur_formats_minutes_and_hours() {
assert_eq!(fmt_dur(0.0), "0:00");
assert_eq!(fmt_dur(59.6), "1:00");
assert_eq!(fmt_dur(3599.0), "59:59");
assert_eq!(fmt_dur(3661.0), "1:01:01");
assert_eq!(fmt_dur(-1.0), "…");
}
#[test]
fn is_video_is_case_insensitive_and_strict() {
assert!(is_video(r"D:\rec\a.FLV"));
assert!(is_video("x.m2ts"));
assert!(!is_video("notes.txt"));
assert!(!is_video("no_extension"));
assert!(!is_video("video.mp4.part"));
}
#[test]
fn probe_many_keeps_order_and_survives_missing_files() {
// no ffprobe needed: a bogus binary makes every probe fail -> 0.0,
// which is exactly the contract callers rely on
let paths: Vec<String> = (0..20).map(|i| format!("missing_{i}.flv")).collect();
let got = probe_many("definitely-not-ffprobe", &paths);
assert_eq!(got.len(), 20);
assert!(got.iter().all(|&d| d == 0.0));
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Video Concat", "productName": "Video Concat",
"version": "5.0.0", "version": "5.1.0",
"identifier": "com.youfu.video-concat", "identifier": "com.youfu.video-concat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -13,6 +13,8 @@
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
{ {
"label": "main",
"create": false,
"title": "Video Concat", "title": "Video Concat",
"width": 960, "width": 960,
"height": 820, "height": 820,
+45 -7
View File
@@ -3,21 +3,56 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { getCurrentWebview } from "@tauri-apps/api/webview"; 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"; 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 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 interface ConcatProgress { pct: number; status: string; elapsed: number; eta: number; speed: number; }
export const getTools = () => invoke<Tools>("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<AppInfo>("app_info");
export const probeDurations = (paths: string[]) => invoke<DurationInfo[]>("probe_durations", { paths }); export const probeDurations = (paths: string[]) => invoke<DurationInfo[]>("probe_durations", { paths });
export const isVideo = (path: string) => invoke<boolean>("is_video", { path }); export const isVideo = (path: string) => invoke<boolean>("is_video", { path });
export const startConcat = (files: string[], output: string) => export const pathExists = (path: string) => invoke<boolean>("path_exists", { path });
invoke<ConcatResult>("start_concat", { files, output }); export const startConcat = (req: ConcatRequest) => invoke<ConcatResult>("start_concat", { req });
export const cancel = () => invoke<void>("cancel"); export const cancel = () => invoke<void>("cancel");
export const deleteFiles = (paths: string[]) => invoke<number>("delete_files", { paths }); export const historyList = () => invoke<HistoryItem[]>("history_list");
export const historyClear = () => invoke<boolean>("history_clear");
export const bililiveRename = (stem: string) => invoke<string | null>("bililive_rename", { stem }); export const bililiveRename = (stem: string) => invoke<string | null>("bililive_rename", { stem });
export const onConcatProgress = (cb: (p: ConcatProgress) => void): Promise<UnlistenFn> => export const onConcatProgress = (cb: (p: ConcatProgress) => void): Promise<UnlistenFn> =>
@@ -42,4 +77,7 @@ export async function pickFolder(): Promise<string | null> {
return typeof sel === "string" ? sel : null; 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); export const reveal = (path: string) => revealItemInDir(path);
+5
View File
@@ -49,6 +49,11 @@ export class FileList {
return this.items.filter((i) => i.checked).map((i) => i.path); 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[] { orderedPaths(): string[] {
return this.items.map((i) => i.path); return this.items.map((i) => i.path);
} }
+46 -19
View File
@@ -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 "./styles.css";
import { h } from "./dom"; import { h } from "./dom";
import { toast } from "./ui"; import { toast } from "./ui";
import { getTools, onFilesDropped } from "./api"; import { appInfo, onFilesDropped, type AppInfo } from "./api";
import { buildConcat } from "./views/concat"; import { buildConcat } from "./views/concat";
import { buildHistory } from "./views/history";
async function boot() { async function boot() {
// ── Theme (system default, persisted toggle) ──────────────────────────── // ── Theme (system default, persisted toggle) ────────────────────────────
const saved = localStorage.getItem("theme"); // localStorage lives in the WebView profile, which the backend keeps in
// <app>/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; const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
let theme = saved ?? (prefersDark ? "dark" : "light"); let theme = saved ?? (prefersDark ? "dark" : "light");
const applyTheme = () => document.documentElement.setAttribute("data-theme", theme); const applyTheme = () => document.documentElement.setAttribute("data-theme", theme);
applyTheme(); applyTheme();
// ── Single view: concat ───────────────────────────────────────────────── let info: AppInfo | null = null;
const concat = buildConcat(); 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" }, const themeBtn = h("button", { class: "icon-btn", title: "Toggle theme" },
theme === "dark" ? "☀" : "☾") as HTMLButtonElement; theme === "dark" ? "☀" : "☾") as HTMLButtonElement;
themeBtn.addEventListener("click", () => { themeBtn.addEventListener("click", () => {
theme = theme === "dark" ? "light" : "dark"; theme = theme === "dark" ? "light" : "dark";
localStorage.setItem("theme", theme); try { localStorage.setItem("theme", theme); } catch { /* not persisted */ }
applyTheme(); applyTheme();
themeBtn.textContent = theme === "dark" ? "☀" : "☾"; themeBtn.textContent = theme === "dark" ? "☀" : "☾";
}); });
const content = h("div", { class: "content" }, concat.el, history.el);
const app = document.getElementById("app")!; const app = document.getElementById("app")!;
app.append( app.append(
h("div", { class: "titlebar" }, h("div", { class: "titlebar" },
h("div", { class: "brand" }, h("div", { class: "brand" },
h("div", { class: "logo" }, "🎬"), h("div", { class: "logo" }, "🎬"),
h("span", {}, "Video Concat")), h("span", {}, "Video Concat")),
h("div", { class: "tabs", style: "margin:0 0 0 12px" }, tabs.merge, tabs.history),
h("div", { class: "spacer" }), h("div", { class: "spacer" }),
themeBtn, themeBtn,
), ),
h("div", { class: "content" }, concat.el), content,
); );
// ── Drag-and-drop routes to the concat view ───────────────────────────── // ── Drag-and-drop always lands in Merge ─────────────────────────────────
await onFilesDropped((paths) => concat.onDrop(paths)); await onFilesDropped((paths) => { show("merge"); concat.onDrop(paths); });
// ── ffmpeg health check ───────────────────────────────────────────────── // ── Health checks ───────────────────────────────────────────────────────
try { if (info && !info.ffmpeg) {
const tools = await getTools(); content.before(h("div", { class: "banner", style: "margin:0 22px 12px" },
if (!tools.ffmpeg) { "⚠ ffmpeg was not found. Put ffmpeg.exe & ffprobe.exe next to the app or on your PATH."));
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."); if (info && info.data_dir && !info.portable) {
app.querySelector(".content")!.before(banner); content.before(h("div", { class: "banner", style: "margin:0 22px 12px" },
} "⚠ Settings are being kept outside the app folder (WebView2 refused the data folder). "
} catch (e) { + "History still works; theme and options won't travel with the folder."));
toast(`Backend not ready: ${e}`, "err");
} }
} }
+48
View File
@@ -305,3 +305,51 @@ button, input, select { font-family: inherit; font-size: inherit; color: inherit
} }
.hint { font-size: 12px; color: var(--text-faint); } .hint { font-size: 12px; color: var(--text-faint); }
.rename-preview { font-size: 12px; color: var(--accent); min-height: 16px; } .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; }
+45 -11
View File
@@ -4,17 +4,24 @@ import { h, stem, dirname, joinPath } from "../dom";
import { FileList } from "../filelist"; import { FileList } from "../filelist";
import { makeProgress, toast } from "../ui"; import { makeProgress, toast } from "../ui";
import { import {
pickVideos, pickFolder, startConcat, cancel, deleteFiles, bililiveRename, pickVideos, pickFolder, startConcat, cancel, bililiveRename, pathExists, confirmAsk,
onConcatProgress, reveal, onConcatProgress, reveal,
} from "../api"; } from "../api";
const EXTS = [".mp4", ".mkv", ".mov", ".avi", ".ts"]; 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<void>;
}
export function buildConcat(): View { export function buildConcat(opts: { onFinished?: () => void } = {}): ConcatView {
let running = false; let running = false;
let lastOutput: string | null = null; 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 nameInput = h("input", { class: "input grow", placeholder: "output_concat" }) as HTMLInputElement;
const extSelect = h("select", { class: "input", style: "width:96px" }, 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 renamePreview = h("div", { class: "rename-preview" });
const renameBtn = h("button", { class: "btn accent-soft", disabled: true }, "✨ Rename") as HTMLButtonElement; const renameBtn = h("button", { class: "btn accent-soft", disabled: true }, "✨ Rename") as HTMLButtonElement;
const delChk = h("input", { type: "checkbox", class: "checkbox" }) as HTMLInputElement; 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 listEl = h("div", { class: "list" });
const totalEl = h("div", { class: "faint" }, "No files"); const totalEl = h("div", { class: "faint" }, "No files");
@@ -76,12 +88,20 @@ export function buildConcat(): View {
renameBtn.addEventListener("click", async () => { renameBtn.addEventListener("click", async () => {
const renamed = await bililiveRename(nameInput.value.trim()); 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); nameInput.addEventListener("input", refresh);
dirInput.addEventListener("input", refresh); dirInput.addEventListener("input", refresh);
extSelect.addEventListener("change", refresh); extSelect.addEventListener("change", refresh);
async function load(paths: string[]) {
files.clearAll();
nameInput.value = "";
dirInput.value = "";
renamedTo = null;
await addFiles(paths);
}
async function start() { async function start() {
const checked = files.checkedPaths(); const checked = files.checkedPaths();
const out = outPath(); const out = outPath();
@@ -90,6 +110,11 @@ export function buildConcat(): View {
toast("Output name matches a source file — choose another.", "err"); toast("Output name matches a source file — choose another.", "err");
return; 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; running = true;
startBtn.disabled = true; startBtn.disabled = true;
@@ -102,16 +127,19 @@ export function buildConcat(): View {
}); });
try { 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; lastOutput = res.output;
progress.set(1, `Done — ${res.size_mb.toFixed(1)} MB`); progress.set(1, `Done — ${res.size_mb.toFixed(1)} MB`);
toast("Concatenation complete", "ok"); toast("Concatenation complete", "ok");
revealBtn.style.display = ""; revealBtn.style.display = "";
if (res.deleted) toast(`Deleted ${res.deleted} original file(s)`, "ok");
if (delChk.checked) {
const n = await deleteFiles(checked);
if (n) toast(`Deleted ${n} original file(s)`, "ok");
}
} catch (e) { } catch (e) {
const msg = String(e); const msg = String(e);
if (msg.includes("__cancelled__")) { progress.set(0, "Stopped by user."); } if (msg.includes("__cancelled__")) { progress.set(0, "Stopped by user."); }
@@ -121,6 +149,7 @@ export function buildConcat(): View {
running = false; running = false;
stopBtn.disabled = true; stopBtn.disabled = true;
refresh(); refresh();
opts.onFinished?.();
} }
} }
@@ -171,6 +200,11 @@ export function buildConcat(): View {
h("label", { class: "row", style: "cursor:pointer" }, delChk, h("label", { class: "row", style: "cursor:pointer" }, delChk,
h("span", {}, "Delete original files after a successful merge")), 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("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 // 5 · Run
h("div", { class: "card" }, h("div", { class: "card" },
@@ -180,5 +214,5 @@ export function buildConcat(): View {
), ),
); );
return { el, onDrop: addFiles }; return { el, onDrop: addFiles, load };
} }
+158
View File
@@ -0,0 +1,158 @@
// History view — every merge the app has run, newest first.
// Stored by the backend in <app>/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<HistoryStatus, string> = {
done: "Done",
failed: "Failed",
cancelled: "Stopped",
interrupted: "Interrupted",
};
export interface HistoryView {
el: HTMLElement;
refresh: () => Promise<void>;
}
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 };
}
+179
View File
@@ -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()
+248
View File
@@ -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".*?</linearGradient>', 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".*?</radialGradient>', 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'<path d="([^"]+)"', svg_text).group(1)
return lin_stops, glow_xy, glow_r, glow_rgb, rad_stops, path_d
def ramp(stops, t):
"""Piecewise-linear colour ramp over an array of positions in [0, 1]."""
offs = np.array([s[0] for s in stops])
cols = np.array([s[1] for s in stops], dtype=np.float64)
idx = np.clip(np.searchsorted(offs, t) - 1, 0, len(offs) - 2)
span = np.maximum(offs[idx + 1] - offs[idx], 1e-9)
f = ((t - offs[idx]) / span)[..., None]
return cols[idx] * (1 - f) + cols[idx + 1] * f
def ramp1(stops, t):
offs = np.array([s[0] for s in stops])
vals = np.array([s[1] for s in stops], dtype=np.float64)
idx = np.clip(np.searchsorted(offs, t) - 1, 0, len(offs) - 2)
span = np.maximum(offs[idx + 1] - offs[idx], 1e-9)
f = (t - offs[idx]) / span
return vals[idx] * (1 - f) + vals[idx + 1] * f
def flatten_path(d, scale):
"""Flatten the glyph path data into polygons, subdividing beziers."""
toks = re.findall(r"([MLCZ])([-\d.,e ]*)", d)
polys, cur, pos, start = [], [], (0.0, 0.0), (0.0, 0.0)
def nums(s):
return [float(v) for v in re.split(r"[, ]+", s.strip()) if v]
for cmd, args in toks:
a = nums(args)
if cmd == "M":
if len(cur) > 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("<HHH", 0, 1, len(images)))
offset = 6 + 16 * len(images)
for im, blob in zip(images, blobs):
w = 0 if im.width >= 256 else im.width
h = 0 if im.height >= 256 else im.height
f.write(struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(blob), offset))
offset += len(blob)
for blob in blobs:
f.write(blob)
PNG_TARGETS = {
"32x32.png": 32,
"64x64.png": 64,
"128x128.png": 128,
"128x128@2x.png": 256,
"icon.png": 512,
"Square30x30Logo.png": 30,
"Square44x44Logo.png": 44,
"Square71x71Logo.png": 71,
"Square89x89Logo.png": 89,
"Square107x107Logo.png": 107,
"Square142x142Logo.png": 142,
"Square150x150Logo.png": 150,
"Square284x284Logo.png": 284,
"Square310x310Logo.png": 310,
"StoreLogo.png": 50,
}
ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
def main():
params = parse_svg(SVG.read_text(encoding="utf-8"))
cache = {}
def get(size):
if size not in cache:
cache[size] = render(size, *params)
return cache[size]
for name, size in PNG_TARGETS.items():
get(size).save(ICONS / name, "PNG", optimize=True)
print(" %-24s %dx%d" % (name, size, size))
write_ico(ICONS / "icon.ico", [get(s) for s in ICO_SIZES])
print(" %-24s %s" % ("icon.ico", ICO_SIZES))
if __name__ == "__main__":
main()
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Replace the icon inside an already-built .exe, without rebuilding it.
Useful when a binary you want to keep byte-for-byte was built with a bad icon.
Only the RT_ICON / RT_GROUP_ICON resources are rewritten; no code is touched.
python tools/patch_exe_icon.py "C:/path/to/App.exe"
python tools/patch_exe_icon.py "C:/path/to/App.exe" --icon other.ico --no-backup
Sizes up to 64px are embedded as 32-bit BGRA DIBs (what Windows shells have
always understood); 128 and 256 go in PNG-compressed, as Vista+ expects.
Windows only. Requires: pillow.
"""
from __future__ import annotations
import argparse
import ctypes
import shutil
import struct
import sys
from ctypes import wintypes
from io import BytesIO
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_ICO = ROOT / "src-tauri" / "icons" / "icon.ico"
RT_ICON = 3
RT_GROUP_ICON = 14
LANG_EN_US = 1033
PNG_FROM = 128 # sizes >= 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(
"<IiiHHIIiiII", 40, w, h * 2, 1, 32, 0, len(xor) + len(mask), 0, 0, 0, 0
)
return bytes(header + xor + mask)
def to_png(img: Image.Image) -> 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("<HHH", 0, 1, len(images))
for i, (im, blob) in enumerate(zip(images, blobs)):
w = 0 if im.width >= 256 else im.width
h = 0 if im.height >= 256 else im.height
data += struct.pack("<BBBBHHIH", w, h, 0, 0, 1, 32, len(blob), first_id + i)
return data
def patch(exe: Path, ico: Path, group_id: int, backup: bool) -> 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())