v5.1.0: history tab, portable data folder, optional faststart, new icons & build tools
@@ -10,10 +10,37 @@ removed, so it needs **no Python and no ML model** — just ffmpeg.
|
||||
## Features
|
||||
|
||||
- Drag-and-drop / browse to add videos, reorder, select which to merge.
|
||||
- Stream-copy concat with honest real-time progress (and a `+faststart`
|
||||
finalising pass so the output seeks instantly).
|
||||
- Stream-copy concat with honest real-time progress. On local NVMe a merge runs
|
||||
at ~95 % of plain file-copy speed — the disk is the limit, not the app.
|
||||
- **History** tab: every merge (done, failed, stopped, or interrupted by a
|
||||
crash) with its files, size, time and MB/s. Show the output in its folder, or
|
||||
load the same files again with "Merge again".
|
||||
- BiliBili-Live filename helper (`[date][uploader][title]` → readable name).
|
||||
- Optionally delete the originals after a successful merge.
|
||||
- Optional **Web-optimize MP4 (`+faststart`)**, off by default. It moves the
|
||||
MP4 index to the front so playback can begin mid-download, but ffmpeg has to
|
||||
re-read and re-write the whole file to do it — measured at 17–56 % extra time
|
||||
locally and 60 % over a NAS. Local players don't need it.
|
||||
|
||||
## Portable: everything stays in one folder
|
||||
|
||||
Nothing is written outside the app folder — not to `%APPDATA%`, not to the
|
||||
registry. Copy the folder to another PC and history, theme and options come
|
||||
with it:
|
||||
|
||||
```
|
||||
VideoConcat\
|
||||
Video Concat.exe
|
||||
ffmpeg.exe, ffprobe.exe
|
||||
data\
|
||||
history.jsonl <- merge history, one JSON line per job
|
||||
running.json <- only while a merge runs; left behind = "interrupted"
|
||||
webview\ <- WebView2 profile (theme, options)
|
||||
```
|
||||
|
||||
`data\` is created on first run. If the folder isn't writable (say, under
|
||||
Program Files), the app still works but shows a banner and keeps no history.
|
||||
It needs the WebView2 runtime, which ships with Windows 10 and 11.
|
||||
|
||||
## Build a standalone (copy-paste) version
|
||||
|
||||
@@ -28,15 +55,69 @@ dist-portable\VideoConcat\
|
||||
```
|
||||
|
||||
Copy that whole `VideoConcat` folder to any Windows PC and run it — nothing else
|
||||
to install.
|
||||
to install. To **upgrade** an existing copy, replace only `Video Concat.exe`
|
||||
and keep its `data\` folder; history carries over.
|
||||
|
||||
### Build requirements (on the build machine only)
|
||||
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [Rust](https://rustup.rs/) (`winget install Rustlang.Rustup`)
|
||||
- Visual Studio 2022 with the **Desktop development with C++** workload
|
||||
- ffmpeg (in `C:\ffmpeg\bin` or on `PATH`)
|
||||
- ffmpeg: in `C:\ffmpeg\bin`, on `PATH`, or point `FFMPEG_DIR` at any folder
|
||||
holding `ffmpeg.exe` + `ffprobe.exe` (an existing portable copy works:
|
||||
`set FFMPEG_DIR=%USERPROFILE%\Documents\VideoConcat`)
|
||||
|
||||
Building from a network share is slow and fragile (thousands of small files in
|
||||
`node_modules` and `target`). Copy the source to a local disk first.
|
||||
|
||||
## Develop
|
||||
|
||||
Double-click **`dev.bat`** (hot-reload UI + Rust). Needs ffmpeg on `PATH`.
|
||||
In dev mode the `data\` folder sits next to the debug exe in `src-tauri\target\debug\`.
|
||||
|
||||
## Test
|
||||
|
||||
```
|
||||
npm run build # the Rust tests embed dist/, so build the UI first
|
||||
cd src-tauri && cargo test
|
||||
```
|
||||
|
||||
Covers the BiliBili rename parser, concat-list path escaping (`\\?\` and UNC
|
||||
prefixes, quotes), duration reuse, "never delete the output", and history
|
||||
crash recovery.
|
||||
|
||||
## Benchmark
|
||||
|
||||
`tools/bench_concat.py` times each pipeline stage with the exact ffmpeg flags
|
||||
the app uses, against the I/O floor of a plain file copy:
|
||||
|
||||
```
|
||||
python tools/bench_concat.py --ffmpeg-dir <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`.
|
||||
|
||||
@@ -16,13 +16,16 @@ cd /d "%~dp0"
|
||||
REM --- 1. Locate ffmpeg.exe + ffprobe.exe --------------------
|
||||
set "FFMPEG="
|
||||
set "FFPROBE="
|
||||
if exist "C:\ffmpeg\bin\ffmpeg.exe" set "FFMPEG=C:\ffmpeg\bin\ffmpeg.exe"
|
||||
if exist "C:\ffmpeg\bin\ffprobe.exe" set "FFPROBE=C:\ffmpeg\bin\ffprobe.exe"
|
||||
REM Optional override: set FFMPEG_DIR=C:\folder\holding\ffmpeg-and-ffprobe
|
||||
if defined FFMPEG_DIR if exist "%FFMPEG_DIR%\ffmpeg.exe" set "FFMPEG=%FFMPEG_DIR%\ffmpeg.exe"
|
||||
if defined FFMPEG_DIR if exist "%FFMPEG_DIR%\ffprobe.exe" set "FFPROBE=%FFMPEG_DIR%\ffprobe.exe"
|
||||
if not defined FFMPEG if exist "C:\ffmpeg\bin\ffmpeg.exe" set "FFMPEG=C:\ffmpeg\bin\ffmpeg.exe"
|
||||
if not defined FFPROBE if exist "C:\ffmpeg\bin\ffprobe.exe" set "FFPROBE=C:\ffmpeg\bin\ffprobe.exe"
|
||||
if not defined FFMPEG for /f "delims=" %%i in ('where ffmpeg 2^>nul') do if not defined FFMPEG set "FFMPEG=%%i"
|
||||
if not defined FFPROBE for /f "delims=" %%i in ('where ffprobe 2^>nul') do if not defined FFPROBE set "FFPROBE=%%i"
|
||||
|
||||
if not defined FFMPEG ( echo [ERROR] ffmpeg.exe not found. Install ffmpeg or place it in C:\ffmpeg\bin & pause & exit /b 1 )
|
||||
if not defined FFPROBE ( echo [ERROR] ffprobe.exe not found. Install ffmpeg or place it in C:\ffmpeg\bin & pause & exit /b 1 )
|
||||
if not defined FFMPEG ( echo [ERROR] ffmpeg.exe not found. Set FFMPEG_DIR, install ffmpeg, or place it in C:\ffmpeg\bin & pause & exit /b 1 )
|
||||
if not defined FFPROBE ( echo [ERROR] ffprobe.exe not found. Set FFMPEG_DIR, install ffmpeg, or place it in C:\ffmpeg\bin & pause & exit /b 1 )
|
||||
echo Using ffmpeg: !FFMPEG!
|
||||
echo Using ffprobe: !FFPROBE!
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "video-concat",
|
||||
"private": true,
|
||||
"version": "5.0.0",
|
||||
"version": "5.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "video-concat"
|
||||
version = "5.0.0"
|
||||
version = "5.1.0"
|
||||
description = "Video Concat — merge split stream recordings into one file"
|
||||
authors = ["youfu"]
|
||||
edition = "2021"
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<title>Video Concat</title>
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="22" y1="22" x2="490" y2="490" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#00001B"/>
|
||||
<stop offset="0.125" stop-color="#000044"/>
|
||||
<stop offset="0.25" stop-color="#130469"/>
|
||||
<stop offset="0.375" stop-color="#130F89"/>
|
||||
<stop offset="0.5" stop-color="#1328AB"/>
|
||||
<stop offset="0.625" stop-color="#0F4CCF"/>
|
||||
<stop offset="0.75" stop-color="#006EE5"/>
|
||||
<stop offset="0.875" stop-color="#0790EA"/>
|
||||
<stop offset="1" stop-color="#009DE0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="280" cy="0" r="420" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFC9FF" stop-opacity="0.980"/>
|
||||
<stop offset="0.15" stop-color="#FFC9FF" stop-opacity="0.672"/>
|
||||
<stop offset="0.3" stop-color="#FFC9FF" stop-opacity="0.411"/>
|
||||
<stop offset="0.5" stop-color="#FFC9FF" stop-opacity="0.228"/>
|
||||
<stop offset="0.7" stop-color="#FFC9FF" stop-opacity="0.119"/>
|
||||
<stop offset="1" stop-color="#FFC9FF" stop-opacity="0.037"/>
|
||||
</radialGradient>
|
||||
<clipPath id="tileClip">
|
||||
<rect x="14" y="14" width="484" height="484" rx="99"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#tileClip)">
|
||||
<rect x="14" y="14" width="484" height="484" fill="url(#tile)"/>
|
||||
<rect x="14" y="14" width="484" height="484" fill="url(#glow)"/>
|
||||
</g>
|
||||
<g fill="#FFFFFF" fill-rule="evenodd">
|
||||
<path d="M201.44,359.5C202.78,358.83,207.2,354.36,208.52,352.33C210.51,349.27,212.34,345.71,213.21,343.17C214.14,340.45,215.85,334.76,216.58,331.92C217.51,328.35,219.71,320.72,220.79,317.33C221.75,314.32,226.6,304.45,227.99,302.67C228.46,302.07,229.36,300.87,229.99,300C234.04,294.42,241.08,288.94,249.67,284.68C255.6,281.74,262.32,279.43,266.14,279C267.02,278.9,268.31,278.66,269,278.46C270.25,278.09,270.25,278.09,333.58,278C403.12,277.9,398.06,278.03,402.63,276.19C412.49,272.22,418.07,260.84,415.2,250.58C413.19,243.38,409.17,238.25,403.72,235.91C399.12,233.94,404.66,234.09,334.58,234C271.42,233.91,271.42,233.91,270.16,233.54C269.47,233.34,268.2,233.1,267.33,233C263.44,232.56,253.4,229.19,248.75,226.76C245.27,224.94,240.82,222,238.49,219.95C237.71,219.28,236.48,218.29,235.75,217.75C228.02,212.09,221.63,199.89,217.09,182.08C212.96,165.93,209.75,159.42,202.96,153.5C196.94,148.25,190.74,148.38,195.59,153.65C198.5,156.81,198.53,157.04,198.66,176.83C198.83,202.41,198.83,202.43,195.53,208.01C194.07,210.48,194.09,210.69,196.08,212.83C196.55,213.34,197.26,214.19,197.65,214.72C198.04,215.25,199.02,216.42,199.82,217.32C200.63,218.22,201.54,219.33,201.84,219.77C202.14,220.22,202.89,221.22,203.5,222C204.11,222.78,204.86,223.79,205.17,224.24C205.48,224.7,206.23,225.64,206.83,226.33C207.44,227.02,208.37,228.18,208.9,228.92C209.44,229.65,211.31,231.7,213.06,233.47C216.19,236.64,217.54,237.77,222.55,241.46C226.25,244.18,227.32,245.19,227.11,245.75C226.75,246.68,219.96,244.82,214.68,242.34C213.35,241.71,211.86,241.04,211.39,240.85C210.2,240.39,206.85,238.69,205.31,237.78C204.61,237.37,202.62,236.31,200.89,235.44C199.16,234.57,196.69,233.22,195.4,232.44C189.56,228.93,183.73,227,178.97,227C175.52,227,175.49,228.27,178.84,231.89C182,235.32,182.8,238.53,182.83,247.87C182.83,249.8,182.93,251.66,183.09,252.74C183.33,254.34,183.33,254.65,183.09,256.32C182.73,258.71,182.66,270.13,182.98,272.42C183.19,273.93,183.12,275.12,182.49,280.25C182.05,283.93,183.03,284.57,187,283.23C188.94,282.57,195.5,279.32,197.83,277.85C198.89,277.19,201.78,275.63,204.25,274.38C206.72,273.14,209.35,271.78,210.08,271.37C215.49,268.31,226.22,264.9,226.62,266.11C226.77,266.56,223.89,269.04,220.75,271.17C220.29,271.48,219.2,272.39,218.33,273.18C217.46,273.97,216,275.18,215.08,275.87C213.18,277.3,211.99,278.5,210.59,280.42C210.05,281.15,209.15,282.28,208.58,282.92C208.02,283.56,207.02,284.8,206.37,285.67C205.72,286.54,204.75,287.78,204.22,288.44C203.68,289.09,202.41,290.81,201.38,292.27C199.52,294.9,194.87,299.8,191.37,302.8C189.69,304.24,189.73,304.95,191.64,307.12C192.35,307.92,193.32,309.16,193.79,309.86C194.27,310.56,195.11,311.65,195.66,312.28C197.05,313.85,198.27,316.32,198.5,318C199.11,322.59,199.16,323.16,199.06,324.34C199,325.06,199.04,326.21,199.16,327.01C199.32,328.2,199.31,328.73,199.08,330.42C198.63,333.64,198.67,341.83,199.14,344.24C199.56,346.38,199.59,347.8,199.23,349.42C198.72,351.77,198.71,359.06,199.22,359.57C199.78,360.13,200.22,360.11,201.44,359.5ZM243.71,272.12C243.52,271.92,243.28,271.45,243.18,271.08C242.93,270.16,242.93,242.68,243.18,241.51C243.61,239.5,245.13,239.38,247.71,241.13C248.1,241.4,249.62,242.35,251.08,243.25C252.55,244.15,255.62,246.12,257.92,247.63C260.21,249.14,263.25,251.09,264.67,251.96C270.66,255.64,270.78,256.21,266.21,259.18C265.04,259.94,262.17,261.81,259.83,263.34C257.5,264.86,255.1,266.4,254.5,266.75C253.9,267.11,251.96,268.36,250.19,269.54C245.49,272.66,244.55,273.03,243.71,272.12Z"/>
|
||||
</g>
|
||||
<g fill="#FFFFFF" fill-rule="evenodd">
|
||||
<rect x="113" y="147" width="79" height="60" rx="12"/>
|
||||
<rect x="181" y="151.00" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="162.25" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="173.50" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="184.75" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="196.00" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="96" y="226" width="79" height="60" rx="12"/>
|
||||
<rect x="164" y="230.00" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="164" y="241.25" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="164" y="252.50" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="164" y="263.75" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="164" y="275.00" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="113" y="305" width="79" height="60" rx="12"/>
|
||||
<rect x="181" y="309.00" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="320.25" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="331.50" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="342.75" width="7.5" height="7.5" rx="1.8"/>
|
||||
<rect x="181" y="354.00" width="7.5" height="7.5" rx="1.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -1,23 +1,47 @@
|
||||
//! ffmpeg concat-demuxer worker with honest, real-time progress.
|
||||
//! ffmpeg concat-demuxer worker with honest, real-time progress, plus a
|
||||
//! history record for every job.
|
||||
//!
|
||||
//! Mirrors the proven logic from the Python original: stream-copy (`-c copy`),
|
||||
//! `+faststart`, and a progress bar that is capped at 99 % until ffmpeg emits
|
||||
//! `progress=end`, at which point it snaps to 100 % and shows "Finalising…".
|
||||
//! Stream copy only (`-c copy`), so a merge is purely I/O bound: on local NVMe
|
||||
//! it runs at ~95 % of a plain file copy. `+faststart` is opt-in because it
|
||||
//! makes ffmpeg re-read and re-write the entire output after the copy - measured
|
||||
//! at 17-56 % of a local run and 60 % of a NAS run - and only helps web
|
||||
//! streaming, not local playback.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::tools::{fmt_dur, hide_window, probe_duration};
|
||||
use crate::history::{self, Entry, InputFile};
|
||||
use crate::tools::{fmt_dur, hide_window, probe_many};
|
||||
use crate::AppState;
|
||||
|
||||
/// Error string the UI recognises as "stopped by the user".
|
||||
pub const CANCELLED: &str = "__cancelled__";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConcatRequest {
|
||||
pub files: Vec<String>,
|
||||
pub output: String,
|
||||
/// Durations the UI already probed, in `files` order. Missing or zero
|
||||
/// entries are probed here; complete ones skip ffprobe entirely.
|
||||
#[serde(default)]
|
||||
pub durations: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub faststart: bool,
|
||||
#[serde(default)]
|
||||
pub delete_originals: bool,
|
||||
#[serde(default)]
|
||||
pub renamed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ConcatProgress {
|
||||
pub pct: f64,
|
||||
@@ -32,68 +56,208 @@ pub struct ConcatResult {
|
||||
pub output: String,
|
||||
pub size_mb: f64,
|
||||
pub elapsed: f64,
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, p: ConcatProgress) {
|
||||
let _ = app.emit("concat-progress", p);
|
||||
}
|
||||
|
||||
/// Build the ffmpeg concat list file (UTF-8, forward slashes, escaped quotes).
|
||||
fn write_list(files: &[String], out_dir: &Path) -> Result<std::path::PathBuf, String> {
|
||||
let list_path = out_dir.join(format!(".stream_studio_{}.txt", std::process::id()));
|
||||
fn unix_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A path the way the concat demuxer wants it: forward slashes, single quotes
|
||||
/// escaped, and the Windows extended-length prefixes canonicalize() adds
|
||||
/// stripped. UNC paths come back as `\\?\UNC\server\share` -> `//?/UNC/server/share`,
|
||||
/// which must collapse to `//server/share` (a real network path), not the
|
||||
/// bare `UNC/server/share` a blanket strip would leave behind.
|
||||
fn ffconcat_path(abs: &str) -> String {
|
||||
abs.replace('\\', "/")
|
||||
.replace('\'', r"'\''")
|
||||
.replace("//?/UNC/", "//")
|
||||
.replace("//?/", "")
|
||||
}
|
||||
|
||||
/// Where the concat list of a job run by process `pid` lives.
|
||||
fn list_path(out_dir: &Path, pid: u32) -> PathBuf {
|
||||
out_dir.join(format!(".video_concat_{pid}.txt"))
|
||||
}
|
||||
|
||||
/// A crash skips the list-file cleanup, leaving it hidden next to the output.
|
||||
/// The history id is `<started>-<pid>`, which is enough to find exactly that
|
||||
/// file and nothing else.
|
||||
pub fn remove_stale_list(entry: &Entry) {
|
||||
let pid = entry.id.rsplit('-').next().and_then(|p| p.parse::<u32>().ok());
|
||||
if let (Some(pid), Some(dir)) = (pid, Path::new(&entry.output).parent()) {
|
||||
let _ = std::fs::remove_file(list_path(dir, pid));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the ffmpeg concat list file next to the output.
|
||||
fn write_list(files: &[String], out_dir: &Path) -> Result<PathBuf, String> {
|
||||
let list_path = list_path(out_dir, std::process::id());
|
||||
let mut body = String::new();
|
||||
for f in files {
|
||||
let abs = std::fs::canonicalize(f).unwrap_or_else(|_| Path::new(f).to_path_buf());
|
||||
let s = abs
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.replace("'", r"'\''")
|
||||
// strip the Windows extended-length prefixes canonicalize() adds.
|
||||
// UNC paths come back as \\?\UNC\server\share -> //?/UNC/server/share,
|
||||
// which must collapse to //server/share (a real network path), not the
|
||||
// bare "UNC/server/share" that a blanket strip would leave behind.
|
||||
.replace("//?/UNC/", "//")
|
||||
.replace("//?/", "");
|
||||
body.push_str(&format!("file '{s}'\n"));
|
||||
body.push_str(&format!("file '{}'\n", ffconcat_path(&abs.to_string_lossy())));
|
||||
}
|
||||
std::fs::write(&list_path, body).map_err(|e| format!("Could not write list file: {e}"))?;
|
||||
Ok(list_path)
|
||||
}
|
||||
|
||||
/// Use the durations the UI already has; probe only the ones it doesn't.
|
||||
fn resolve_durations(
|
||||
files: &[String],
|
||||
given: &[f64],
|
||||
probe: impl Fn(&[String]) -> Vec<f64>,
|
||||
) -> Vec<f64> {
|
||||
let missing: Vec<usize> =
|
||||
(0..files.len()).filter(|&i| given.get(i).is_none_or(|d| *d <= 0.0)).collect();
|
||||
let mut out: Vec<f64> = (0..files.len()).map(|i| given.get(i).copied().unwrap_or(0.0)).collect();
|
||||
if !missing.is_empty() {
|
||||
let paths: Vec<String> = missing.iter().map(|&i| files[i].clone()).collect();
|
||||
for (&i, d) in missing.iter().zip(probe(&paths)) {
|
||||
out[i] = d;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Delete the originals after a successful merge - but never the output
|
||||
/// itself, whatever path spelling it arrived under.
|
||||
fn delete_originals(files: &[String], output: &str) -> usize {
|
||||
let out = std::fs::canonicalize(output).ok();
|
||||
files
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
let is_output = out.is_some() && std::fs::canonicalize(f).ok() == out;
|
||||
!is_output && std::fs::remove_file(f).is_ok()
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
ffmpeg: &str,
|
||||
ffprobe: &str,
|
||||
files: Vec<String>,
|
||||
output: String,
|
||||
req: ConcatRequest,
|
||||
) -> Result<ConcatResult, String> {
|
||||
state.cancel.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let out_path = Path::new(&output);
|
||||
let out_dir = out_path.parent().ok_or("Invalid output path")?.to_path_buf();
|
||||
state.cancel.store(false, Ordering::SeqCst);
|
||||
let out_dir = Path::new(&req.output).parent().ok_or("Invalid output path")?.to_path_buf();
|
||||
|
||||
if req.durations.len() != req.files.len() || req.durations.iter().any(|d| *d <= 0.0) {
|
||||
emit(app, ConcatProgress { pct: 0.0, status: "Scanning durations…".into(), elapsed: 0.0, eta: -1.0, speed: 0.0 });
|
||||
let total_secs: f64 = files.iter().map(|f| probe_duration(ffprobe, f)).sum();
|
||||
}
|
||||
let durations = resolve_durations(&req.files, &req.durations, |p| probe_many(ffprobe, p));
|
||||
let total_secs: f64 = durations.iter().sum();
|
||||
|
||||
let list_path = write_list(&files, &out_dir)?;
|
||||
let started = unix_ms();
|
||||
let mut entry = Entry {
|
||||
id: format!("{started}-{}", std::process::id()),
|
||||
started,
|
||||
status: "interrupted".into(),
|
||||
error: None,
|
||||
inputs: req
|
||||
.files
|
||||
.iter()
|
||||
.zip(&durations)
|
||||
.map(|(p, d)| InputFile {
|
||||
path: p.clone(),
|
||||
size: std::fs::metadata(p).map(|m| m.len()).unwrap_or(0),
|
||||
duration: *d,
|
||||
})
|
||||
.collect(),
|
||||
output: req.output.clone(),
|
||||
output_size: 0,
|
||||
faststart: req.faststart,
|
||||
elapsed: 0.0,
|
||||
mb_per_s: 0.0,
|
||||
deleted_originals: 0,
|
||||
renamed: req.renamed,
|
||||
};
|
||||
if let Some(d) = &state.data_dir {
|
||||
history::begin(d, &entry);
|
||||
}
|
||||
|
||||
let t0 = Instant::now();
|
||||
let outcome = execute(app, state, ffmpeg, &req, &out_dir, total_secs, t0);
|
||||
entry.elapsed = t0.elapsed().as_secs_f64();
|
||||
|
||||
let result = match outcome {
|
||||
Ok(()) => {
|
||||
entry.status = "done".into();
|
||||
entry.output_size = std::fs::metadata(&req.output).map(|m| m.len()).unwrap_or(0);
|
||||
let size_mb = entry.output_size as f64 / 1_048_576.0;
|
||||
entry.mb_per_s = size_mb / entry.elapsed.max(1e-9);
|
||||
if req.delete_originals && entry.output_size > 0 {
|
||||
entry.deleted_originals = delete_originals(&req.files, &req.output);
|
||||
}
|
||||
Ok(ConcatResult {
|
||||
output: req.output.clone(),
|
||||
size_mb,
|
||||
elapsed: entry.elapsed,
|
||||
deleted: entry.deleted_originals,
|
||||
})
|
||||
}
|
||||
Err(e) if e == CANCELLED => {
|
||||
entry.status = "cancelled".into();
|
||||
Err(e)
|
||||
}
|
||||
Err(e) => {
|
||||
entry.status = "failed".into();
|
||||
entry.set_error(&e);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(d) = &state.data_dir {
|
||||
history::finish(d, &entry);
|
||||
}
|
||||
if let Ok(r) = &result {
|
||||
emit(app, ConcatProgress {
|
||||
pct: 1.0,
|
||||
status: format!("Done — {} · {:.1} MB", fmt_dur(r.elapsed), r.size_mb),
|
||||
elapsed: r.elapsed,
|
||||
eta: 0.0,
|
||||
speed: 0.0,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Run ffmpeg and stream its progress to the UI.
|
||||
fn execute(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
ffmpeg: &str,
|
||||
req: &ConcatRequest,
|
||||
out_dir: &Path,
|
||||
total_secs: f64,
|
||||
t0: Instant,
|
||||
) -> Result<(), String> {
|
||||
let list_path = write_list(&req.files, out_dir)?;
|
||||
|
||||
let mut cmd = Command::new(ffmpeg);
|
||||
cmd.args([
|
||||
"-y",
|
||||
"-f", "concat", "-safe", "0",
|
||||
"-i", &list_path.to_string_lossy(),
|
||||
"-c:v", "copy", "-c:a", "copy",
|
||||
"-movflags", "+faststart",
|
||||
"-nostdin", "-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-progress", "pipe:1",
|
||||
&output,
|
||||
]);
|
||||
cmd.args(["-y", "-f", "concat", "-safe", "0", "-i"])
|
||||
.arg(&list_path)
|
||||
.args(["-c:v", "copy", "-c:a", "copy"]);
|
||||
if req.faststart {
|
||||
cmd.args(["-movflags", "+faststart"]);
|
||||
}
|
||||
cmd.args(["-nostdin", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1"])
|
||||
.arg(&req.output);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
hide_window(&mut cmd);
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| format!("Failed to launch ffmpeg: {e}"))?;
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&list_path);
|
||||
return Err(format!("Failed to launch ffmpeg: {e}"));
|
||||
}
|
||||
};
|
||||
let stdout = child.stdout.take().ok_or("No ffmpeg stdout")?;
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
@@ -101,73 +265,79 @@ pub fn run(
|
||||
*state.proc.lock().unwrap() = Some(child);
|
||||
|
||||
emit(app, ConcatProgress { pct: 0.0, status: "Concatenating…".into(), elapsed: 0.0, eta: -1.0, speed: 0.0 });
|
||||
let t0 = Instant::now();
|
||||
let mut cur_us: i64 = 0;
|
||||
|
||||
// When ffmpeg finishes the copy it still has to relocate the moov atom for
|
||||
// +faststart (a full-file rewrite that can take a while on large outputs).
|
||||
// We hold the bar at 99 % and run this heartbeat so "Finalising…" stays
|
||||
// visibly alive instead of looking frozen, and 100 % means *actually done*.
|
||||
// With +faststart, ffmpeg goes silent while it rewrites the file to move
|
||||
// the index to the front - and current builds only print progress=end
|
||||
// *after* that rewrite, so waiting for it never works. Nor does "last
|
||||
// report was past 95 %": reports come every ~0.5 s, so the last one before
|
||||
// the silence can land anywhere from ~93 % up. Instead the watchdog
|
||||
// extrapolates from the last position and speed: once ffmpeg must have
|
||||
// passed the end and it's still quiet, it is finalising.
|
||||
let fin = Arc::new(AtomicBool::new(false));
|
||||
let mut heartbeat: Option<thread::JoinHandle<()>> = None;
|
||||
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
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 line = line.trim();
|
||||
|
||||
let mut cur_us: i64 = 0;
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let line = line.trim();
|
||||
if let Some(v) = line.strip_prefix("out_time_us=").or_else(|| line.strip_prefix("out_time_ms=")) {
|
||||
if let Ok(n) = v.parse::<i64>() {
|
||||
cur_us = n; // ffmpeg reports microseconds under both keys
|
||||
}
|
||||
} else if line == "progress=end" {
|
||||
// Copy is done; the +faststart rewrite happens now, during which
|
||||
// ffmpeg emits nothing. Spawn a heartbeat to keep the UI honest.
|
||||
if heartbeat.is_none() {
|
||||
let appc = app.clone();
|
||||
let finc = fin.clone();
|
||||
heartbeat = Some(thread::spawn(move || {
|
||||
while !finc.load(Ordering::SeqCst) {
|
||||
let elapsed = t0.elapsed().as_secs_f64();
|
||||
emit(&appc, ConcatProgress {
|
||||
pct: 0.99,
|
||||
status: "Finalising output file…".into(),
|
||||
elapsed,
|
||||
eta: -1.0,
|
||||
speed: 0.0,
|
||||
});
|
||||
thread::sleep(Duration::from_millis(700));
|
||||
}
|
||||
}));
|
||||
}
|
||||
} else if line.starts_with("progress=continue") {
|
||||
if total_secs > 0.0 && cur_us > 0 {
|
||||
} else if line == "progress=continue" && total_secs > 0.0 && cur_us > 0 {
|
||||
// progress=end only arrives after any faststart rewrite, right
|
||||
// before exit - reporting "Concatenating…" then would be wrong
|
||||
let played = cur_us as f64 / 1_000_000.0;
|
||||
let pct = (played / total_secs).clamp(0.0, 0.99);
|
||||
let elapsed = t0.elapsed().as_secs_f64();
|
||||
let speed = played / elapsed.max(1e-9);
|
||||
let eta = (total_secs - played) / speed.max(1e-9);
|
||||
last_played.store(played.to_bits(), Ordering::SeqCst);
|
||||
last_speed.store(speed.to_bits(), Ordering::SeqCst);
|
||||
if let Ok(mut t) = last_tick.lock() {
|
||||
*t = Instant::now();
|
||||
}
|
||||
emit(app, ConcatProgress {
|
||||
pct,
|
||||
status: "Concatenating…".into(),
|
||||
elapsed,
|
||||
eta,
|
||||
eta: (total_secs - played) / speed.max(1e-9),
|
||||
speed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpeg's stdout has closed — the +faststart rewrite is done too. Stop the
|
||||
// finalisation heartbeat before we reclaim the child and report 100 %.
|
||||
fin.store(true, Ordering::SeqCst);
|
||||
if let Some(h) = heartbeat.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
let _ = watchdog.join();
|
||||
|
||||
// Reclaim the child and finish.
|
||||
let mut child = state.proc.lock().unwrap().take().ok_or("Process vanished")?;
|
||||
let status = child.wait().map_err(|e| format!("ffmpeg wait failed: {e}"))?;
|
||||
let mut stderr_text = String::new();
|
||||
@@ -175,29 +345,118 @@ pub fn run(
|
||||
use std::io::Read;
|
||||
let _ = se.read_to_string(&mut stderr_text);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&list_path);
|
||||
|
||||
if state.cancel.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
let _ = std::fs::remove_file(&output);
|
||||
return Err("__cancelled__".into());
|
||||
if state.cancel.load(Ordering::SeqCst) {
|
||||
let _ = std::fs::remove_file(&req.output);
|
||||
return Err(CANCELLED.into());
|
||||
}
|
||||
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_file(&output);
|
||||
let _ = std::fs::remove_file(&req.output);
|
||||
let msg = stderr_text.trim();
|
||||
return Err(if msg.is_empty() { "Unknown ffmpeg error.".into() } else { msg.to_string() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let elapsed = t0.elapsed().as_secs_f64();
|
||||
let size_mb = std::fs::metadata(&output).map(|m| m.len() as f64 / 1_048_576.0).unwrap_or(0.0);
|
||||
emit(app, ConcatProgress {
|
||||
pct: 1.0,
|
||||
status: format!("Done — {} · {:.1} MB", fmt_dur(elapsed), size_mb),
|
||||
elapsed,
|
||||
eta: 0.0,
|
||||
speed: 0.0,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn ffconcat_path_normalises_plain_windows_paths() {
|
||||
assert_eq!(ffconcat_path(r"D:\rec\a.flv"), "D:/rec/a.flv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffconcat_path_strips_the_extended_length_prefix() {
|
||||
assert_eq!(ffconcat_path(r"\\?\D:\rec\a.flv"), "D:/rec/a.flv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffconcat_path_keeps_unc_paths_as_network_paths() {
|
||||
assert_eq!(ffconcat_path(r"\\?\UNC\TRUENAS\share\a.flv"), "//TRUENAS/share/a.flv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffconcat_path_escapes_single_quotes() {
|
||||
assert_eq!(ffconcat_path(r"D:\rec\it's live.flv"), r"D:/rec/it'\''s live.flv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_durations_skips_ffprobe_when_the_ui_has_them_all() {
|
||||
let files = vec!["a".to_string(), "b".to_string()];
|
||||
let calls = Cell::new(0);
|
||||
let got = resolve_durations(&files, &[60.0, 30.5], |p| {
|
||||
calls.set(calls.get() + 1);
|
||||
vec![0.0; p.len()]
|
||||
});
|
||||
assert_eq!(got, [60.0, 30.5]);
|
||||
assert_eq!(calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_durations_probes_only_the_gaps() {
|
||||
let files: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
|
||||
let got = resolve_durations(&files, &[60.0, 0.0, 45.0], |p| {
|
||||
assert_eq!(p, ["b", "d"]);
|
||||
vec![10.0, 20.0]
|
||||
});
|
||||
assert_eq!(got, [60.0, 10.0, 45.0, 20.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_stale_list_deletes_only_the_crashed_jobs_list() {
|
||||
let d = std::env::temp_dir().join(format!("vc_stale_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let stale = list_path(&d, 4242);
|
||||
let other = list_path(&d, 777); // a different job's list: leave it
|
||||
let video = d.join("keep.mp4");
|
||||
for p in [&stale, &other, &video] {
|
||||
std::fs::write(p, b"x").unwrap();
|
||||
}
|
||||
let entry = Entry {
|
||||
id: "1758780000000-4242".into(),
|
||||
started: 0,
|
||||
status: "interrupted".into(),
|
||||
error: None,
|
||||
inputs: vec![],
|
||||
output: d.join("out.mp4").to_string_lossy().into_owned(),
|
||||
output_size: 0,
|
||||
faststart: false,
|
||||
elapsed: 0.0,
|
||||
mb_per_s: 0.0,
|
||||
deleted_originals: 0,
|
||||
renamed: false,
|
||||
};
|
||||
remove_stale_list(&entry);
|
||||
assert!(!stale.exists());
|
||||
assert!(other.exists() && video.exists());
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_originals_never_deletes_the_output() {
|
||||
let d = std::env::temp_dir().join(format!("vc_delete_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let a = d.join("a.flv");
|
||||
let b = d.join("b.flv");
|
||||
let out = d.join("out.mp4");
|
||||
for p in [&a, &b, &out] {
|
||||
std::fs::write(p, b"x").unwrap();
|
||||
}
|
||||
let files: Vec<String> = [&a, &b, &out].iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
// the output listed again under a different spelling must survive too
|
||||
let out_alias = d.join(".").join("out.mp4").to_string_lossy().into_owned();
|
||||
let mut with_alias = files.clone();
|
||||
with_alias.push(out_alias);
|
||||
|
||||
Ok(ConcatResult { output, size_mb, elapsed })
|
||||
assert_eq!(delete_originals(&with_alias, &out.to_string_lossy()), 2);
|
||||
assert!(!a.exists() && !b.exists());
|
||||
assert!(out.exists(), "output was deleted");
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Concat history, kept in `data/history.jsonl` inside the app folder.
|
||||
//!
|
||||
//! One JSON object per line, appended when a job ends. While a job runs, its
|
||||
//! entry also sits in `data/running.json`; if the app dies mid-merge that file
|
||||
//! survives, and the next launch files it as `interrupted`. Append-only lines
|
||||
//! mean a crash can at worst lose the job in flight, never earlier history,
|
||||
//! and a corrupt line is skipped instead of taking the whole file down.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const HISTORY: &str = "history.jsonl";
|
||||
const RUNNING: &str = "running.json";
|
||||
const MAX_ERROR: usize = 2000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InputFile {
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub id: String,
|
||||
/// Unix epoch, milliseconds.
|
||||
pub started: u64,
|
||||
/// `done` | `failed` | `cancelled` | `interrupted`
|
||||
pub status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub inputs: Vec<InputFile>,
|
||||
pub output: String,
|
||||
#[serde(default)]
|
||||
pub output_size: u64,
|
||||
#[serde(default)]
|
||||
pub faststart: bool,
|
||||
/// Seconds ffmpeg ran for.
|
||||
#[serde(default)]
|
||||
pub elapsed: f64,
|
||||
/// Output bytes / elapsed, in MB/s. 0 unless the job finished.
|
||||
#[serde(default)]
|
||||
pub mb_per_s: f64,
|
||||
/// How many originals were deleted after the merge.
|
||||
#[serde(default)]
|
||||
pub deleted_originals: usize,
|
||||
/// Whether the BiliBili rename was applied to the output name.
|
||||
#[serde(default)]
|
||||
pub renamed: bool,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn set_error(&mut self, msg: &str) {
|
||||
let msg = msg.trim();
|
||||
self.error = Some(if msg.chars().count() > MAX_ERROR {
|
||||
format!("{}…", msg.chars().take(MAX_ERROR).collect::<String>())
|
||||
} else {
|
||||
msg.to_string()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry as the UI sees it: what was recorded plus what is true right now.
|
||||
#[derive(Serialize)]
|
||||
pub struct Item {
|
||||
#[serde(flatten)]
|
||||
pub entry: Entry,
|
||||
pub output_exists: bool,
|
||||
/// Per input, in order: is the file still on disk?
|
||||
pub inputs_exist: Vec<bool>,
|
||||
}
|
||||
|
||||
fn history_path(dir: &Path) -> PathBuf {
|
||||
dir.join(HISTORY)
|
||||
}
|
||||
|
||||
fn running_path(dir: &Path) -> PathBuf {
|
||||
dir.join(RUNNING)
|
||||
}
|
||||
|
||||
/// Parse history text, skipping blank or corrupt lines. Newest first.
|
||||
pub fn parse(text: &str) -> Vec<Entry> {
|
||||
let mut out: Vec<Entry> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
|
||||
pub fn append(dir: &Path, entry: &Entry) -> std::io::Result<()> {
|
||||
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
|
||||
let mut f = OpenOptions::new().create(true).append(true).open(history_path(dir))?;
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Vec<Entry> {
|
||||
std::fs::read_to_string(history_path(dir)).map(|t| parse(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Mark a job as in flight. Its status is already `interrupted`, so if the
|
||||
/// app never reaches `finish`, `recover` can file it without editing.
|
||||
pub fn begin(dir: &Path, entry: &Entry) {
|
||||
if let Ok(json) = serde_json::to_string(entry) {
|
||||
let _ = std::fs::write(running_path(dir), json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the finished job and clear the in-flight marker.
|
||||
pub fn finish(dir: &Path, entry: &Entry) {
|
||||
let _ = append(dir, entry);
|
||||
let _ = std::fs::remove_file(running_path(dir));
|
||||
}
|
||||
|
||||
/// On startup: file a job left over from a crash as `interrupted`.
|
||||
pub fn recover(dir: &Path) -> Option<Entry> {
|
||||
let path = running_path(dir);
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut entry: Entry = serde_json::from_str(&text).ok()?;
|
||||
entry.status = "interrupted".into();
|
||||
append(dir, &entry).ok()?;
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
pub fn clear(dir: &Path) -> bool {
|
||||
match std::fs::remove_file(history_path(dir)) {
|
||||
Ok(()) => true,
|
||||
Err(e) => e.kind() == std::io::ErrorKind::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn items(dir: &Path) -> Vec<Item> {
|
||||
load(dir)
|
||||
.into_iter()
|
||||
.map(|entry| Item {
|
||||
output_exists: Path::new(&entry.output).is_file(),
|
||||
inputs_exist: entry.inputs.iter().map(|i| Path::new(&i.path).is_file()).collect(),
|
||||
entry,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc_history_{name}_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn entry(id: &str) -> Entry {
|
||||
Entry {
|
||||
id: id.into(),
|
||||
started: 1_758_780_000_000,
|
||||
status: "interrupted".into(),
|
||||
error: None,
|
||||
inputs: vec![InputFile { path: r"D:\rec\a.flv".into(), size: 10, duration: 60.0 }],
|
||||
output: r"D:\rec\a_concat.mp4".into(),
|
||||
output_size: 0,
|
||||
faststart: false,
|
||||
elapsed: 0.0,
|
||||
mb_per_s: 0.0,
|
||||
deleted_originals: 0,
|
||||
renamed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_through_a_line() {
|
||||
let mut e = entry("1");
|
||||
e.status = "done".into();
|
||||
e.output_size = 1_705_000_000;
|
||||
e.mb_per_s = 1164.2;
|
||||
let back = parse(&serde_json::to_string(&e).unwrap());
|
||||
assert_eq!(back, vec![e]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skips_junk_and_returns_newest_first() {
|
||||
let a = serde_json::to_string(&entry("a")).unwrap();
|
||||
let b = serde_json::to_string(&entry("b")).unwrap();
|
||||
let text = format!("{a}\n\nnot json at all\n{{\"half\":\n{b}\n");
|
||||
let ids: Vec<_> = parse(&text).into_iter().map(|e| e.id).collect();
|
||||
assert_eq!(ids, ["b", "a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_lines_missing_new_fields_still_load() {
|
||||
let line = r#"{"id":"x","started":1,"status":"done","inputs":[],"output":"o.mp4"}"#;
|
||||
let e = &parse(line)[0];
|
||||
assert_eq!(e.id, "x");
|
||||
assert!(!e.faststart);
|
||||
assert_eq!(e.deleted_originals, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_capped() {
|
||||
let mut e = entry("1");
|
||||
e.set_error(&"x".repeat(5000));
|
||||
assert_eq!(e.error.as_ref().unwrap().chars().count(), MAX_ERROR + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_appends_and_clears_the_running_marker() {
|
||||
let d = tmp("finish");
|
||||
let mut e = entry("1");
|
||||
begin(&d, &e);
|
||||
assert!(running_path(&d).exists());
|
||||
e.status = "done".into();
|
||||
finish(&d, &e);
|
||||
assert!(!running_path(&d).exists());
|
||||
assert_eq!(load(&d)[0].status, "done");
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crashed_job_is_recovered_as_interrupted() {
|
||||
let d = tmp("recover");
|
||||
let mut e = entry("crashed");
|
||||
e.status = "done".into(); // whatever was written, recovery must not trust it
|
||||
begin(&d, &e);
|
||||
let got = recover(&d).expect("should recover");
|
||||
assert_eq!(got.status, "interrupted");
|
||||
assert!(!running_path(&d).exists());
|
||||
assert_eq!(load(&d).len(), 1);
|
||||
assert!(recover(&d).is_none(), "recovering twice must not duplicate");
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_history_and_tolerates_no_file() {
|
||||
let d = tmp("clear");
|
||||
append(&d, &entry("1")).unwrap();
|
||||
assert!(clear(&d));
|
||||
assert!(load(&d).is_empty());
|
||||
assert!(clear(&d), "clearing an empty history is not an error");
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,32 @@
|
||||
//! Video Concat — Tauri backend.
|
||||
//! A focused tool: stream-copy concat of split recordings via ffmpeg, with a
|
||||
//! small command surface to the web UI.
|
||||
//! small command surface to the web UI. Portable: everything it writes stays
|
||||
//! inside the app folder (see `portable`).
|
||||
|
||||
mod concat;
|
||||
mod history;
|
||||
mod portable;
|
||||
mod tools;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri::{AppHandle, Manager, State, WebviewWindowBuilder};
|
||||
|
||||
use concat::ConcatResult;
|
||||
use concat::{ConcatRequest, ConcatResult};
|
||||
|
||||
pub struct AppState {
|
||||
pub proc: Mutex<Option<Child>>,
|
||||
pub cancel: AtomicBool,
|
||||
pub ffmpeg: Option<String>,
|
||||
pub ffprobe: Option<String>,
|
||||
/// `<app>/data`, or `None` when the app folder isn't writable.
|
||||
pub data_dir: Option<PathBuf>,
|
||||
/// Whether the WebView profile (theme, preferences) lives in the app folder.
|
||||
pub webview_portable: AtomicBool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -29,14 +36,19 @@ impl AppState {
|
||||
cancel: AtomicBool::new(false),
|
||||
ffmpeg: tools::find_tool("ffmpeg"),
|
||||
ffprobe: tools::find_tool("ffprobe"),
|
||||
data_dir: portable::data_dir(),
|
||||
webview_portable: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ToolsInfo {
|
||||
struct AppInfo {
|
||||
ffmpeg: Option<String>,
|
||||
ffprobe: Option<String>,
|
||||
data_dir: Option<String>,
|
||||
/// True when history *and* the WebView profile are stored in the app folder.
|
||||
portable: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -48,31 +60,23 @@ struct DurationInfo {
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_tools(state: State<AppState>) -> ToolsInfo {
|
||||
ToolsInfo {
|
||||
fn app_info(state: State<AppState>) -> AppInfo {
|
||||
AppInfo {
|
||||
ffmpeg: state.ffmpeg.clone(),
|
||||
ffprobe: state.ffprobe.clone(),
|
||||
data_dir: state.data_dir.as_ref().map(|d| d.to_string_lossy().into_owned()),
|
||||
portable: state.data_dir.is_some() && state.webview_portable.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe several files in parallel; returns durations in seconds.
|
||||
#[tauri::command]
|
||||
fn probe_durations(state: State<AppState>, paths: Vec<String>) -> Vec<DurationInfo> {
|
||||
let ffprobe = match &state.ffprobe {
|
||||
Some(p) => p.clone(),
|
||||
None => return paths.into_iter().map(|p| DurationInfo { path: p, duration: 0.0 }).collect(),
|
||||
let durations = match &state.ffprobe {
|
||||
Some(ff) => tools::probe_many(ff, &paths),
|
||||
None => vec![0.0; paths.len()],
|
||||
};
|
||||
let handles: Vec<_> = paths
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let ff = ffprobe.clone();
|
||||
thread::spawn(move || DurationInfo {
|
||||
duration: tools::probe_duration(&ff, &p),
|
||||
path: p,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect()
|
||||
paths.into_iter().zip(durations).map(|(path, duration)| DurationInfo { path, duration }).collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -80,21 +84,22 @@ fn is_video(path: String) -> bool {
|
||||
tools::is_video(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn path_exists(path: String) -> bool {
|
||||
std::path::Path::new(&path).exists()
|
||||
}
|
||||
|
||||
// Concat runs ffmpeg for a long time, so it MUST run off the main thread,
|
||||
// otherwise the native window stops pumping messages and Windows paints the
|
||||
// app "Not responding". `spawn_blocking` keeps the UI responsive while the
|
||||
// command still resolves with the final result.
|
||||
#[tauri::command]
|
||||
async fn start_concat(
|
||||
app: AppHandle,
|
||||
files: Vec<String>,
|
||||
output: String,
|
||||
) -> Result<ConcatResult, String> {
|
||||
async fn start_concat(app: AppHandle, req: ConcatRequest) -> Result<ConcatResult, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let ffmpeg = state.ffmpeg.clone().ok_or("ffmpeg not found.")?;
|
||||
let ffprobe = state.ffprobe.clone().unwrap_or_else(|| "ffprobe".into());
|
||||
concat::run(&app, &state, &ffmpeg, &ffprobe, files, output)
|
||||
concat::run(&app, &state, &ffmpeg, &ffprobe, req)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("concat task failed: {e}"))?
|
||||
@@ -102,7 +107,7 @@ async fn start_concat(
|
||||
|
||||
#[tauri::command]
|
||||
fn cancel(state: State<AppState>) {
|
||||
state.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
state.cancel.store(true, Ordering::SeqCst);
|
||||
if let Some(child) = state.proc.lock().unwrap().as_mut() {
|
||||
// Kill the whole tree, not just the direct child.
|
||||
tools::kill_process_tree(child.id());
|
||||
@@ -111,8 +116,13 @@ fn cancel(state: State<AppState>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_files(paths: Vec<String>) -> usize {
|
||||
paths.iter().filter(|p| std::fs::remove_file(p).is_ok()).count()
|
||||
fn history_list(state: State<AppState>) -> Vec<history::Item> {
|
||||
state.data_dir.as_deref().map(history::items).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn history_clear(state: State<AppState>) -> bool {
|
||||
state.data_dir.as_deref().is_some_and(history::clear)
|
||||
}
|
||||
|
||||
/// Convert a BiliBili Live recording stem to a readable name, or null.
|
||||
@@ -143,24 +153,104 @@ fn bililive_rename(stem: String) -> Option<String> {
|
||||
Some(format!("{y}年{m}月{d}日【直播回放】{title}"))
|
||||
}
|
||||
|
||||
/// Create the main window ourselves (it is `create: false` in tauri.conf.json)
|
||||
/// so its WebView2 profile lands in `<app>/data/webview` instead of
|
||||
/// `%LOCALAPPDATA%`. If that fails - read-only folder, or a location WebView2
|
||||
/// refuses - fall back to the default profile so the app still opens.
|
||||
fn create_main_window(app: &tauri::App, data_dir: Option<&PathBuf>) -> tauri::Result<bool> {
|
||||
let cfg = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.label == "main")
|
||||
.cloned()
|
||||
.expect("tauri.conf.json must define the main window");
|
||||
|
||||
if let Some(dir) = data_dir {
|
||||
let built = WebviewWindowBuilder::from_config(app.handle(), &cfg)?
|
||||
.data_directory(dir.join("webview"))
|
||||
.build();
|
||||
if built.is_ok() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
WebviewWindowBuilder::from_config(app.handle(), &cfg)?.build()?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
app.manage(AppState::new());
|
||||
let state = AppState::new();
|
||||
if let Some(entry) = state.data_dir.as_deref().and_then(history::recover) {
|
||||
concat::remove_stale_list(&entry);
|
||||
}
|
||||
let portable_web = create_main_window(app, state.data_dir.as_ref())?;
|
||||
state.webview_portable.store(portable_web, Ordering::SeqCst);
|
||||
app.manage(state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_tools,
|
||||
app_info,
|
||||
probe_durations,
|
||||
is_video,
|
||||
path_exists,
|
||||
start_concat,
|
||||
cancel,
|
||||
delete_files,
|
||||
history_list,
|
||||
history_clear,
|
||||
bililive_rename,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bililive_rename as rename;
|
||||
|
||||
#[test]
|
||||
fn renames_a_bililive_stem() {
|
||||
assert_eq!(
|
||||
rename("[2026-03-14 22-46-37][纳豆nado][今晚九点来捏!]".into()).as_deref(),
|
||||
Some("2026年03月14日【直播回放】今晚九点来捏!")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_the_concat_suffix_and_padding() {
|
||||
assert_eq!(
|
||||
rename(" [2026-03-14 22-46-37][up][title]_concat".into()).as_deref(),
|
||||
Some("2026年03月14日【直播回放】title")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_may_contain_brackets_of_its_own() {
|
||||
assert_eq!(
|
||||
rename("[2026-07-08 21-00-00][up][part [1] 【随机专场】]".into()).as_deref(),
|
||||
Some("2026年07月08日【直播回放】part [1] 【随机专场】")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_anything_that_is_not_the_bililive_shape() {
|
||||
for s in [
|
||||
"",
|
||||
"plain_name",
|
||||
"[2026-03-14][up][title]", // no time part
|
||||
"[26-03-14 22-46-37][up][title]", // 2-digit year
|
||||
"[2026-3-14 22-46-37][up][title]", // 1-digit month
|
||||
"[2026-ab-14 22-46-37][up][title]", // non-digit
|
||||
"[2026-03-14 22-46-37][up][]", // empty title
|
||||
"[2026-03-14 22-46-37][up]", // no title
|
||||
"[2026-03-14 22-46-37][up]title", // title not bracketed
|
||||
] {
|
||||
assert_eq!(rename(s.into()), None, "should reject {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Portable layout: everything the app writes lives next to the exe, so the
|
||||
//! whole folder can be copied to another machine and keeps working as-is.
|
||||
//!
|
||||
//! ```text
|
||||
//! VideoConcat\
|
||||
//! Video Concat.exe, ffmpeg.exe, ffprobe.exe
|
||||
//! data\history.jsonl concat history, one JSON object per line
|
||||
//! data\running.json the job in flight; left behind = interrupted
|
||||
//! data\webview\ WebView2 profile (theme and UI preferences)
|
||||
//! ```
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directory the executable lives in.
|
||||
pub fn app_dir() -> Option<PathBuf> {
|
||||
std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// `<app>/data`, created on demand. `None` when the app folder is read-only
|
||||
/// (e.g. Program Files or a locked-down USB stick) - callers then run without
|
||||
/// history rather than silently writing somewhere outside the folder.
|
||||
pub fn data_dir() -> Option<PathBuf> {
|
||||
let dir = app_dir()?.join("data");
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
writable(&dir).then_some(dir)
|
||||
}
|
||||
|
||||
/// `create_dir_all` succeeding on an existing folder says nothing about write
|
||||
/// access, so actually try writing.
|
||||
pub fn writable(dir: &Path) -> bool {
|
||||
let probe = dir.join(format!(".write_test_{}", std::process::id()));
|
||||
let ok = std::fs::write(&probe, b"").is_ok();
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
ok
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writable_detects_a_usable_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("vc_portable_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(writable(&dir));
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "probe file left behind");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writable_rejects_a_missing_folder() {
|
||||
let missing = std::env::temp_dir().join(format!("vc_missing_{}", std::process::id())).join("nested");
|
||||
assert!(!writable(&missing));
|
||||
}
|
||||
}
|
||||
@@ -40,15 +40,10 @@ pub fn kill_process_tree(pid: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory the executable lives in (resources dir when bundled).
|
||||
fn app_dir() -> Option<PathBuf> {
|
||||
std::env::current_exe().ok()?.parent().map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Locate ffmpeg / ffprobe: next to the exe, then on PATH.
|
||||
pub fn find_tool(name: &str) -> Option<String> {
|
||||
let exe = format!("{name}.exe");
|
||||
if let Some(base) = app_dir() {
|
||||
if let Some(base) = crate::portable::app_dir() {
|
||||
for sub in ["", "bin", "resources", "_internal", ".."] {
|
||||
let cand = base.join(sub).join(&exe);
|
||||
if cand.exists() {
|
||||
@@ -98,6 +93,26 @@ pub fn probe_duration(ffprobe: &str, path: &str) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe many files at once, in order. Bounded to a few ffprobe processes at a
|
||||
/// time - spawning one per file is fine for 6 segments, not for 200.
|
||||
pub fn probe_many(ffprobe: &str, paths: &[String]) -> Vec<f64> {
|
||||
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8);
|
||||
let mut out = vec![0.0; paths.len()];
|
||||
for (chunk_idx, chunk) in paths.chunks(workers).enumerate() {
|
||||
let handles: Vec<_> = chunk
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (ff, p) = (ffprobe.to_string(), p.clone());
|
||||
std::thread::spawn(move || probe_duration(&ff, &p))
|
||||
})
|
||||
.collect();
|
||||
for (i, h) in handles.into_iter().enumerate() {
|
||||
out[chunk_idx * workers + i] = h.join().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Format seconds as H:MM:SS or M:SS.
|
||||
pub fn fmt_dur(seconds: f64) -> String {
|
||||
if seconds < 0.0 {
|
||||
@@ -124,3 +139,36 @@ pub fn is_video(path: &str) -> bool {
|
||||
.map(|e| EXTS.contains(&e.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fmt_dur_formats_minutes_and_hours() {
|
||||
assert_eq!(fmt_dur(0.0), "0:00");
|
||||
assert_eq!(fmt_dur(59.6), "1:00");
|
||||
assert_eq!(fmt_dur(3599.0), "59:59");
|
||||
assert_eq!(fmt_dur(3661.0), "1:01:01");
|
||||
assert_eq!(fmt_dur(-1.0), "…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_video_is_case_insensitive_and_strict() {
|
||||
assert!(is_video(r"D:\rec\a.FLV"));
|
||||
assert!(is_video("x.m2ts"));
|
||||
assert!(!is_video("notes.txt"));
|
||||
assert!(!is_video("no_extension"));
|
||||
assert!(!is_video("video.mp4.part"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_many_keeps_order_and_survives_missing_files() {
|
||||
// no ffprobe needed: a bogus binary makes every probe fail -> 0.0,
|
||||
// which is exactly the contract callers rely on
|
||||
let paths: Vec<String> = (0..20).map(|i| format!("missing_{i}.flv")).collect();
|
||||
let got = probe_many("definitely-not-ffprobe", &paths);
|
||||
assert_eq!(got.len(), 20);
|
||||
assert!(got.iter().all(|&d| d == 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Video Concat",
|
||||
"version": "5.0.0",
|
||||
"version": "5.1.0",
|
||||
"identifier": "com.youfu.video-concat",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,6 +13,8 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"create": false,
|
||||
"title": "Video Concat",
|
||||
"width": 960,
|
||||
"height": 820,
|
||||
|
||||
@@ -3,21 +3,56 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { ask, open } from "@tauri-apps/plugin-dialog";
|
||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||
|
||||
export interface Tools { ffmpeg: string | null; ffprobe: string | null; }
|
||||
export interface AppInfo {
|
||||
ffmpeg: string | null;
|
||||
ffprobe: string | null;
|
||||
data_dir: string | null;
|
||||
/** History and the WebView profile both live inside the app folder. */
|
||||
portable: boolean;
|
||||
}
|
||||
export interface DurationInfo { path: string; duration: number; }
|
||||
export interface ConcatResult { output: string; size_mb: number; elapsed: number; }
|
||||
export interface ConcatRequest {
|
||||
files: string[];
|
||||
output: string;
|
||||
durations: number[];
|
||||
faststart: boolean;
|
||||
deleteOriginals: boolean;
|
||||
renamed: boolean;
|
||||
}
|
||||
export interface ConcatResult { output: string; size_mb: number; elapsed: number; deleted: number; }
|
||||
export interface ConcatProgress { pct: number; status: string; elapsed: number; eta: number; speed: number; }
|
||||
|
||||
export const getTools = () => invoke<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 isVideo = (path: string) => invoke<boolean>("is_video", { path });
|
||||
export const startConcat = (files: string[], output: string) =>
|
||||
invoke<ConcatResult>("start_concat", { files, output });
|
||||
export const pathExists = (path: string) => invoke<boolean>("path_exists", { path });
|
||||
export const startConcat = (req: ConcatRequest) => invoke<ConcatResult>("start_concat", { req });
|
||||
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 onConcatProgress = (cb: (p: ConcatProgress) => void): Promise<UnlistenFn> =>
|
||||
@@ -42,4 +77,7 @@ export async function pickFolder(): Promise<string | 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);
|
||||
|
||||
@@ -49,6 +49,11 @@ export class FileList {
|
||||
return this.items.filter((i) => i.checked).map((i) => i.path);
|
||||
}
|
||||
|
||||
/** Durations for checkedPaths(), 0 where still unknown (the backend probes those). */
|
||||
checkedDurations(): number[] {
|
||||
return this.items.filter((i) => i.checked).map((i) => Math.max(i.duration, 0));
|
||||
}
|
||||
|
||||
orderedPaths(): string[] {
|
||||
return this.items.map((i) => i.path);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,83 @@
|
||||
// Video Concat — app shell: title bar, theme, drag-drop.
|
||||
// Video Concat — app shell: title bar, tabs, theme, drag-drop.
|
||||
|
||||
import "./styles.css";
|
||||
import { h } from "./dom";
|
||||
import { toast } from "./ui";
|
||||
import { getTools, onFilesDropped } from "./api";
|
||||
import { appInfo, onFilesDropped, type AppInfo } from "./api";
|
||||
import { buildConcat } from "./views/concat";
|
||||
import { buildHistory } from "./views/history";
|
||||
|
||||
async function boot() {
|
||||
// ── Theme (system default, persisted toggle) ────────────────────────────
|
||||
const saved = localStorage.getItem("theme");
|
||||
// localStorage lives in the WebView profile, which the backend keeps in
|
||||
// <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;
|
||||
let theme = saved ?? (prefersDark ? "dark" : "light");
|
||||
const applyTheme = () => document.documentElement.setAttribute("data-theme", theme);
|
||||
applyTheme();
|
||||
|
||||
// ── Single view: concat ─────────────────────────────────────────────────
|
||||
const concat = buildConcat();
|
||||
let info: AppInfo | null = null;
|
||||
try { info = await appInfo(); }
|
||||
catch (e) { toast(`Backend not ready: ${e}`, "err"); }
|
||||
|
||||
// ── Views ───────────────────────────────────────────────────────────────
|
||||
const history = buildHistory({
|
||||
enabled: () => !!info?.data_dir,
|
||||
onMergeAgain: async (paths) => { show("merge"); await concat.load(paths); },
|
||||
});
|
||||
const concat = buildConcat({ onFinished: () => history.refresh() });
|
||||
|
||||
const tabs = {
|
||||
merge: h("button", { class: "tab active" }, "Merge") as HTMLButtonElement,
|
||||
history: h("button", { class: "tab" }, "History") as HTMLButtonElement,
|
||||
};
|
||||
function show(which: keyof typeof tabs) {
|
||||
concat.el.hidden = which !== "merge";
|
||||
history.el.hidden = which !== "history";
|
||||
tabs.merge.classList.toggle("active", which === "merge");
|
||||
tabs.history.classList.toggle("active", which === "history");
|
||||
if (which === "history") history.refresh();
|
||||
}
|
||||
tabs.merge.addEventListener("click", () => show("merge"));
|
||||
tabs.history.addEventListener("click", () => show("history"));
|
||||
|
||||
const themeBtn = h("button", { class: "icon-btn", title: "Toggle theme" },
|
||||
theme === "dark" ? "☀" : "☾") as HTMLButtonElement;
|
||||
themeBtn.addEventListener("click", () => {
|
||||
theme = theme === "dark" ? "light" : "dark";
|
||||
localStorage.setItem("theme", theme);
|
||||
try { localStorage.setItem("theme", theme); } catch { /* not persisted */ }
|
||||
applyTheme();
|
||||
themeBtn.textContent = theme === "dark" ? "☀" : "☾";
|
||||
});
|
||||
|
||||
const content = h("div", { class: "content" }, concat.el, history.el);
|
||||
const app = document.getElementById("app")!;
|
||||
app.append(
|
||||
h("div", { class: "titlebar" },
|
||||
h("div", { class: "brand" },
|
||||
h("div", { class: "logo" }, "🎬"),
|
||||
h("span", {}, "Video Concat")),
|
||||
h("div", { class: "tabs", style: "margin:0 0 0 12px" }, tabs.merge, tabs.history),
|
||||
h("div", { class: "spacer" }),
|
||||
themeBtn,
|
||||
),
|
||||
h("div", { class: "content" }, concat.el),
|
||||
content,
|
||||
);
|
||||
|
||||
// ── Drag-and-drop routes to the concat view ─────────────────────────────
|
||||
await onFilesDropped((paths) => concat.onDrop(paths));
|
||||
// ── Drag-and-drop always lands in Merge ─────────────────────────────────
|
||||
await onFilesDropped((paths) => { show("merge"); concat.onDrop(paths); });
|
||||
|
||||
// ── ffmpeg health check ─────────────────────────────────────────────────
|
||||
try {
|
||||
const tools = await getTools();
|
||||
if (!tools.ffmpeg) {
|
||||
const banner = h("div", { class: "banner", style: "margin:0 22px 12px" },
|
||||
"⚠ ffmpeg was not found. Put ffmpeg.exe & ffprobe.exe next to the app or on your PATH.");
|
||||
app.querySelector(".content")!.before(banner);
|
||||
// ── Health checks ───────────────────────────────────────────────────────
|
||||
if (info && !info.ffmpeg) {
|
||||
content.before(h("div", { class: "banner", style: "margin:0 22px 12px" },
|
||||
"⚠ ffmpeg was not found. Put ffmpeg.exe & ffprobe.exe next to the app or on your PATH."));
|
||||
}
|
||||
} catch (e) {
|
||||
toast(`Backend not ready: ${e}`, "err");
|
||||
if (info && info.data_dir && !info.portable) {
|
||||
content.before(h("div", { class: "banner", style: "margin:0 22px 12px" },
|
||||
"⚠ Settings are being kept outside the app folder (WebView2 refused the data folder). "
|
||||
+ "History still works; theme and options won't travel with the folder."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -305,3 +305,51 @@ button, input, select { font-family: inherit; font-size: inherit; color: inherit
|
||||
}
|
||||
.hint { font-size: 12px; color: var(--text-faint); }
|
||||
.rename-preview { font-size: 12px; color: var(--accent); min-height: 16px; }
|
||||
|
||||
/* ── History ──────────────────────────────────────────────────────────── */
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn.ghost.danger:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }
|
||||
.hist-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.hist-row {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--surface-2); padding: 10px 12px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.hist-top { display: flex; align-items: center; gap: 10px; }
|
||||
.hist-name { flex: 1; min-width: 0; font-weight: 600;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hist-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px;
|
||||
font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.hist-error {
|
||||
font-family: "Cascadia Code", "Consolas", monospace; font-size: 11.5px;
|
||||
color: var(--danger); white-space: pre-wrap; user-select: text;
|
||||
max-height: 4.6em; overflow-y: auto;
|
||||
}
|
||||
.hist-actions { display: flex; gap: 4px; margin-left: -8px; }
|
||||
.hist-files { display: flex; flex-direction: column; gap: 2px; font-size: 12px;
|
||||
border-top: 1px solid var(--border); padding-top: 6px; }
|
||||
.hist-files[hidden] { display: none; }
|
||||
.hist-file { display: grid; grid-template-columns: 26px 1fr auto 72px; gap: 8px; padding: 2px 0; }
|
||||
.hist-file .idx { color: var(--text-faint); text-align: center; }
|
||||
.hist-file .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hist-file .dur { text-align: right; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.hist-file.gone .name { color: var(--text-faint); text-decoration: line-through; }
|
||||
.hist-file.gone .dur:last-child { color: var(--danger); }
|
||||
.empty { padding: 28px 12px; text-align: center; color: var(--text-faint); }
|
||||
|
||||
.badge {
|
||||
font-size: 11px; font-weight: 650; padding: 2px 8px; border-radius: 99px;
|
||||
border: 1px solid transparent; white-space: nowrap;
|
||||
}
|
||||
.badge.done { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); }
|
||||
.badge.failed { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||
.badge.cancelled { color: var(--text-dim); background: var(--surface-hover); }
|
||||
.badge.interrupted { color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); }
|
||||
|
||||
.tag {
|
||||
font-size: 11px; padding: 1px 7px; border-radius: 6px;
|
||||
border: 1px solid var(--border); color: var(--text-dim); background: var(--surface);
|
||||
}
|
||||
.tag.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); }
|
||||
.tag.muted { color: var(--text-faint); font-style: italic; }
|
||||
.banner[hidden] { display: none; }
|
||||
|
||||
@@ -4,17 +4,24 @@ import { h, stem, dirname, joinPath } from "../dom";
|
||||
import { FileList } from "../filelist";
|
||||
import { makeProgress, toast } from "../ui";
|
||||
import {
|
||||
pickVideos, pickFolder, startConcat, cancel, deleteFiles, bililiveRename,
|
||||
pickVideos, pickFolder, startConcat, cancel, bililiveRename, pathExists, confirmAsk,
|
||||
onConcatProgress, reveal,
|
||||
} from "../api";
|
||||
|
||||
const EXTS = [".mp4", ".mkv", ".mov", ".avi", ".ts"];
|
||||
const FASTSTART_KEY = "faststart";
|
||||
|
||||
export interface View { el: HTMLElement; onDrop: (paths: string[]) => void; }
|
||||
export interface ConcatView {
|
||||
el: HTMLElement;
|
||||
onDrop: (paths: string[]) => void;
|
||||
/** Replace the list with these files, in this order (History → Merge again). */
|
||||
load: (paths: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function buildConcat(): View {
|
||||
export function buildConcat(opts: { onFinished?: () => void } = {}): ConcatView {
|
||||
let running = false;
|
||||
let lastOutput: string | null = null;
|
||||
let renamedTo: string | null = null; // name the BiliBili rename produced, if applied
|
||||
|
||||
const nameInput = h("input", { class: "input grow", placeholder: "output_concat" }) as HTMLInputElement;
|
||||
const extSelect = h("select", { class: "input", style: "width:96px" },
|
||||
@@ -23,6 +30,11 @@ export function buildConcat(): View {
|
||||
const renamePreview = h("div", { class: "rename-preview" });
|
||||
const renameBtn = h("button", { class: "btn accent-soft", disabled: true }, "✨ Rename") as HTMLButtonElement;
|
||||
const delChk = h("input", { type: "checkbox", class: "checkbox" }) as HTMLInputElement;
|
||||
const fastChk = h("input", { type: "checkbox", class: "checkbox" }) as HTMLInputElement;
|
||||
try { fastChk.checked = localStorage.getItem(FASTSTART_KEY) === "1"; } catch { /* default off */ }
|
||||
fastChk.addEventListener("change", () => {
|
||||
try { localStorage.setItem(FASTSTART_KEY, fastChk.checked ? "1" : "0"); } catch { /* not persisted */ }
|
||||
});
|
||||
|
||||
const listEl = h("div", { class: "list" });
|
||||
const totalEl = h("div", { class: "faint" }, "No files");
|
||||
@@ -76,12 +88,20 @@ export function buildConcat(): View {
|
||||
|
||||
renameBtn.addEventListener("click", async () => {
|
||||
const renamed = await bililiveRename(nameInput.value.trim());
|
||||
if (renamed) { nameInput.value = renamed; refresh(); }
|
||||
if (renamed) { nameInput.value = renamed; renamedTo = renamed; refresh(); }
|
||||
});
|
||||
nameInput.addEventListener("input", refresh);
|
||||
dirInput.addEventListener("input", refresh);
|
||||
extSelect.addEventListener("change", refresh);
|
||||
|
||||
async function load(paths: string[]) {
|
||||
files.clearAll();
|
||||
nameInput.value = "";
|
||||
dirInput.value = "";
|
||||
renamedTo = null;
|
||||
await addFiles(paths);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const checked = files.checkedPaths();
|
||||
const out = outPath();
|
||||
@@ -90,6 +110,11 @@ export function buildConcat(): View {
|
||||
toast("Output name matches a source file — choose another.", "err");
|
||||
return;
|
||||
}
|
||||
// ffmpeg runs with -y, so without this an existing file is replaced silently
|
||||
if (await pathExists(out)) {
|
||||
const ok = await confirmAsk(`${out}\n\nalready exists. Replace it?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
startBtn.disabled = true;
|
||||
@@ -102,16 +127,19 @@ export function buildConcat(): View {
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await startConcat(checked, out);
|
||||
const res = await startConcat({
|
||||
files: checked,
|
||||
output: out,
|
||||
durations: files.checkedDurations(),
|
||||
faststart: fastChk.checked,
|
||||
deleteOriginals: delChk.checked,
|
||||
renamed: renamedTo !== null && nameInput.value.trim() === renamedTo,
|
||||
});
|
||||
lastOutput = res.output;
|
||||
progress.set(1, `Done — ${res.size_mb.toFixed(1)} MB`);
|
||||
toast("Concatenation complete", "ok");
|
||||
revealBtn.style.display = "";
|
||||
|
||||
if (delChk.checked) {
|
||||
const n = await deleteFiles(checked);
|
||||
if (n) toast(`Deleted ${n} original file(s)`, "ok");
|
||||
}
|
||||
if (res.deleted) toast(`Deleted ${res.deleted} original file(s)`, "ok");
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
if (msg.includes("__cancelled__")) { progress.set(0, "Stopped by user."); }
|
||||
@@ -121,6 +149,7 @@ export function buildConcat(): View {
|
||||
running = false;
|
||||
stopBtn.disabled = true;
|
||||
refresh();
|
||||
opts.onFinished?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +200,11 @@ export function buildConcat(): View {
|
||||
h("label", { class: "row", style: "cursor:pointer" }, delChk,
|
||||
h("span", {}, "Delete original files after a successful merge")),
|
||||
h("div", { class: "hint", style: "margin-top:4px;margin-left:27px" }, "⚠ Permanent — originals cannot be recovered."),
|
||||
h("label", { class: "row", style: "cursor:pointer;margin-top:12px" }, fastChk,
|
||||
h("span", {}, "Web-optimize MP4 (+faststart)")),
|
||||
h("div", { class: "hint", style: "margin-top:4px;margin-left:27px" },
|
||||
"Moves the index to the front so playback can start mid-download. Rewrites the whole file a second time — "
|
||||
+ "about 2× slower. Not needed for local playback; MP4/MOV only."),
|
||||
),
|
||||
// 5 · Run
|
||||
h("div", { class: "card" },
|
||||
@@ -180,5 +214,5 @@ export function buildConcat(): View {
|
||||
),
|
||||
);
|
||||
|
||||
return { el, onDrop: addFiles };
|
||||
return { el, onDrop: addFiles, load };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||