v5: standalone Tauri rewrite — Rust backend + TypeScript UI

Complete rewrite from the Python/Tkinter app to a native Tauri 2 desktop
app. Drops the song-detection feature, so it needs no Python and no ML
model — just ffmpeg. Replaces video_concat.py entirely.

- Rust backend (src-tauri): stream-copy concat via ffmpeg with honest
  real-time progress (-progress pipe:1, out_time vs total duration), held
  at 99% through the +faststart finalising pass so the bar never jumps
- ffprobe duration scanning parallelised across threads; cancel kills the
  whole ffmpeg process tree; CREATE_NO_WINDOW hides console flashes
- BiliBili Live filename helper ported to Rust (bililive_rename)
- TypeScript UI, no framework: tiny h() DOM helper, drag-and-drop,
  reorderable/checkable file list with live totals, light/dark theme,
  reusable toast + progress components
- Optional delete-originals-after-merge with a clear warning
- New app icon (three clips merging into one); build.bat assembles a
  portable folder with ffmpeg/ffprobe bundled next to the exe
- Ignore tauri-generated gen/schemas (regenerated on every build)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:24:59 +08:00
parent 3261a461cb
commit 37ec549544
78 changed files with 8101 additions and 1100 deletions

46
.gitignore vendored
View File

@@ -1,4 +1,42 @@
video_concat_v2.1.py
video_concat_v3.py
video_concat_v4.py
video_concat_v4.1.py
# deps & caches
node_modules/
__pycache__/
*.pyc
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# build outputs
target/
dist/
dist-portable/
build/
# tauri auto-generated (regenerated on every build)
/src-tauri/gen/schemas/
# logs & databases
*.log
*.db
*.sqlite
*.sqlite3
# secrets / credentials
.env
*.env
*cookie*
*credential*
credentials.json
token.json
*token.json
.cookies.json
google_cred/
*.pem
*.key
# model weights / large artifacts
*.onnx
*.pt
*.pth
*.ckpt
*.safetensors
# os cruft
.DS_Store
Thumbs.db

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# Video Concat
A small, focused desktop app that **merges split stream recordings into one
file** using ffmpeg stream-copy (`-c copy`, no re-encoding — fast and lossless).
Built with Tauri 2 (Rust backend + TypeScript UI).
This is a standalone spin-off of Stream Studio with the song-detection feature
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).
- BiliBili-Live filename helper (`[date][uploader][title]` → readable name).
- Optionally delete the originals after a successful merge.
## Build a standalone (copy-paste) version
Double-click **`build.bat`**. It compiles the app and assembles a portable
folder with ffmpeg bundled:
```
dist-portable\VideoConcat\
Video Concat.exe <- double-click this
ffmpeg.exe <- bundled
ffprobe.exe <- bundled
```
Copy that whole `VideoConcat` folder to any Windows PC and run it — nothing else
to install.
### 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`)
## Develop
Double-click **`dev.bat`** (hot-reload UI + Rust). Needs ffmpeg on `PATH`.

52
build.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
REM ============================================================
REM Video Concat - build a STANDALONE, portable Windows app.
REM
REM Output: dist-portable\VideoConcat\
REM Usage: copy that whole folder to any Windows PC and
REM double-click "Video Concat.exe". ffmpeg is bundled.
REM
REM Requires on THIS build machine: Node, Rust (cargo),
REM Visual Studio 2022 C++ build tools, and ffmpeg.
REM ============================================================
setlocal EnableDelayedExpansion
set "PATH=%USERPROFILE%\.cargo\bin;%PATH%"
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"
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 )
echo Using ffmpeg: !FFMPEG!
echo Using ffprobe: !FFPROBE!
REM --- 2. Build the release app ------------------------------
if not exist "node_modules" call npm install
echo Building release (this takes a few minutes the first time)...
call npm run tauri build
if errorlevel 1 ( echo [ERROR] Build failed. & pause & exit /b 1 )
REM --- 3. Assemble the portable folder ----------------------
set "OUT=dist-portable\VideoConcat"
if exist "dist-portable" rmdir /s /q "dist-portable"
mkdir "%OUT%"
copy /y "src-tauri\target\release\video-concat.exe" "%OUT%\Video Concat.exe" >nul
copy /y "!FFMPEG!" "%OUT%\ffmpeg.exe" >nul
copy /y "!FFPROBE!" "%OUT%\ffprobe.exe" >nul
echo.
echo ============================================================
echo DONE. Your standalone app is here:
echo %CD%\%OUT%
echo.
echo Copy that whole "VideoConcat" folder to any Windows PC and
echo double-click "Video Concat.exe". ffmpeg is included, so
echo there is nothing else to install.
echo ============================================================
pause

14
dev.bat Normal file
View File

@@ -0,0 +1,14 @@
@echo off
REM Video Concat - run in development (hot-reload UI + Rust).
REM Requires: Node, Rust (cargo), ffmpeg on PATH.
setlocal
set "PATH=%USERPROFILE%\.cargo\bin;%PATH%"
cd /d "%~dp0"
if not exist "node_modules" (
echo Installing npm dependencies...
call npm install
)
echo Starting Video Concat (dev)...
call npm run tauri dev

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Video Concat</title>
</head>
<body>
<div id="app"></div>
<div class="toast-host" id="toasts"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1371
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "video-concat",
"private": true,
"version": "5.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-dialog": "^2"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"vite": "^6.0.3",
"typescript": "~5.6.2"
}
}

5045
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

32
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "video-concat"
version = "5.0.0"
description = "Video Concat — merge split stream recordings into one file"
authors = ["youfu"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "video_concat_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true
panic = "abort"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:allow-start-dragging",
"opener:default",
"opener:allow-reveal-item-in-dir",
"dialog:default",
"dialog:allow-open"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

203
src-tauri/src/concat.rs Normal file
View File

@@ -0,0 +1,203 @@
//! ffmpeg concat-demuxer worker with honest, real-time progress.
//!
//! 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…".
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use crate::tools::{fmt_dur, hide_window, probe_duration};
use crate::AppState;
#[derive(Clone, Serialize)]
pub struct ConcatProgress {
pub pct: f64,
pub status: String,
pub elapsed: f64,
pub eta: f64,
pub speed: f64,
}
#[derive(Clone, Serialize)]
pub struct ConcatResult {
pub output: String,
pub size_mb: f64,
pub elapsed: f64,
}
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()));
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"));
}
std::fs::write(&list_path, body).map_err(|e| format!("Could not write list file: {e}"))?;
Ok(list_path)
}
pub fn run(
app: &AppHandle,
state: &AppState,
ffmpeg: &str,
ffprobe: &str,
files: Vec<String>,
output: String,
) -> 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();
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 list_path = write_list(&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.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 stdout = child.stdout.take().ok_or("No ffmpeg stdout")?;
let stderr = child.stderr.take();
// Hand the child to shared state so cancel() can kill it.
*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*.
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 line = line.trim();
if let Some(v) = line.strip_prefix("out_time_us=").or_else(|| line.strip_prefix("out_time_ms=")) {
if let Ok(n) = v.parse::<i64>() {
cur_us = n; // ffmpeg reports microseconds under both keys
}
} else if line == "progress=end" {
// Copy is done; the +faststart rewrite happens now, during which
// ffmpeg emits nothing. Spawn a heartbeat to keep the UI honest.
if heartbeat.is_none() {
let appc = app.clone();
let finc = fin.clone();
heartbeat = Some(thread::spawn(move || {
while !finc.load(Ordering::SeqCst) {
let elapsed = t0.elapsed().as_secs_f64();
emit(&appc, ConcatProgress {
pct: 0.99,
status: "Finalising output file…".into(),
elapsed,
eta: -1.0,
speed: 0.0,
});
thread::sleep(Duration::from_millis(700));
}
}));
}
} else if line.starts_with("progress=continue") {
if total_secs > 0.0 && cur_us > 0 {
let played = cur_us as f64 / 1_000_000.0;
let pct = (played / total_secs).clamp(0.0, 0.99);
let elapsed = t0.elapsed().as_secs_f64();
let speed = played / elapsed.max(1e-9);
let eta = (total_secs - played) / speed.max(1e-9);
emit(app, ConcatProgress {
pct,
status: "Concatenating…".into(),
elapsed,
eta,
speed,
});
}
}
}
// 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();
}
// 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();
if let Some(mut se) = stderr {
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 !status.success() {
let _ = std::fs::remove_file(&output);
let msg = stderr_text.trim();
return Err(if msg.is_empty() { "Unknown ffmpeg error.".into() } else { msg.to_string() });
}
let elapsed = t0.elapsed().as_secs_f64();
let size_mb = std::fs::metadata(&output).map(|m| m.len() as f64 / 1_048_576.0).unwrap_or(0.0);
emit(app, ConcatProgress {
pct: 1.0,
status: format!("Done — {} · {:.1} MB", fmt_dur(elapsed), size_mb),
elapsed,
eta: 0.0,
speed: 0.0,
});
Ok(ConcatResult { output, size_mb, elapsed })
}

166
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,166 @@
//! Video Concat — Tauri backend.
//! A focused tool: stream-copy concat of split recordings via ffmpeg, with a
//! small command surface to the web UI.
mod concat;
mod tools;
use std::process::Child;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use std::thread;
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use concat::ConcatResult;
pub struct AppState {
pub proc: Mutex<Option<Child>>,
pub cancel: AtomicBool,
pub ffmpeg: Option<String>,
pub ffprobe: Option<String>,
}
impl AppState {
fn new() -> Self {
Self {
proc: Mutex::new(None),
cancel: AtomicBool::new(false),
ffmpeg: tools::find_tool("ffmpeg"),
ffprobe: tools::find_tool("ffprobe"),
}
}
}
#[derive(Serialize)]
struct ToolsInfo {
ffmpeg: Option<String>,
ffprobe: Option<String>,
}
#[derive(Serialize)]
struct DurationInfo {
path: String,
duration: f64,
}
// ── Commands ─────────────────────────────────────────────────────────────────
#[tauri::command]
fn get_tools(state: State<AppState>) -> ToolsInfo {
ToolsInfo {
ffmpeg: state.ffmpeg.clone(),
ffprobe: state.ffprobe.clone(),
}
}
/// 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 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()
}
#[tauri::command]
fn is_video(path: String) -> bool {
tools::is_video(&path)
}
// 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> {
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)
})
.await
.map_err(|e| format!("concat task failed: {e}"))?
}
#[tauri::command]
fn cancel(state: State<AppState>) {
state.cancel.store(true, std::sync::atomic::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());
let _ = child.kill();
}
}
#[tauri::command]
fn delete_files(paths: Vec<String>) -> usize {
paths.iter().filter(|p| std::fs::remove_file(p).is_ok()).count()
}
/// Convert a BiliBili Live recording stem to a readable name, or null.
/// `[2026-03-14 22-46-37][纳豆nado][title]` → `2026年03月14日【直播回放】title`
#[tauri::command]
fn bililive_rename(stem: String) -> Option<String> {
let test = stem.strip_suffix("_concat").unwrap_or(&stem).trim();
let s = test.strip_prefix('[')?;
let (datetime, rest) = s.split_once(']')?;
let (date, _time) = datetime.split_once(' ')?;
let parts: Vec<&str> = date.split('-').collect();
if parts.len() != 3 {
return None;
}
let (y, m, d) = (parts[0], parts[1], parts[2]);
if y.len() != 4 || m.len() != 2 || d.len() != 2 {
return None;
}
if !y.bytes().chain(m.bytes()).chain(d.bytes()).all(|b| b.is_ascii_digit()) {
return None;
}
let rest = rest.strip_prefix('[')?;
let (_uploader, rest) = rest.split_once(']')?;
let title = rest.strip_prefix('[')?.strip_suffix(']')?;
if title.is_empty() {
return None;
}
Some(format!("{y}{m}{d}日【直播回放】{title}"))
}
#[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());
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_tools,
probe_durations,
is_video,
start_concat,
cancel,
delete_files,
bililive_rename,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
video_concat_lib::run()
}

126
src-tauri/src/tools.rs Normal file
View File

@@ -0,0 +1,126 @@
//! ffmpeg / ffprobe discovery, probing, and small shared helpers.
use std::path::{Path, PathBuf};
use std::process::Command;
/// On Windows, prevent child processes from flashing a console window.
#[cfg(windows)]
pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Apply the no-window flag to a Command (no-op on non-Windows).
pub fn hide_window(cmd: &mut Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
{
let _ = cmd;
}
}
/// Forcibly kill a process *and all of its descendants*.
///
/// `Child::kill()` only signals the direct child. ffmpeg is a single process,
/// so that is usually enough, but `taskkill /T` walks the whole tree to be
/// safe if ffmpeg ever spawns helpers of its own.
pub fn kill_process_tree(pid: u32) {
#[cfg(windows)]
{
let mut cmd = Command::new("taskkill");
cmd.args(["/F", "/T", "/PID", &pid.to_string()]);
hide_window(&mut cmd);
let _ = cmd.output();
}
#[cfg(not(windows))]
{
// Best-effort on Unix: kill the process group.
let _ = Command::new("pkill").args(["-TERM", "-P", &pid.to_string()]).output();
}
}
/// 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() {
for sub in ["", "bin", "resources", "_internal", ".."] {
let cand = base.join(sub).join(&exe);
if cand.exists() {
return Some(cand.to_string_lossy().into_owned());
}
}
}
// Fall back to PATH discovery via `where` semantics.
if which(&exe).is_some() {
return Some(exe);
}
if which(name).is_some() {
return Some(name.to_string());
}
None
}
/// Minimal PATH lookup so we don't pull in an extra crate.
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let full = dir.join(name);
if full.is_file() {
return Some(full);
}
}
None
}
/// Probe media duration (seconds) via ffprobe. Returns 0.0 on any failure.
pub fn probe_duration(ffprobe: &str, path: &str) -> f64 {
let mut cmd = Command::new(ffprobe);
cmd.args([
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
path,
]);
hide_window(&mut cmd);
match cmd.output() {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<f64>()
.unwrap_or(0.0)
.max(0.0),
_ => 0.0,
}
}
/// Format seconds as H:MM:SS or M:SS.
pub fn fmt_dur(seconds: f64) -> String {
if seconds < 0.0 {
return "".into();
}
let s = seconds.round() as i64;
let (h, rem) = (s / 3600, s % 3600);
let (m, sec) = (rem / 60, rem % 60);
if h > 0 {
format!("{h}:{m:02}:{sec:02}")
} else {
format!("{m}:{sec:02}")
}
}
/// True if a path has a recognised video extension.
pub fn is_video(path: &str) -> bool {
const EXTS: [&str; 11] = [
"mp4", "mov", "mkv", "avi", "flv", "ts", "wmv", "m4v", "webm", "mts", "m2ts",
];
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| EXTS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}

39
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Video Concat",
"version": "5.0.0",
"identifier": "com.youfu.video-concat",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Video Concat",
"width": 960,
"height": 820,
"minWidth": 720,
"minHeight": 620,
"dragDropEnabled": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["msi"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

45
src/api.ts Normal file
View File

@@ -0,0 +1,45 @@
// Typed bridge to the Rust backend (concat-only build).
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 { revealItemInDir } from "@tauri-apps/plugin-opener";
export interface Tools { ffmpeg: string | null; ffprobe: string | null; }
export interface DurationInfo { path: string; duration: number; }
export interface ConcatResult { output: string; size_mb: number; elapsed: number; }
export interface ConcatProgress { pct: number; status: string; elapsed: number; eta: number; speed: number; }
export const getTools = () => invoke<Tools>("get_tools");
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 cancel = () => invoke<void>("cancel");
export const deleteFiles = (paths: string[]) => invoke<number>("delete_files", { paths });
export const bililiveRename = (stem: string) => invoke<string | null>("bililive_rename", { stem });
export const onConcatProgress = (cb: (p: ConcatProgress) => void): Promise<UnlistenFn> =>
listen<ConcatProgress>("concat-progress", (e) => cb(e.payload));
export const onFilesDropped = (cb: (paths: string[]) => void): Promise<UnlistenFn> =>
getCurrentWebview().onDragDropEvent((e) => {
if (e.payload.type === "drop") cb(e.payload.paths);
});
export async function pickVideos(): Promise<string[]> {
const sel = await open({
multiple: true,
filters: [{ name: "Video", extensions: ["mp4", "mov", "mkv", "avi", "flv", "ts", "wmv", "m4v", "webm", "mts", "m2ts"] }],
});
if (!sel) return [];
return Array.isArray(sel) ? sel : [sel];
}
export async function pickFolder(): Promise<string | null> {
const sel = await open({ directory: true, multiple: false });
return typeof sel === "string" ? sel : null;
}
export const reveal = (path: string) => revealItemInDir(path);

78
src/dom.ts Normal file
View File

@@ -0,0 +1,78 @@
// Minimal DOM helpers — keeps the views declarative without a framework.
type Props = Record<string, unknown>;
type Child = Node | string | null | undefined | false;
export function h<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Props = {},
...children: Child[]
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (v == null || v === false) continue;
if (k === "class") el.className = String(v);
else if (k === "html") el.innerHTML = String(v);
else if (k.startsWith("on") && typeof v === "function") {
el.addEventListener(k.slice(2).toLowerCase(), v as EventListener);
} else if (k === "dataset" && typeof v === "object") {
Object.assign(el.dataset, v);
} else if (k in el) {
// direct property (value, disabled, checked, textContent…)
(el as any)[k] = v;
} else {
el.setAttribute(k, String(v));
}
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
return el;
}
export const $ = <T extends Element = HTMLElement>(sel: string, root: ParentNode = document) =>
root.querySelector<T>(sel);
export function clear(el: Element) {
while (el.firstChild) el.removeChild(el.firstChild);
}
export function basename(p: string): string {
return p.replace(/[/\\]+$/, "").split(/[/\\]/).pop() ?? p;
}
export function stem(p: string): string {
const b = basename(p);
const i = b.lastIndexOf(".");
return i > 0 ? b.slice(0, i) : b;
}
export function dirname(p: string): string {
const norm = p.replace(/[/\\]+$/, "");
const i = Math.max(norm.lastIndexOf("/"), norm.lastIndexOf("\\"));
return i >= 0 ? norm.slice(0, i) : "";
}
export function joinPath(dir: string, name: string): string {
if (!dir) return name;
const sep = dir.includes("\\") ? "\\" : "/";
return dir.replace(/[/\\]+$/, "") + sep + name;
}
export function fmtDur(seconds: number): string {
if (seconds < 0) return "…";
if (!seconds) return "—";
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return h ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`
: `${m}:${String(sec).padStart(2, "0")}`;
}
export function fmtElapsed(s: number): string {
if (s < 60) return `${Math.round(s)}s`;
const m = Math.floor(s / 60);
return `${m}m ${String(Math.round(s % 60)).padStart(2, "0")}s`;
}

109
src/filelist.ts Normal file
View File

@@ -0,0 +1,109 @@
// Reusable, reorderable, checkable video file list with async durations.
import { h, clear, basename, fmtDur } from "./dom";
import { probeDurations, isVideo } from "./api";
export interface FileItem {
path: string;
checked: boolean;
duration: number; // -1 = pending
}
export class FileList {
items: FileItem[] = [];
active: number | null = null;
private listEl: HTMLElement;
private totalEl: HTMLElement;
private onChange: () => void;
constructor(listEl: HTMLElement, totalEl: HTMLElement, onChange: () => void) {
this.listEl = listEl;
this.totalEl = totalEl;
this.onChange = onChange;
}
/** Filter to videos, dedupe, append, then probe durations in the background. */
async add(paths: string[]) {
const existing = new Set(this.items.map((i) => i.path));
const fresh: FileItem[] = [];
for (const p of paths) {
if (existing.has(p)) continue;
if (!(await isVideo(p))) continue;
const item: FileItem = { path: p, checked: true, duration: -1 };
this.items.push(item);
fresh.push(item);
}
if (!fresh.length) return;
if (this.active === null) this.active = 0;
this.render();
this.onChange();
const durs = await probeDurations(fresh.map((f) => f.path));
const byPath = new Map(durs.map((d) => [d.path, d.duration]));
for (const f of fresh) f.duration = byPath.get(f.path) ?? 0;
this.render();
this.onChange();
}
checkedPaths(): string[] {
return this.items.filter((i) => i.checked).map((i) => i.path);
}
orderedPaths(): string[] {
return this.items.map((i) => i.path);
}
clearAll() { this.items = []; this.active = null; this.render(); this.onChange(); }
selectAll(v: boolean) { this.items.forEach((i) => (i.checked = v)); this.render(); this.onChange(); }
moveActive(dir: -1 | 1) {
if (this.active === null) return;
const j = this.active + dir;
if (j < 0 || j >= this.items.length) return;
[this.items[this.active], this.items[j]] = [this.items[j], this.items[this.active]];
this.active = j;
this.render();
this.onChange();
}
removeActive() {
if (this.active === null) return;
this.items.splice(this.active, 1);
this.active = this.items.length ? Math.min(this.active, this.items.length - 1) : null;
this.render();
this.onChange();
}
private total(): { secs: number; pending: number } {
let secs = 0, pending = 0;
for (const i of this.items) {
if (i.duration >= 0) secs += i.duration;
else pending++;
}
return { secs, pending };
}
render() {
clear(this.listEl);
this.items.forEach((it, idx) => {
const cb = h("input", { type: "checkbox", class: "checkbox", checked: it.checked }) as HTMLInputElement;
cb.addEventListener("change", () => { it.checked = cb.checked; this.onChange(); });
const row = h("div", { class: `list-row ${this.active === idx ? "active" : ""}` },
h("div", { class: "idx" }, String(idx + 1)),
cb,
h("div", { class: "name", title: it.path }, basename(it.path)),
h("div", { class: "dur" }, it.duration >= 0 ? fmtDur(it.duration) : "…"),
h("button", { class: "x", title: "Remove", onclick: (e: Event) => { e.stopPropagation(); this.active = idx; this.removeActive(); } }, "✕"),
);
row.addEventListener("click", () => { this.active = idx; this.render(); });
this.listEl.append(row);
});
const { secs, pending } = this.total();
const n = this.items.length;
this.totalEl.textContent = !n ? "No files"
: pending ? `${n} file${n > 1 ? "s" : ""} · ${fmtDur(secs)} (scanning ${pending}…)`
: `${n} file${n > 1 ? "s" : ""} · total ${fmtDur(secs)}`;
}
}

57
src/main.ts Normal file
View File

@@ -0,0 +1,57 @@
// Video Concat — app shell: title bar, theme, drag-drop.
import "./styles.css";
import { h } from "./dom";
import { toast } from "./ui";
import { getTools, onFilesDropped } from "./api";
import { buildConcat } from "./views/concat";
async function boot() {
// ── Theme (system default, persisted toggle) ────────────────────────────
const saved = localStorage.getItem("theme");
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();
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);
applyTheme();
themeBtn.textContent = theme === "dark" ? "☀" : "☾";
});
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: "spacer" }),
themeBtn,
),
h("div", { class: "content" }, concat.el),
);
// ── Drag-and-drop routes to the concat view ─────────────────────────────
await onFilesDropped((paths) => concat.onDrop(paths));
// ── ffmpeg health check ─────────────────────────────────────────────────
try {
const tools = await getTools();
if (!tools.ffmpeg) {
const banner = h("div", { class: "banner", style: "margin:0 22px 12px" },
"⚠ ffmpeg was not found. Put ffmpeg.exe & ffprobe.exe next to the app or on your PATH.");
app.querySelector(".content")!.before(banner);
}
} catch (e) {
toast(`Backend not ready: ${e}`, "err");
}
}
boot();

307
src/styles.css Normal file
View File

@@ -0,0 +1,307 @@
/* ============================================================================
Stream Studio — design system
Native-feeling on Windows 11: Segoe UI Variable, Fluent-ish surfaces,
GPU-friendly transitions, custom scrollbars. Light/dark via [data-theme].
========================================================================= */
:root {
--accent: #6d5efc;
--accent-2: #8b7bff;
--accent-ink: #ffffff;
--bg: #f4f5fb;
--bg-grad-1: #eef0fb;
--bg-grad-2: #f7f5ff;
--surface: #ffffff;
--surface-2: #f6f7fc;
--surface-hover: #eef0fa;
--border: #e4e6f0;
--border-strong: #d3d6e6;
--text: #1b1c28;
--text-dim: #5a5d70;
--text-faint: #8b8fa6;
--ok: #16a34a;
--warn: #f59e0b;
--danger: #e11d48;
--shadow-sm: 0 1px 2px rgba(20, 22, 50, 0.06), 0 1px 3px rgba(20, 22, 50, 0.05);
--shadow-md: 0 4px 16px rgba(20, 22, 50, 0.08), 0 2px 6px rgba(20, 22, 50, 0.05);
--shadow-lg: 0 18px 50px rgba(20, 22, 50, 0.16);
--radius: 12px;
--radius-lg: 16px;
--radius-sm: 8px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
}
[data-theme="dark"] {
--accent: #8b7bff;
--accent-2: #a594ff;
--accent-ink: #14121f;
--bg: #0e0f17;
--bg-grad-1: #11111d;
--bg-grad-2: #161427;
--surface: #1a1b27;
--surface-2: #20212f;
--surface-hover: #262838;
--border: #2a2c3c;
--border-strong: #383b50;
--text: #eceefb;
--text-dim: #a6a9bf;
--text-faint: #71748c;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 6px 22px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 22px 60px rgba(0, 0, 0, 0.55);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
font-size: 14px;
color: var(--text);
background: radial-gradient(1200px 600px at 80% -10%, var(--bg-grad-2), transparent 60%),
radial-gradient(900px 500px at -10% 110%, var(--bg-grad-1), transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
user-select: none;
}
button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ── Scrollbars ───────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 99px;
border: 3px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: content-box; }
/* ── App shell ────────────────────────────────────────────────────────── */
#app { display: flex; flex-direction: column; height: 100vh; }
.titlebar {
display: flex; align-items: center; gap: 12px;
padding: 14px 22px;
-webkit-app-region: drag;
}
.titlebar .brand {
display: flex; align-items: center; gap: 10px;
font-weight: 650; font-size: 16px; letter-spacing: 0.2px;
}
.titlebar .logo {
width: 26px; height: 26px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: grid; place-items: center; color: var(--accent-ink);
box-shadow: var(--shadow-sm);
font-size: 15px;
}
.titlebar .spacer { flex: 1; }
.icon-btn {
-webkit-app-region: no-drag;
width: 34px; height: 34px; border-radius: 9px;
border: 1px solid transparent; background: transparent;
color: var(--text-dim); cursor: pointer;
display: grid; place-items: center; font-size: 16px;
transition: background .18s var(--ease), color .18s var(--ease);
}
.icon-btn:hover { background: var(--surface-hover); color: var(--text); }
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.tabs {
-webkit-app-region: no-drag;
display: flex; gap: 4px;
margin: 0 22px;
padding: 4px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
width: fit-content;
}
.tab {
display: flex; align-items: center; gap: 7px;
padding: 8px 16px; border-radius: 9px;
border: none; background: transparent; cursor: pointer;
color: var(--text-dim); font-weight: 550;
transition: color .18s var(--ease), background .18s var(--ease);
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--text);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
/* ── Content area ─────────────────────────────────────────────────────── */
.content { flex: 1; overflow-y: auto; padding: 18px 22px 28px; }
.view { display: flex; flex-direction: column; gap: 16px; max-width: 880px; margin: 0 auto; }
.view[hidden] { display: none; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 18px;
}
.card h2 {
font-size: 13px; font-weight: 650; letter-spacing: .2px;
color: var(--text); margin-bottom: 14px;
display: flex; align-items: center; gap: 8px;
}
.card h2 .step {
width: 20px; height: 20px; border-radius: 6px;
background: var(--surface-2); border: 1px solid var(--border);
display: grid; place-items: center; font-size: 11px; color: var(--text-dim);
}
.muted { color: var(--text-dim); }
.faint { color: var(--text-faint); font-size: 12px; }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
-webkit-app-region: no-drag;
display: inline-flex; align-items: center; gap: 8px;
padding: 9px 16px; border-radius: 10px;
border: 1px solid var(--border-strong); background: var(--surface);
color: var(--text); font-weight: 550; cursor: pointer;
transition: transform .12s var(--ease), background .18s var(--ease),
border-color .18s var(--ease), box-shadow .18s var(--ease), opacity .18s;
}
.btn:hover { background: var(--surface-hover); }
.btn:active { transform: translateY(1px) scale(0.99); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent; color: var(--accent-ink);
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 40%, transparent);
}
.btn.primary:hover { filter: brightness(1.05); background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-dim); }
.btn.ghost:hover { background: var(--surface-hover); color: var(--text); }
.btn.danger:hover { border-color: var(--danger); color: var(--danger); }
.btn.lg { padding: 12px 22px; font-size: 15px; }
.btn.accent-soft {
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--accent);
}
/* ── Inputs ───────────────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--text-dim); font-weight: 550; }
.input, select.input {
-webkit-app-region: no-drag;
width: 100%; padding: 9px 12px; border-radius: 10px;
border: 1px solid var(--border-strong); background: var(--surface-2);
color: var(--text); transition: border-color .18s var(--ease), background .18s var(--ease);
user-select: text;
}
.input:focus { border-color: var(--accent); background: var(--surface); }
.row { display: flex; gap: 10px; align-items: center; }
.row.wrap { flex-wrap: wrap; }
.grow { flex: 1; min-width: 0; }
/* ── Dropzone ─────────────────────────────────────────────────────────── */
.dropzone {
border: 1.5px dashed var(--border-strong); border-radius: var(--radius);
padding: 22px; text-align: center; color: var(--text-dim);
background: var(--surface-2);
transition: border-color .2s var(--ease), background .2s var(--ease), color .2s var(--ease);
}
.dropzone.hot {
border-color: var(--accent); color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}
/* ── File list ────────────────────────────────────────────────────────── */
.list { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow-y: auto; padding-right: 4px; }
.list-head, .list-row {
display: grid; grid-template-columns: 30px 26px 1fr 72px 32px;
align-items: center; gap: 6px;
}
.list-head { padding: 4px 10px; font-size: 11px; color: var(--text-faint); font-weight: 650; }
.list-row {
padding: 9px 10px; border-radius: 10px;
background: var(--surface-2); border: 1px solid transparent;
transition: background .15s var(--ease), border-color .15s var(--ease);
}
.list-row:hover { background: var(--surface-hover); }
.list-row.active { border-color: color-mix(in srgb, var(--accent) 50%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
.list-row .idx { color: var(--text-faint); font-size: 12px; text-align: center; }
.list-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-row .dur { text-align: right; color: var(--text-dim); font-variant-numeric: tabular-nums; font-size: 12px; }
.list-row .x {
width: 24px; height: 24px; border-radius: 7px; border: none; cursor: pointer;
background: transparent; color: var(--text-faint);
transition: background .15s, color .15s;
}
.list-row .x:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.checkbox { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── Progress ─────────────────────────────────────────────────────────── */
.progress-wrap { display: flex; flex-direction: column; gap: 8px; }
.bar { height: 10px; border-radius: 99px; background: var(--surface-2);
overflow: hidden; border: 1px solid var(--border); }
.bar > i {
display: block; height: 100%; width: 0%;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
border-radius: 99px; transition: width .25s var(--ease);
}
.bar.indeterminate > i {
width: 35% !important; border-radius: 99px;
animation: slide 1.1s var(--ease) infinite;
}
@keyframes slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; }
.progress-meta .status { color: var(--text); font-weight: 550; }
.progress-meta .timing { color: var(--text-faint); font-variant-numeric: tabular-nums; }
/* ── Log ──────────────────────────────────────────────────────────────── */
.log {
margin-top: 10px; max-height: 150px; overflow-y: auto;
font-family: "Cascadia Code", "Consolas", monospace; font-size: 12px;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: 10px; padding: 10px; color: var(--text-dim);
white-space: pre-wrap; user-select: text;
}
.log:empty { display: none; }
/* ── Toast ────────────────────────────────────────────────────────────── */
.toast-host { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 50; }
.toast {
padding: 11px 18px; border-radius: 12px; box-shadow: var(--shadow-lg);
background: var(--surface); border: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; font-weight: 550;
animation: rise .3s var(--ease);
}
.toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, var(--border)); }
.toast.err { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.banner {
padding: 10px 14px; border-radius: 10px; font-size: 13px;
background: color-mix(in srgb, var(--warn) 14%, var(--surface));
border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
color: var(--text);
}
.hint { font-size: 12px; color: var(--text-faint); }
.rename-preview { font-size: 12px; color: var(--accent); min-height: 16px; }

67
src/ui.ts Normal file
View File

@@ -0,0 +1,67 @@
// Shared UI helpers: toasts and a reusable progress panel.
import { h, fmtElapsed } from "./dom";
export function toast(message: string, kind: "ok" | "err" | "" = "") {
const host = document.getElementById("toasts")!;
const el = h("div", { class: `toast ${kind}` },
h("span", {}, kind === "ok" ? "✓" : kind === "err" ? "⚠" : "•"),
h("span", {}, message),
);
host.append(el);
setTimeout(() => {
el.style.transition = "opacity .25s, transform .25s";
el.style.opacity = "0";
el.style.transform = "translateY(8px)";
setTimeout(() => el.remove(), 260);
}, kind === "err" ? 6000 : 3200);
}
/** A progress bar + status/timing line + collapsible log, returned as one unit. */
export function makeProgress() {
const fill = h("i");
const bar = h("div", { class: "bar" }, fill);
const status = h("span", { class: "status" }, "Ready");
const timing = h("span", { class: "timing" });
const log = h("div", { class: "log" });
const root = h("div", { class: "progress-wrap" },
bar,
h("div", { class: "progress-meta" }, status, timing),
log,
);
return {
root,
reset() {
bar.classList.remove("indeterminate");
fill.style.width = "0%";
status.textContent = "Ready";
timing.textContent = "";
log.textContent = "";
},
indeterminate(on: boolean, text?: string) {
bar.classList.toggle("indeterminate", on);
if (on) fill.style.width = "35%";
if (text) status.textContent = text;
},
set(pct: number, statusText: string, opts?: { elapsed?: number; eta?: number; speed?: number }) {
bar.classList.remove("indeterminate");
fill.style.width = `${Math.round(pct * 100)}%`;
status.textContent = statusText;
if (opts) {
const bits: string[] = [];
if (opts.elapsed != null) bits.push(`Elapsed ${fmtElapsed(opts.elapsed)}`);
if (opts.eta != null && opts.eta > 0) bits.push(`ETA ${fmtElapsed(opts.eta)}`);
if (opts.speed != null && opts.speed > 0) bits.push(`${opts.speed.toFixed(1)}×`);
timing.textContent = bits.join(" · ");
}
},
setTiming(text: string) { timing.textContent = text; },
appendLog(line: string) {
log.textContent += (log.textContent ? "\n" : "") + line;
log.scrollTop = log.scrollHeight;
},
};
}
export type Progress = ReturnType<typeof makeProgress>;

184
src/views/concat.ts Normal file
View File

@@ -0,0 +1,184 @@
// Concat view — merge split stream recordings into one file.
import { h, stem, dirname, joinPath } from "../dom";
import { FileList } from "../filelist";
import { makeProgress, toast } from "../ui";
import {
pickVideos, pickFolder, startConcat, cancel, deleteFiles, bililiveRename,
onConcatProgress, reveal,
} from "../api";
const EXTS = [".mp4", ".mkv", ".mov", ".avi", ".ts"];
export interface View { el: HTMLElement; onDrop: (paths: string[]) => void; }
export function buildConcat(): View {
let running = false;
let lastOutput: string | null = null;
const nameInput = h("input", { class: "input grow", placeholder: "output_concat" }) as HTMLInputElement;
const extSelect = h("select", { class: "input", style: "width:96px" },
...EXTS.map((e) => h("option", { value: e }, e))) as HTMLSelectElement;
const dirInput = h("input", { class: "input grow", placeholder: "Choose output folder…" }) as HTMLInputElement;
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 listEl = h("div", { class: "list" });
const totalEl = h("div", { class: "faint" }, "No files");
const files = new FileList(listEl, totalEl, refresh);
const startBtn = h("button", { class: "btn primary lg", disabled: true }, "▶ Start Concatenation") as HTMLButtonElement;
const stopBtn = h("button", { class: "btn danger", disabled: true }, "■ Stop") as HTMLButtonElement;
const progress = makeProgress();
const dropzone = h("div", { class: "dropzone" },
h("div", { style: "font-size:24px;margin-bottom:6px" }, "📂"),
h("div", {}, "Drag & drop videos here, or "),
h("button", { class: "btn", style: "margin-top:10px", onclick: pick }, " Add Videos"),
);
async function pick() {
const sel = await pickVideos();
if (sel.length) addFiles(sel);
}
async function addFiles(paths: string[]) {
await files.add(paths);
if (!dirInput.value.trim() && files.items.length) {
dirInput.value = dirname(files.items[0].path);
}
if ((!nameInput.value.trim() || nameInput.value === "output_concat") && files.items.length) {
nameInput.value = stem(files.items[0].path);
}
refresh();
}
function outPath(): string {
const d = dirInput.value.trim(), n = nameInput.value.trim();
if (!d || !n) return "";
return joinPath(d, n + extSelect.value);
}
async function refresh() {
const ok = !running && files.checkedPaths().length > 0 && !!dirInput.value.trim() && !!nameInput.value.trim();
startBtn.disabled = !ok;
// BiliBili rename preview
const renamed = await bililiveRename(nameInput.value.trim());
if (renamed && renamed !== nameInput.value.trim()) {
renamePreview.textContent = `${renamed}`;
renameBtn.disabled = false;
} else {
renamePreview.textContent = "";
renameBtn.disabled = true;
}
}
renameBtn.addEventListener("click", async () => {
const renamed = await bililiveRename(nameInput.value.trim());
if (renamed) { nameInput.value = renamed; refresh(); }
});
nameInput.addEventListener("input", refresh);
dirInput.addEventListener("input", refresh);
extSelect.addEventListener("change", refresh);
async function start() {
const checked = files.checkedPaths();
const out = outPath();
if (!checked.length || !out) return;
if (checked.some((f) => stem(f) === stem(out))) {
toast("Output name matches a source file — choose another.", "err");
return;
}
running = true;
startBtn.disabled = true;
stopBtn.disabled = false;
progress.reset();
progress.set(0, "Starting…");
const un = await onConcatProgress((p) => {
progress.set(p.pct, p.status, { elapsed: p.elapsed, eta: p.eta, speed: p.speed });
});
try {
const res = await startConcat(checked, out);
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");
}
} catch (e) {
const msg = String(e);
if (msg.includes("__cancelled__")) { progress.set(0, "Stopped by user."); }
else { progress.set(0, "Failed."); toast(msg, "err"); }
} finally {
un();
running = false;
stopBtn.disabled = true;
refresh();
}
}
const revealBtn = h("button", { class: "btn ghost", style: "display:none", onclick: () => lastOutput && reveal(lastOutput) }, "Show in folder");
startBtn.addEventListener("click", start);
stopBtn.addEventListener("click", () => cancel());
const el = h("div", { class: "view" },
// 1 · Add
h("div", { class: "card" },
h("h2", {}, h("span", { class: "step" }, "1"), "Add Videos"),
dropzone,
),
// 2 · List
h("div", { class: "card" },
h("h2", {}, h("span", { class: "step" }, "2"), "Review & Reorder"),
h("div", { class: "list-head" },
h("div", { style: "text-align:center" }, "#"), h("div"), h("div", {}, "File"), h("div", { class: "dur" }, "Length"), h("div")),
listEl,
h("div", { class: "row", style: "margin-top:10px;justify-content:space-between" },
h("div", { class: "toolbar" },
h("button", { class: "btn ghost", onclick: () => files.moveActive(-1) }, "↑ Up"),
h("button", { class: "btn ghost", onclick: () => files.moveActive(1) }, "↓ Down"),
h("button", { class: "btn ghost", onclick: () => files.selectAll(true) }, "Select all"),
h("button", { class: "btn ghost", onclick: () => files.selectAll(false) }, "None"),
h("button", { class: "btn ghost", onclick: () => files.clearAll() }, "Clear"),
),
totalEl,
),
),
// 3 · Output
h("div", { class: "card" },
h("h2", {}, h("span", { class: "step" }, "3"), "Output"),
h("div", { class: "field" },
h("label", {}, "File name"),
h("div", { class: "row" }, nameInput, extSelect, renameBtn),
renamePreview,
),
h("div", { class: "field", style: "margin-top:10px" },
h("label", {}, "Save to"),
h("div", { class: "row" }, dirInput,
h("button", { class: "btn", onclick: async () => { const d = await pickFolder(); if (d) { dirInput.value = d; refresh(); } } }, "Browse…")),
),
),
// 4 · Options
h("div", { class: "card" },
h("h2", {}, h("span", { class: "step" }, "4"), "Options"),
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."),
),
// 5 · Run
h("div", { class: "card" },
h("h2", {}, h("span", { class: "step" }, "5"), "Run"),
h("div", { class: "row", style: "margin-bottom:14px" }, startBtn, stopBtn, revealBtn),
progress.root,
),
);
return { el, onDrop: addFiles };
}

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

File diff suppressed because it is too large Load Diff

30
vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));