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

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"
]
}
}