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>
46
.gitignore
vendored
@@ -1,4 +1,42 @@
|
|||||||
video_concat_v2.1.py
|
# deps & caches
|
||||||
video_concat_v3.py
|
node_modules/
|
||||||
video_concat_v4.py
|
__pycache__/
|
||||||
video_concat_v4.1.py
|
*.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
@@ -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
@@ -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
@@ -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
@@ -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
22
package.json
Normal 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
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/api.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
1096
video_concat.py
30
vite.config.ts
Normal 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/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||