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>
5045
src-tauri/Cargo.lock
generated
Normal file
32
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 126 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -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
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
203
src-tauri/src/concat.rs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||