initial commit with full project
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
src-tauri/target/
|
||||
dist/
|
||||
*.md
|
||||
!README.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.cursorrules
|
||||
.cursor/
|
||||
.github/copilot*
|
||||
.vscode/
|
||||
.aider*
|
||||
.continue/
|
||||
.codeium/
|
||||
.tabnine*
|
||||
.codex/
|
||||
.agents/
|
||||
SKILL.md
|
||||
.ccmanager.json
|
||||
.copilot*
|
||||
.windsurfrules
|
||||
.windsurf/
|
||||
.bolt/
|
||||
.idx/
|
||||
.replit
|
||||
.devcontainer/
|
||||
6085
package-lock.json
generated
Normal file
6085
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "cinch",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-webfont": "^3.41.1",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"puppeteer-core": "^24.42.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svelte": "^5.55.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
5660
src-tauri/Cargo.lock
generated
Normal file
5660
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
src-tauri/Cargo.toml
Normal file
26
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "cinch"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
http = "1"
|
||||
http-range = "0.1"
|
||||
percent-encoding = "2"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
41
src-tauri/capabilities/default.json
Normal file
41
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Cinch",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"shell:allow-kill",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:default",
|
||||
"fs:allow-read",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-rename",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "**" }
|
||||
]
|
||||
},
|
||||
"process:allow-exit",
|
||||
"process:allow-restart",
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{ "path": "**" }
|
||||
]
|
||||
},
|
||||
"opener:allow-reveal-item-in-dir"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Cinch","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-read","fs:allow-read-file","fs:allow-write","fs:allow-write-file","fs:allow-exists","fs:allow-mkdir","fs:allow-remove","fs:allow-rename",{"identifier":"fs:scope","allow":[{"path":"**"}]},"process:allow-exit","process:allow-restart","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},"opener:allow-reveal-item-in-dir"]}}
|
||||
6463
src-tauri/gen/schemas/desktop-schema.json
Normal file
6463
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
6463
src-tauri/gen/schemas/windows-schema.json
Normal file
6463
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
165
src-tauri/src/commands/analyze.rs
Normal file
165
src-tauri/src/commands/analyze.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::config;
|
||||
use crate::ffmpeg::{discovery, probe};
|
||||
use crate::types::*;
|
||||
use crate::AppState;
|
||||
|
||||
// Step 1: Fast metadata only (instant)
|
||||
#[tauri::command]
|
||||
pub async fn analyze_video(path: String, state: State<'_, AppState>) -> Result<VideoInfo, String> {
|
||||
let ffmpeg_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
|
||||
let ffmpeg = ffmpeg_path.unwrap_or_else(|| "ffmpeg".into());
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
probe::probe_metadata(&path, &ffmpeg)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match &result {
|
||||
Ok(info) => eprintln!("[cinch-rs] analyze_video OK: {}x{} {} {:.1}s", info.width, info.height, info.video_codec, info.duration),
|
||||
Err(e) => eprintln!("[cinch-rs] analyze_video ERROR: {}", e),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// Step 2: Keyframe extraction (can be slow on large files)
|
||||
#[tauri::command]
|
||||
pub async fn extract_keyframes(path: String, state: State<'_, AppState>) -> Result<Vec<f64>, String> {
|
||||
let ffmpeg_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
|
||||
let ffmpeg = ffmpeg_path.unwrap_or_else(|| "ffmpeg".into());
|
||||
|
||||
let times = tokio::task::spawn_blocking(move || {
|
||||
probe::extract_keyframes_fast(&path, &ffmpeg)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(times)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_thumbnails(
|
||||
path: String,
|
||||
count: u32,
|
||||
duration: Option<f64>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let ffmpeg_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
|
||||
let ffmpeg = ffmpeg_path.clone().unwrap_or_else(|| "ffmpeg".into());
|
||||
|
||||
let dur = match duration {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let info = tokio::task::spawn_blocking({
|
||||
let path = path.clone();
|
||||
let ffmpeg = ffmpeg.clone();
|
||||
move || probe::probe_metadata(&path, &ffmpeg)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
info.duration
|
||||
}
|
||||
};
|
||||
|
||||
let hash = format!("{:x}", md5_simple(&path));
|
||||
let thumb_dir = config::ensure_temp_subdir(&format!("thumbs/{}", hash));
|
||||
let pattern = thumb_dir.join("thumb_%04d.jpg").to_string_lossy().to_string();
|
||||
|
||||
let cmd = crate::ffmpeg::commands::build_thumbnail(&path, &pattern, count, dur);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::ffmpeg::runner::run_ffmpeg_silent(&ffmpeg, &cmd)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for i in 1..=count {
|
||||
let p = thumb_dir.join(format!("thumb_{:04}.jpg", i));
|
||||
if p.exists() {
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_preview(
|
||||
path: String,
|
||||
codec: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let ffmpeg_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
|
||||
let ffmpeg = ffmpeg_path.unwrap_or_else(|| "ffmpeg".into());
|
||||
let hash = format!("{:x}", md5_simple(&path));
|
||||
let preview_dir = config::ensure_temp_subdir(&format!("preview/{}", hash));
|
||||
let output = preview_dir.join("preview.mp4").to_string_lossy().to_string();
|
||||
|
||||
// skip if already exists
|
||||
if std::path::Path::new(&output).exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let codec_str = codec.unwrap_or_default();
|
||||
let cmd = crate::ffmpeg::commands::build_preview(&path, &output, &codec_str);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::ffmpeg::runner::run_ffmpeg_silent(&ffmpeg, &cmd)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_hardware(state: State<'_, AppState>) -> Result<HardwareInfo, String> {
|
||||
let ffmpeg_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
|
||||
let ffmpeg = ffmpeg_path.unwrap_or_else(|| "ffmpeg".into());
|
||||
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
discovery::detect_hardware_encoders(&ffmpeg)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
{
|
||||
let mut hw = state.hw_info.lock().map_err(|e| e.to_string())?;
|
||||
*hw = Some(info.clone());
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn md5_simple(input: &str) -> u64 {
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for b in input.bytes() {
|
||||
hash ^= b as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
3
src-tauri/src/commands/mod.rs
Normal file
3
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod analyze;
|
||||
pub mod process;
|
||||
pub mod utility;
|
||||
312
src-tauri/src/commands/process.rs
Normal file
312
src-tauri/src/commands/process.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use std::fs;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::config;
|
||||
use crate::ffmpeg::{commands, probe, runner};
|
||||
use crate::recovery;
|
||||
use crate::types::*;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn compress(
|
||||
input: String,
|
||||
output: String,
|
||||
settings: CompressSettings,
|
||||
trim: Option<TrimRange>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<OutputInfo, String> {
|
||||
let ffmpeg_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
let ffmpeg = ffmpeg_path.unwrap_or_else(|| "ffmpeg".into());
|
||||
|
||||
let app_config = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.clone()
|
||||
};
|
||||
|
||||
let hw_info = {
|
||||
let hw = state.hw_info.lock().map_err(|e| e.to_string())?;
|
||||
hw.clone().unwrap_or(HardwareInfo {
|
||||
nvenc: false,
|
||||
qsv: false,
|
||||
amf: false,
|
||||
nvenc_codecs: Vec::new(),
|
||||
qsv_codecs: Vec::new(),
|
||||
amf_codecs: Vec::new(),
|
||||
})
|
||||
};
|
||||
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
let jobs = state.jobs.clone();
|
||||
|
||||
let info = {
|
||||
let input = input.clone();
|
||||
let ffmpeg = ffmpeg.clone();
|
||||
tokio::task::spawn_blocking(move || probe::probe_video(&input, &ffmpeg))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??
|
||||
};
|
||||
|
||||
let duration = match &trim {
|
||||
Some(t) => t.end - t.start,
|
||||
None => info.duration,
|
||||
};
|
||||
|
||||
let has_audio = info.audio_codec.is_some();
|
||||
|
||||
let settings_json = serde_json::to_string(&settings).unwrap_or_default();
|
||||
recovery::write_job_info(&config::temp_dir(), &input, &output, "compress", &settings_json);
|
||||
|
||||
let (out_path, attempts) = {
|
||||
let input = input.clone();
|
||||
let output = output.clone();
|
||||
let settings = settings.clone();
|
||||
let trim = trim.clone();
|
||||
let ffmpeg = ffmpeg.clone();
|
||||
let hw = hw_info.clone();
|
||||
let cfg = app_config.clone();
|
||||
let jid = job_id.clone();
|
||||
let app = app.clone();
|
||||
let jobs = jobs.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
runner::run_compress_with_retry(
|
||||
&ffmpeg,
|
||||
&input,
|
||||
&output,
|
||||
&settings,
|
||||
trim.as_ref(),
|
||||
&hw,
|
||||
&cfg,
|
||||
&jid,
|
||||
duration,
|
||||
has_audio,
|
||||
&app,
|
||||
&jobs,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??
|
||||
};
|
||||
|
||||
recovery::delete_job_info(&config::temp_dir());
|
||||
|
||||
let out_info = {
|
||||
let path = out_path.clone();
|
||||
let ffmpeg = ffmpeg.clone();
|
||||
tokio::task::spawn_blocking(move || probe::probe_video(&path, &ffmpeg))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??
|
||||
};
|
||||
|
||||
let file_size = fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
Ok(OutputInfo {
|
||||
path: out_path,
|
||||
file_size,
|
||||
duration: out_info.duration,
|
||||
width: out_info.width,
|
||||
height: out_info.height,
|
||||
video_codec: out_info.video_codec,
|
||||
video_bitrate: out_info.video_bitrate,
|
||||
audio_codec: out_info.audio_codec,
|
||||
audio_bitrate: out_info.audio_bitrate,
|
||||
attempts,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn trim(
|
||||
input: String,
|
||||
output: String,
|
||||
range: TrimRange,
|
||||
smart_cut: bool,
|
||||
strip_audio: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<OutputInfo, String> {
|
||||
let ffmpeg_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
let ffmpeg = ffmpeg_path.unwrap_or_else(|| "ffmpeg".into());
|
||||
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
let jobs = state.jobs.clone();
|
||||
|
||||
let range_json = serde_json::to_string(&range).unwrap_or_default();
|
||||
recovery::write_job_info(&config::temp_dir(), &input, &output, "trim", &range_json);
|
||||
|
||||
let do_strip = strip_audio.unwrap_or(false);
|
||||
|
||||
if smart_cut {
|
||||
do_smart_cut(&input, &output, &range, &ffmpeg, &job_id, &app, &jobs, do_strip).await?;
|
||||
} else {
|
||||
let cmd = commands::build_trim_keyframe(&input, &output, &range, do_strip);
|
||||
let ffmpeg_c = ffmpeg.clone();
|
||||
let jid = job_id.clone();
|
||||
let duration = range.end - range.start;
|
||||
let app_c = app.clone();
|
||||
let jobs_c = jobs.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
runner::run_ffmpeg(&ffmpeg_c, &cmd, &jid, duration, "encoding", &app_c, &jobs_c)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
}
|
||||
|
||||
recovery::delete_job_info(&config::temp_dir());
|
||||
|
||||
let out_info = {
|
||||
let path = output.clone();
|
||||
let ffmpeg = ffmpeg.clone();
|
||||
tokio::task::spawn_blocking(move || probe::probe_video(&path, &ffmpeg))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??
|
||||
};
|
||||
|
||||
let file_size = fs::metadata(&output).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
Ok(OutputInfo {
|
||||
path: output,
|
||||
file_size,
|
||||
duration: out_info.duration,
|
||||
width: out_info.width,
|
||||
height: out_info.height,
|
||||
video_codec: out_info.video_codec,
|
||||
video_bitrate: out_info.video_bitrate,
|
||||
audio_codec: out_info.audio_codec,
|
||||
audio_bitrate: out_info.audio_bitrate,
|
||||
attempts: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn do_smart_cut(
|
||||
input: &str,
|
||||
output: &str,
|
||||
range: &TrimRange,
|
||||
ffmpeg: &str,
|
||||
job_id: &str,
|
||||
app: &AppHandle,
|
||||
jobs: &runner::JobMap,
|
||||
strip_audio: bool,
|
||||
) -> Result<(), String> {
|
||||
let info = {
|
||||
let input = input.to_string();
|
||||
let ffmpeg = ffmpeg.to_string();
|
||||
tokio::task::spawn_blocking(move || probe::probe_video(&input, &ffmpeg))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??
|
||||
};
|
||||
|
||||
let keyframes = &info.keyframe_times;
|
||||
|
||||
let first_kf = keyframes
|
||||
.iter()
|
||||
.find(|&&t| t >= range.start)
|
||||
.copied()
|
||||
.unwrap_or(range.start);
|
||||
|
||||
let last_kf = keyframes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|&&t| t <= range.end)
|
||||
.copied()
|
||||
.unwrap_or(range.end);
|
||||
|
||||
let has_audio = info.audio_codec.is_some() && !strip_audio;
|
||||
|
||||
if first_kf >= last_kf || (range.end - range.start) < 5.0 {
|
||||
let cmd = commands::build_trim_keyframe(input, output, range, strip_audio);
|
||||
let ffmpeg = ffmpeg.to_string();
|
||||
let jid = job_id.to_string();
|
||||
let dur = range.end - range.start;
|
||||
let app = app.clone();
|
||||
let jobs = jobs.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
runner::run_ffmpeg(&ffmpeg, &cmd, &jid, dur, "encoding", &app, &jobs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let work_dir = config::ensure_temp_subdir(&format!("smartcut/{}", job_id));
|
||||
let head_path = work_dir.join("head.mp4").to_string_lossy().to_string();
|
||||
let middle_path = work_dir.join("middle.mp4").to_string_lossy().to_string();
|
||||
let tail_path = work_dir.join("tail.mp4").to_string_lossy().to_string();
|
||||
let filelist_path = work_dir.join("filelist.txt").to_string_lossy().to_string();
|
||||
|
||||
// head
|
||||
{
|
||||
let cmd = commands::build_smart_cut_head(input, &head_path, range.start, first_kf, has_audio);
|
||||
let ffmpeg = ffmpeg.to_string();
|
||||
let jid = job_id.to_string();
|
||||
let dur = first_kf - range.start;
|
||||
let app = app.clone();
|
||||
let jobs = jobs.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
runner::run_ffmpeg(&ffmpeg, &cmd, &jid, dur, "encoding", &app, &jobs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
}
|
||||
|
||||
// middle
|
||||
{
|
||||
let cmd = commands::build_smart_cut_middle(input, &middle_path, first_kf, last_kf);
|
||||
let ffmpeg = ffmpeg.to_string();
|
||||
tokio::task::spawn_blocking(move || runner::run_ffmpeg_silent(&ffmpeg, &cmd))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
}
|
||||
|
||||
// tail
|
||||
{
|
||||
let cmd = commands::build_smart_cut_tail(input, &tail_path, last_kf, range.end, has_audio);
|
||||
let ffmpeg = ffmpeg.to_string();
|
||||
let jid = job_id.to_string();
|
||||
let dur = range.end - last_kf;
|
||||
let app = app.clone();
|
||||
let jobs = jobs.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
runner::run_ffmpeg(&ffmpeg, &cmd, &jid, dur, "encoding", &app, &jobs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
}
|
||||
|
||||
// concat
|
||||
let filelist_content = format!(
|
||||
"file '{}'\nfile '{}'\nfile '{}'",
|
||||
head_path.replace('\\', "/"),
|
||||
middle_path.replace('\\', "/"),
|
||||
tail_path.replace('\\', "/"),
|
||||
);
|
||||
fs::write(&filelist_path, &filelist_content).map_err(|e| e.to_string())?;
|
||||
|
||||
{
|
||||
let cmd = commands::build_concat(&filelist_path, output);
|
||||
let ffmpeg = ffmpeg.to_string();
|
||||
tokio::task::spawn_blocking(move || runner::run_ffmpeg_silent(&ffmpeg, &cmd))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
}
|
||||
|
||||
// cleanup temp
|
||||
let _ = fs::remove_dir_all(&work_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_job(job_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let result = runner::cancel_job(&job_id, &state.jobs);
|
||||
recovery::delete_job_info(&config::temp_dir());
|
||||
result
|
||||
}
|
||||
422
src-tauri/src/commands/utility.rs
Normal file
422
src-tauri/src/commands/utility.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::config;
|
||||
use crate::ffmpeg::discovery;
|
||||
use crate::recovery;
|
||||
use crate::types::*;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_stream_url_cmd(
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let port = state.stream_port.lock().map_err(|e| e.to_string())?;
|
||||
Ok(format!("http://127.0.0.1:{}/{}", *port, percent_encoding::percent_encode(
|
||||
path.as_bytes(),
|
||||
percent_encoding::NON_ALPHANUMERIC
|
||||
)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_stream_port_cmd(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<u16, String> {
|
||||
let port = state.stream_port.lock().map_err(|e| e.to_string())?;
|
||||
Ok(*port)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_ffmpeg(state: State<'_, AppState>) -> Result<FFmpegStatus, String> {
|
||||
let override_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
|
||||
let status = tokio::task::spawn_blocking(move || {
|
||||
discovery::find_ffmpeg(override_path.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if status.found {
|
||||
if let Some(ref path) = status.path {
|
||||
let mut cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path = Some(path.clone());
|
||||
let _ = config::save_config(&cfg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_in_explorer(path: String) -> Result<(), String> {
|
||||
let p = PathBuf::from(&path);
|
||||
let dir = if p.is_dir() {
|
||||
p
|
||||
} else {
|
||||
p.parent().map(|d| d.to_path_buf()).unwrap_or(p)
|
||||
};
|
||||
|
||||
std::process::Command::new("explorer")
|
||||
.arg(dir.to_string_lossy().to_string())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open explorer: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
Ok(cfg.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_config_cmd(
|
||||
new_config: AppConfig,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
config::save_config(&new_config)?;
|
||||
let mut cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
*cfg = new_config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_output_path(
|
||||
input: String,
|
||||
mode: String,
|
||||
container: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let default_dir = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.default_output_dir.clone()
|
||||
};
|
||||
|
||||
let input_path = PathBuf::from(&input);
|
||||
let stem = input_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("output");
|
||||
|
||||
let ext = match container.to_lowercase().as_str() {
|
||||
"mkv" => "mkv",
|
||||
"webm" => "webm",
|
||||
"mov" => "mov",
|
||||
"avi" => "avi",
|
||||
"ts" => "ts",
|
||||
_ => "mp4",
|
||||
};
|
||||
|
||||
let suffix = match mode.as_str() {
|
||||
"compress" => "compressed",
|
||||
"trim" => "trimmed",
|
||||
"trimcomp" => "trimcomp",
|
||||
_ => "output",
|
||||
};
|
||||
|
||||
let timestamp = chrono_free_timestamp();
|
||||
|
||||
let filename = format!("{}_{}_{}.{}", stem, suffix, timestamp, ext);
|
||||
|
||||
let dir = match default_dir {
|
||||
Some(d) if !d.is_empty() => PathBuf::from(d),
|
||||
_ => input_path
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from(".")),
|
||||
};
|
||||
|
||||
let out = dir.join(&filename);
|
||||
Ok(out.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn chrono_free_timestamp() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = now.as_secs();
|
||||
|
||||
let days = secs / 86400;
|
||||
let time_of_day = secs % 86400;
|
||||
let hours = time_of_day / 3600;
|
||||
let minutes = (time_of_day % 3600) / 60;
|
||||
|
||||
// days since epoch to Y/M/D - good enough approximation
|
||||
let mut y = 1970i64;
|
||||
let mut remaining = days as i64;
|
||||
|
||||
loop {
|
||||
let days_in_year = if is_leap(y) { 366 } else { 365 };
|
||||
if remaining < days_in_year {
|
||||
break;
|
||||
}
|
||||
remaining -= days_in_year;
|
||||
y += 1;
|
||||
}
|
||||
|
||||
let month_days = if is_leap(y) {
|
||||
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
} else {
|
||||
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
};
|
||||
|
||||
let mut m = 1u32;
|
||||
for &md in &month_days {
|
||||
if remaining < md {
|
||||
break;
|
||||
}
|
||||
remaining -= md;
|
||||
m += 1;
|
||||
}
|
||||
let d = remaining + 1;
|
||||
|
||||
format!("{}{:02}{:02}_{:02}{:02}", y, m, d, hours, minutes)
|
||||
}
|
||||
|
||||
fn is_leap(y: i64) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn init_app(state: State<'_, AppState>) -> Result<FFmpegStatus, String> {
|
||||
let override_path = {
|
||||
let cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path.clone()
|
||||
};
|
||||
|
||||
let status = tokio::task::spawn_blocking(move || {
|
||||
discovery::find_ffmpeg(override_path.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if status.found {
|
||||
if let Some(ref path) = status.path {
|
||||
let mut cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path = Some(path.clone());
|
||||
let _ = config::save_config(&cfg);
|
||||
}
|
||||
|
||||
let ffmpeg = status.path.clone().unwrap_or_else(|| "ffmpeg".into());
|
||||
let hw = tokio::task::spawn_blocking(move || {
|
||||
discovery::detect_hardware_encoders(&ffmpeg)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut hw_state = state.hw_info.lock().map_err(|e| e.to_string())?;
|
||||
*hw_state = Some(hw);
|
||||
}
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_ffmpeg(app_handle: tauri::AppHandle, state: State<'_, AppState>) -> Result<FFmpegStatus, String> {
|
||||
use tauri::Emitter;
|
||||
|
||||
let exe_dir = std::env::current_exe()
|
||||
.map_err(|e| e.to_string())?
|
||||
.parent()
|
||||
.ok_or("Could not find exe directory")?
|
||||
.to_path_buf();
|
||||
|
||||
let zip_path = exe_dir.join("ffmpeg-download.zip");
|
||||
let extract_dir = exe_dir.join("ffmpeg-download");
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
|
||||
let url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip";
|
||||
let expected_size: u64 = 90_000_000; // ~90MB estimate
|
||||
|
||||
// emit starting
|
||||
let _ = app_handle.emit("ffmpeg-download-progress", serde_json::json!({
|
||||
"phase": "downloading", "percent": 0, "message": "Connecting..."
|
||||
}));
|
||||
|
||||
// start curl download in background (curl ships with Windows 10+, gives us progress)
|
||||
let child = Command::new("curl")
|
||||
.args(["-L", "-o", &zip_path.to_string_lossy(), "--progress-bar", url])
|
||||
.creation_flags(0x08000000) // CREATE_NO_WINDOW
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|_| {
|
||||
// fallback to PowerShell if curl not available
|
||||
"curl not found".to_string()
|
||||
});
|
||||
|
||||
let use_curl = child.is_ok();
|
||||
|
||||
let done_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
|
||||
if use_curl {
|
||||
let mut child = child.unwrap();
|
||||
let zip_clone = zip_path.clone();
|
||||
let handle_clone = app_handle.clone();
|
||||
let flag = done_flag.clone();
|
||||
|
||||
let poll_thread = std::thread::spawn(move || {
|
||||
while !flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if let Ok(meta) = std::fs::metadata(&zip_clone) {
|
||||
let pct = ((meta.len() as f64 / expected_size as f64) * 100.0).min(99.0);
|
||||
let mb = meta.len() as f64 / 1_048_576.0;
|
||||
let _ = handle_clone.emit("ffmpeg-download-progress", serde_json::json!({
|
||||
"phase": "downloading",
|
||||
"percent": pct as u32,
|
||||
"message": format!("{:.1} MB downloaded", mb)
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Download failed: {}", e))?;
|
||||
done_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = poll_thread.join();
|
||||
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
return Err("Download failed. Check your internet connection.".into());
|
||||
}
|
||||
} else {
|
||||
let _ = app_handle.emit("ffmpeg-download-progress", serde_json::json!({
|
||||
"phase": "downloading", "percent": 0, "message": "Downloading..."
|
||||
}));
|
||||
|
||||
let dl_status = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile", "-NonInteractive", "-Command",
|
||||
&format!(
|
||||
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '{}' -OutFile '{}'",
|
||||
url, zip_path.to_string_lossy()
|
||||
)
|
||||
])
|
||||
.creation_flags(0x08000000)
|
||||
.status()
|
||||
.map_err(|e| format!("Download failed: {}", e))?;
|
||||
|
||||
if !dl_status.success() {
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
return Err("Download failed. Check your internet connection.".into());
|
||||
}
|
||||
}
|
||||
|
||||
// extract - use tar (faster than PowerShell Expand-Archive, ships with Windows 10+)
|
||||
let _ = app_handle.emit("ffmpeg-download-progress", serde_json::json!({
|
||||
"phase": "extracting", "percent": 100, "message": "Extracting..."
|
||||
}));
|
||||
|
||||
let ex_status = Command::new("tar")
|
||||
.args(["-xf", &zip_path.to_string_lossy(), "-C", &exe_dir.to_string_lossy()])
|
||||
.creation_flags(0x08000000)
|
||||
.status()
|
||||
.or_else(|_| {
|
||||
// fallback to PowerShell if tar fails
|
||||
Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile", "-NonInteractive", "-Command",
|
||||
&format!(
|
||||
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
|
||||
zip_path.to_string_lossy(),
|
||||
extract_dir.to_string_lossy()
|
||||
)
|
||||
])
|
||||
.creation_flags(0x08000000)
|
||||
.status()
|
||||
})
|
||||
.map_err(|e| format!("Extraction failed: {}", e))?;
|
||||
|
||||
if !ex_status.success() {
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
let _ = std::fs::remove_dir_all(&extract_dir);
|
||||
return Err("Could not extract the archive.".into());
|
||||
}
|
||||
|
||||
let _ = app_handle.emit("ffmpeg-download-progress", serde_json::json!({
|
||||
"phase": "installing", "percent": 100, "message": "Installing..."
|
||||
}));
|
||||
|
||||
// find ffmpeg.exe - tar extracts to exe_dir, PowerShell to extract_dir
|
||||
let mut ffmpeg_src = None;
|
||||
let mut ffprobe_src = None;
|
||||
for search_dir in [&exe_dir, &extract_dir] {
|
||||
if let Ok(entries) = std::fs::read_dir(search_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// check nested bin/ directory (zip has one top-level folder)
|
||||
let bin_dir = path.join("bin");
|
||||
if bin_dir.is_dir() {
|
||||
let ff = bin_dir.join("ffmpeg.exe");
|
||||
let fp = bin_dir.join("ffprobe.exe");
|
||||
if ff.exists() { ffmpeg_src = Some(ff); }
|
||||
if fp.exists() { ffprobe_src = Some(fp); }
|
||||
if ffmpeg_src.is_some() { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if ffmpeg_src.is_some() { break; }
|
||||
}
|
||||
|
||||
let ffmpeg_src = ffmpeg_src.ok_or("Could not find ffmpeg.exe in the download.")?;
|
||||
let ffprobe_src = ffprobe_src.ok_or("Could not find ffprobe.exe in the download.")?;
|
||||
|
||||
let ffmpeg_dest = exe_dir.join("ffmpeg.exe");
|
||||
let ffprobe_dest = exe_dir.join("ffprobe.exe");
|
||||
|
||||
std::fs::copy(&ffmpeg_src, &ffmpeg_dest).map_err(|e| format!("Could not copy ffmpeg: {}", e))?;
|
||||
std::fs::copy(&ffprobe_src, &ffprobe_dest).map_err(|e| format!("Could not copy ffprobe: {}", e))?;
|
||||
|
||||
// cleanup zip and any extracted folders
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
let _ = std::fs::remove_dir_all(&extract_dir);
|
||||
// also clean up the tar-extracted folder in exe_dir (matches ffmpeg-master-*)
|
||||
if let Ok(entries) = std::fs::read_dir(&exe_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("ffmpeg-master-") && entry.path().is_dir() {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verify and update config
|
||||
let path_str = ffmpeg_dest.to_string_lossy().to_string();
|
||||
let version = discovery::parse_version(&PathBuf::from(&path_str));
|
||||
|
||||
let status = FFmpegStatus {
|
||||
found: true,
|
||||
path: Some(path_str.clone()),
|
||||
version,
|
||||
};
|
||||
|
||||
{
|
||||
let mut cfg = state.config.lock().map_err(|e| e.to_string())?;
|
||||
cfg.ffmpeg_path = Some(path_str);
|
||||
let _ = config::save_config(&cfg);
|
||||
}
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_recovery() -> Result<Option<recovery::InterruptedJob>, String> {
|
||||
let temp = config::temp_dir();
|
||||
Ok(recovery::check_interrupted_job(&temp))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_recovery() -> Result<(), String> {
|
||||
let temp = config::temp_dir();
|
||||
recovery::cleanup_orphaned_temps(&temp);
|
||||
recovery::delete_job_info(&temp);
|
||||
Ok(())
|
||||
}
|
||||
36
src-tauri/src/config.rs
Normal file
36
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::types::AppConfig;
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
exe.parent().unwrap_or(&PathBuf::from(".")).join("config.json")
|
||||
}
|
||||
|
||||
pub fn load_config() -> AppConfig {
|
||||
let path = config_path();
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
||||
Err(_) => AppConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(config: &AppConfig) -> Result<(), String> {
|
||||
let path = config_path();
|
||||
let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, json).map_err(|e| format!("Failed to write config: {}", e))
|
||||
}
|
||||
|
||||
pub fn temp_dir() -> PathBuf {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let dir = exe.parent().unwrap_or(&PathBuf::from(".")).join("temp");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
pub fn ensure_temp_subdir(subdir: &str) -> PathBuf {
|
||||
let dir = temp_dir().join(subdir);
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
453
src-tauri/src/ffmpeg/commands.rs
Normal file
453
src-tauri/src/ffmpeg/commands.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
use crate::types::*;
|
||||
|
||||
pub struct FfmpegCommand {
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
impl FfmpegCommand {
|
||||
fn new() -> Self {
|
||||
Self { args: vec!["-y".into()] }
|
||||
}
|
||||
|
||||
fn arg(mut self, a: &str) -> Self {
|
||||
self.args.push(a.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn args(mut self, a: &[&str]) -> Self {
|
||||
self.args.extend(a.iter().map(|s| s.to_string()));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_compress_pass1(
|
||||
input: &str,
|
||||
settings: &CompressSettings,
|
||||
bitrate_kbps: u32,
|
||||
passlog_prefix: &str,
|
||||
) -> FfmpegCommand {
|
||||
let encoder = software_encoder(&settings.video_codec);
|
||||
|
||||
let mut cmd = FfmpegCommand::new()
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c:v").arg(&encoder)
|
||||
.arg("-preset").arg(&sw_preset(&settings.speed_preset, &settings.video_codec))
|
||||
.arg("-b:v").arg(&format!("{}k", bitrate_kbps))
|
||||
.arg("-pass").arg("1")
|
||||
.arg("-passlogfile").arg(passlog_prefix)
|
||||
.arg("-an")
|
||||
.arg("-f").arg("null");
|
||||
|
||||
cmd = apply_resolution(cmd, &settings.resolution);
|
||||
cmd.arg("NUL")
|
||||
}
|
||||
|
||||
pub fn build_compress_pass2(
|
||||
input: &str,
|
||||
output: &str,
|
||||
settings: &CompressSettings,
|
||||
bitrate_kbps: u32,
|
||||
passlog_prefix: &str,
|
||||
trim: Option<&TrimRange>,
|
||||
has_audio: bool,
|
||||
) -> FfmpegCommand {
|
||||
let encoder = software_encoder(&settings.video_codec);
|
||||
|
||||
let mut cmd = FfmpegCommand::new();
|
||||
|
||||
if let Some(t) = trim {
|
||||
cmd = cmd
|
||||
.arg("-ss").arg(&format!("{}", t.start))
|
||||
.arg("-to").arg(&format!("{}", t.end));
|
||||
}
|
||||
|
||||
cmd = cmd
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c:v").arg(&encoder)
|
||||
.arg("-preset").arg(&sw_preset(&settings.speed_preset, &settings.video_codec))
|
||||
.arg("-b:v").arg(&format!("{}k", bitrate_kbps))
|
||||
.arg("-pass").arg("2")
|
||||
.arg("-passlogfile").arg(passlog_prefix);
|
||||
|
||||
if has_audio {
|
||||
cmd = apply_audio(cmd, &settings.audio_codec, settings.audio_bitrate);
|
||||
} else {
|
||||
cmd = cmd.arg("-an");
|
||||
}
|
||||
cmd = apply_resolution(cmd, &settings.resolution);
|
||||
cmd = apply_container_flags(cmd, &settings.container);
|
||||
cmd.arg("-progress").arg("pipe:1").arg(output)
|
||||
}
|
||||
|
||||
pub fn build_compress_hw(
|
||||
input: &str,
|
||||
output: &str,
|
||||
settings: &CompressSettings,
|
||||
bitrate_kbps: u32,
|
||||
hw_encoder: &str,
|
||||
trim: Option<&TrimRange>,
|
||||
has_audio: bool,
|
||||
) -> FfmpegCommand {
|
||||
let mut cmd = FfmpegCommand::new();
|
||||
|
||||
if let Some(t) = trim {
|
||||
cmd = cmd
|
||||
.arg("-ss").arg(&format!("{}", t.start))
|
||||
.arg("-to").arg(&format!("{}", t.end));
|
||||
}
|
||||
|
||||
let maxrate = (bitrate_kbps as f64 * 1.5) as u32;
|
||||
let bufsize = bitrate_kbps * 2;
|
||||
|
||||
cmd = cmd
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c:v").arg(hw_encoder)
|
||||
.arg("-preset").arg(&hw_preset(&settings.speed_preset, hw_encoder))
|
||||
.arg("-b:v").arg(&format!("{}k", bitrate_kbps))
|
||||
.arg("-maxrate").arg(&format!("{}k", maxrate))
|
||||
.arg("-bufsize").arg(&format!("{}k", bufsize));
|
||||
|
||||
if has_audio {
|
||||
cmd = apply_audio(cmd, &settings.audio_codec, settings.audio_bitrate);
|
||||
} else {
|
||||
cmd = cmd.arg("-an");
|
||||
}
|
||||
cmd = apply_resolution(cmd, &settings.resolution);
|
||||
cmd = apply_container_flags(cmd, &settings.container);
|
||||
cmd.arg("-progress").arg("pipe:1").arg(output)
|
||||
}
|
||||
|
||||
pub fn build_crf_command(
|
||||
input: &str,
|
||||
output: &str,
|
||||
settings: &CompressSettings,
|
||||
crf_value: u32,
|
||||
encoder: &str,
|
||||
trim: Option<&TrimRange>,
|
||||
has_audio: bool,
|
||||
) -> FfmpegCommand {
|
||||
let mut cmd = FfmpegCommand::new();
|
||||
|
||||
if let Some(t) = trim {
|
||||
cmd = cmd
|
||||
.arg("-ss").arg(&format!("{}", t.start))
|
||||
.arg("-to").arg(&format!("{}", t.end));
|
||||
}
|
||||
|
||||
cmd = cmd
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c:v").arg(encoder);
|
||||
|
||||
if encoder.contains("svtav1") || encoder == "libsvtav1" {
|
||||
cmd = cmd.arg("-crf").arg(&format!("{}", crf_value));
|
||||
} else if encoder.contains("nvenc") || encoder.contains("amf") || encoder.contains("qsv") {
|
||||
cmd = cmd.arg("-cq").arg(&format!("{}", crf_value));
|
||||
} else {
|
||||
cmd = cmd.arg("-crf").arg(&format!("{}", crf_value));
|
||||
}
|
||||
|
||||
cmd = cmd.arg("-preset").arg(&resolve_preset(&settings.speed_preset, encoder));
|
||||
if has_audio {
|
||||
cmd = apply_audio(cmd, &settings.audio_codec, settings.audio_bitrate);
|
||||
} else {
|
||||
cmd = cmd.arg("-an");
|
||||
}
|
||||
cmd = apply_resolution(cmd, &settings.resolution);
|
||||
cmd = apply_container_flags(cmd, &settings.container);
|
||||
cmd.arg("-progress").arg("pipe:1").arg(output)
|
||||
}
|
||||
|
||||
pub fn build_bitrate_command(
|
||||
input: &str,
|
||||
output: &str,
|
||||
settings: &CompressSettings,
|
||||
bitrate_kbps: u32,
|
||||
encoder: &str,
|
||||
trim: Option<&TrimRange>,
|
||||
has_audio: bool,
|
||||
) -> FfmpegCommand {
|
||||
let mut cmd = FfmpegCommand::new();
|
||||
|
||||
if let Some(t) = trim {
|
||||
cmd = cmd
|
||||
.arg("-ss").arg(&format!("{}", t.start))
|
||||
.arg("-to").arg(&format!("{}", t.end));
|
||||
}
|
||||
|
||||
cmd = cmd
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c:v").arg(encoder)
|
||||
.arg("-b:v").arg(&format!("{}k", bitrate_kbps))
|
||||
.arg("-preset").arg(&resolve_preset(&settings.speed_preset, encoder));
|
||||
|
||||
if has_audio {
|
||||
cmd = apply_audio(cmd, &settings.audio_codec, settings.audio_bitrate);
|
||||
} else {
|
||||
cmd = cmd.arg("-an");
|
||||
}
|
||||
cmd = apply_resolution(cmd, &settings.resolution);
|
||||
cmd = apply_container_flags(cmd, &settings.container);
|
||||
cmd.arg("-progress").arg("pipe:1").arg(output)
|
||||
}
|
||||
|
||||
pub fn build_trim_keyframe(
|
||||
input: &str,
|
||||
output: &str,
|
||||
range: &TrimRange,
|
||||
strip_audio: bool,
|
||||
) -> FfmpegCommand {
|
||||
let mut cmd = FfmpegCommand::new()
|
||||
.arg("-ss").arg(&format!("{}", range.start))
|
||||
.arg("-to").arg(&format!("{}", range.end))
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c").arg("copy")
|
||||
.arg("-avoid_negative_ts").arg("make_zero");
|
||||
|
||||
if strip_audio {
|
||||
cmd = cmd.arg("-an");
|
||||
}
|
||||
|
||||
cmd.arg(output)
|
||||
}
|
||||
|
||||
pub fn build_smart_cut_head(
|
||||
input: &str,
|
||||
output: &str,
|
||||
start: f64,
|
||||
first_keyframe: f64,
|
||||
has_audio: bool,
|
||||
) -> FfmpegCommand {
|
||||
let mut cmd = FfmpegCommand::new()
|
||||
.arg("-ss").arg(&format!("{}", start))
|
||||
.arg("-to").arg(&format!("{}", first_keyframe))
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c:v").arg("libx264")
|
||||
.arg("-crf").arg("18");
|
||||
|
||||
if has_audio {
|
||||
cmd = cmd.arg("-c:a").arg("aac");
|
||||
} else {
|
||||
cmd = cmd.arg("-an");
|
||||
}
|
||||
|
||||
cmd.arg("-progress").arg("pipe:1").arg(output)
|
||||
}
|
||||
|
||||
pub fn build_smart_cut_middle(
|
||||
input: &str,
|
||||
output: &str,
|
||||
first_keyframe: f64,
|
||||
last_keyframe: f64,
|
||||
) -> FfmpegCommand {
|
||||
FfmpegCommand::new()
|
||||
.arg("-ss").arg(&format!("{}", first_keyframe))
|
||||
.arg("-to").arg(&format!("{}", last_keyframe))
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c").arg("copy")
|
||||
.arg("-avoid_negative_ts").arg("make_zero")
|
||||
.arg(output)
|
||||
}
|
||||
|
||||
pub fn build_smart_cut_tail(
|
||||
input: &str,
|
||||
output: &str,
|
||||
last_keyframe: f64,
|
||||
end: f64,
|
||||
has_audio: bool,
|
||||
) -> FfmpegCommand {
|
||||
let mut cmd = FfmpegCommand::new()
|
||||
.arg("-ss").arg(&format!("{}", last_keyframe))
|
||||
.arg("-to").arg(&format!("{}", end))
|
||||
.arg("-i").arg(input)
|
||||
.arg("-c:v").arg("libx264")
|
||||
.arg("-crf").arg("18");
|
||||
|
||||
if has_audio {
|
||||
cmd = cmd.arg("-c:a").arg("aac");
|
||||
} else {
|
||||
cmd = cmd.arg("-an");
|
||||
}
|
||||
|
||||
cmd.arg("-progress").arg("pipe:1").arg(output)
|
||||
}
|
||||
|
||||
pub fn build_concat(filelist: &str, output: &str) -> FfmpegCommand {
|
||||
FfmpegCommand::new()
|
||||
.arg("-f").arg("concat")
|
||||
.arg("-safe").arg("0")
|
||||
.arg("-i").arg(filelist)
|
||||
.arg("-c").arg("copy")
|
||||
.arg(output)
|
||||
}
|
||||
|
||||
pub fn build_thumbnail(
|
||||
input: &str,
|
||||
output_pattern: &str,
|
||||
count: u32,
|
||||
duration: f64,
|
||||
) -> FfmpegCommand {
|
||||
let interval = if count > 1 {
|
||||
duration / (count as f64)
|
||||
} else {
|
||||
duration / 2.0
|
||||
};
|
||||
|
||||
FfmpegCommand::new()
|
||||
.arg("-i").arg(input)
|
||||
.args(&["-vf", &format!("fps=1/{:.2},scale=240:-1", interval)])
|
||||
.arg("-q:v").arg("8")
|
||||
.arg(output_pattern)
|
||||
}
|
||||
|
||||
pub fn build_preview(input: &str, output: &str, codec: &str) -> FfmpegCommand {
|
||||
let mut cmd = FfmpegCommand::new();
|
||||
cmd = cmd.arg("-i").arg(input);
|
||||
|
||||
// if the source is already h264, just remux to mp4 (instant, no re-encoding)
|
||||
let dominated = codec.to_lowercase();
|
||||
if dominated == "h264" || dominated == "h265" || dominated == "hevc" {
|
||||
cmd = cmd.arg("-c:v").arg("copy")
|
||||
.arg("-c:a").arg("copy");
|
||||
} else {
|
||||
// transcode only first 30 seconds for preview
|
||||
cmd = cmd.arg("-t").arg("30")
|
||||
.arg("-c:v").arg("libx264")
|
||||
.arg("-preset").arg("ultrafast")
|
||||
.arg("-crf").arg("28")
|
||||
.args(&["-vf", "scale=640:-2"])
|
||||
.arg("-c:a").arg("aac")
|
||||
.arg("-b:a").arg("64k");
|
||||
}
|
||||
|
||||
cmd.args(&["-movflags", "+faststart"]).arg(output)
|
||||
}
|
||||
|
||||
pub fn calculate_bitrate(target_mb: f64, duration: f64, audio_bitrate_kbps: u32) -> u32 {
|
||||
let target_bits = target_mb * 1024.0 * 1024.0 * 8.0;
|
||||
let audio_bits = audio_bitrate_kbps as f64 * 1000.0 * duration;
|
||||
let video_bits = target_bits - audio_bits;
|
||||
let kbps = (video_bits / duration / 1000.0).max(10.0);
|
||||
kbps as u32
|
||||
}
|
||||
|
||||
pub fn select_encoder(
|
||||
codec: &VideoCodec,
|
||||
hw_accel: &HwAccelMode,
|
||||
hw_info: &HardwareInfo,
|
||||
) -> String {
|
||||
match hw_accel {
|
||||
HwAccelMode::ForceCPU => software_encoder(codec),
|
||||
HwAccelMode::ForceGPU => {
|
||||
hw_encoder(codec, hw_info).unwrap_or_else(|| software_encoder(codec))
|
||||
}
|
||||
HwAccelMode::Auto => {
|
||||
hw_encoder(codec, hw_info).unwrap_or_else(|| software_encoder(codec))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_hw_encoder(encoder: &str) -> bool {
|
||||
encoder.contains("nvenc")
|
||||
|| encoder.contains("qsv")
|
||||
|| encoder.contains("amf")
|
||||
}
|
||||
|
||||
fn software_encoder(codec: &VideoCodec) -> String {
|
||||
match codec {
|
||||
VideoCodec::H264 => "libx264".into(),
|
||||
VideoCodec::HEVC => "libx265".into(),
|
||||
VideoCodec::AV1 => "libsvtav1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hw_encoder(codec: &VideoCodec, hw: &HardwareInfo) -> Option<String> {
|
||||
let target = match codec {
|
||||
VideoCodec::H264 => "h264",
|
||||
VideoCodec::HEVC => "hevc",
|
||||
VideoCodec::AV1 => "av1",
|
||||
};
|
||||
|
||||
// prefer nvenc, then amf, then qsv
|
||||
for name in &hw.nvenc_codecs {
|
||||
if name.starts_with(target) {
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
for name in &hw.amf_codecs {
|
||||
if name.starts_with(target) {
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
for name in &hw.qsv_codecs {
|
||||
if name.starts_with(target) {
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn apply_audio(cmd: FfmpegCommand, codec: &AudioCodec, bitrate: u32) -> FfmpegCommand {
|
||||
match codec {
|
||||
AudioCodec::None => cmd.arg("-an"),
|
||||
AudioCodec::AAC => cmd
|
||||
.arg("-c:a").arg("aac")
|
||||
.arg("-b:a").arg(&format!("{}k", bitrate)),
|
||||
AudioCodec::Opus => cmd
|
||||
.arg("-c:a").arg("libopus")
|
||||
.arg("-b:a").arg(&format!("{}k", bitrate)),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_resolution(cmd: FfmpegCommand, res: &Resolution) -> FfmpegCommand {
|
||||
match res {
|
||||
Resolution::Original => cmd,
|
||||
Resolution::P720 => cmd.args(&["-vf", "scale=-2:720"]),
|
||||
Resolution::P1080 => cmd.args(&["-vf", "scale=-2:1080"]),
|
||||
Resolution::P1440 => cmd.args(&["-vf", "scale=-2:1440"]),
|
||||
Resolution::P4K => cmd.args(&["-vf", "scale=-2:2160"]),
|
||||
Resolution::Custom { width, height } => {
|
||||
cmd.args(&["-vf", &format!("scale={}:{}", width, height)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_container_flags(cmd: FfmpegCommand, container: &Container) -> FfmpegCommand {
|
||||
match container {
|
||||
Container::MP4 | Container::MOV => cmd.args(&["-movflags", "+faststart"]),
|
||||
Container::MKV | Container::WebM | Container::AVI | Container::TS => cmd,
|
||||
}
|
||||
}
|
||||
|
||||
fn sw_preset(preset: &str, codec: &VideoCodec) -> String {
|
||||
if preset.is_empty() || preset == "medium" {
|
||||
return match codec {
|
||||
VideoCodec::AV1 => "6".into(),
|
||||
_ => "medium".into(),
|
||||
};
|
||||
}
|
||||
preset.to_string()
|
||||
}
|
||||
|
||||
fn hw_preset(preset: &str, encoder: &str) -> String {
|
||||
if preset.is_empty() || preset == "medium" {
|
||||
if encoder.contains("nvenc") {
|
||||
return "p5".into();
|
||||
}
|
||||
return "medium".into();
|
||||
}
|
||||
preset.to_string()
|
||||
}
|
||||
|
||||
fn resolve_preset(preset: &str, encoder: &str) -> String {
|
||||
if is_hw_encoder(encoder) {
|
||||
hw_preset(preset, encoder)
|
||||
} else {
|
||||
if preset.is_empty() || preset == "medium" {
|
||||
if encoder == "libsvtav1" {
|
||||
return "6".into();
|
||||
}
|
||||
return "medium".into();
|
||||
}
|
||||
preset.to_string()
|
||||
}
|
||||
}
|
||||
190
src-tauri/src/ffmpeg/discovery.rs
Normal file
190
src-tauri/src/ffmpeg/discovery.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::types::{FFmpegStatus, HardwareInfo};
|
||||
|
||||
pub fn find_ffmpeg(override_path: Option<&str>) -> FFmpegStatus {
|
||||
if let Some(p) = override_path {
|
||||
let path = PathBuf::from(p);
|
||||
if path.exists() {
|
||||
if let Some(ver) = parse_version(&path) {
|
||||
return FFmpegStatus {
|
||||
found: true,
|
||||
path: Some(p.to_string()),
|
||||
version: Some(ver),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(status) = try_from_path("ffmpeg") {
|
||||
return status;
|
||||
}
|
||||
|
||||
let candidates = get_search_paths();
|
||||
for dir in candidates {
|
||||
let bin = dir.join("ffmpeg.exe");
|
||||
if bin.exists() {
|
||||
if let Some(status) = try_from_path(bin.to_str().unwrap_or_default()) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FFmpegStatus {
|
||||
found: false,
|
||||
path: None,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_search_paths() -> Vec<PathBuf> {
|
||||
let mut paths = vec![
|
||||
PathBuf::from(r"C:\ffmpeg\bin"),
|
||||
PathBuf::from(r"C:\ffmpeg"),
|
||||
];
|
||||
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
paths.push(PathBuf::from(&local).join("ffmpeg"));
|
||||
paths.push(PathBuf::from(&local).join("ffmpeg").join("bin"));
|
||||
}
|
||||
|
||||
if let Ok(pf) = std::env::var("PROGRAMFILES") {
|
||||
paths.push(PathBuf::from(&pf).join("ffmpeg"));
|
||||
paths.push(PathBuf::from(&pf).join("ffmpeg").join("bin"));
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
if let Some(parent) = exe.parent() {
|
||||
paths.push(parent.to_path_buf());
|
||||
paths.push(parent.join("ffmpeg"));
|
||||
paths.push(parent.join("ffmpeg").join("bin"));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn try_from_path(cmd: &str) -> Option<FFmpegStatus> {
|
||||
let path = resolve_path(cmd)?;
|
||||
let ver = parse_version(&path)?;
|
||||
Some(FFmpegStatus {
|
||||
found: true,
|
||||
path: Some(path.to_string_lossy().to_string()),
|
||||
version: Some(ver),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_path(cmd: &str) -> Option<PathBuf> {
|
||||
let p = PathBuf::from(cmd);
|
||||
if p.is_absolute() && p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
let output = Command::new("where").arg(cmd).output().ok()?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = stdout.lines().next()?;
|
||||
let resolved = PathBuf::from(first_line.trim());
|
||||
if resolved.exists() {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn parse_version(ffmpeg_path: &PathBuf) -> Option<String> {
|
||||
let output = Command::new(ffmpeg_path).arg("-version").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = stdout.lines().next()?;
|
||||
// "ffmpeg version 7.1 Copyright ..." or "ffmpeg version N-123456-g..."
|
||||
let parts: Vec<&str> = first_line.split_whitespace().collect();
|
||||
if parts.len() >= 3 && parts[0] == "ffmpeg" && parts[1] == "version" {
|
||||
let ver = parts[2];
|
||||
// strip trailing dash or git hash portions for cleanliness
|
||||
let clean = ver.split('-').next().unwrap_or(ver);
|
||||
Some(clean.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ffprobe_path(ffmpeg_path: &str) -> String {
|
||||
let p = PathBuf::from(ffmpeg_path);
|
||||
if let Some(parent) = p.parent() {
|
||||
let probe = parent.join("ffprobe.exe");
|
||||
if probe.exists() {
|
||||
eprintln!("[cinch-rs] ffprobe_path: found {}", probe.display());
|
||||
return probe.to_string_lossy().to_string();
|
||||
}
|
||||
let probe2 = parent.join("ffprobe");
|
||||
if probe2.exists() {
|
||||
eprintln!("[cinch-rs] ffprobe_path: found {}", probe2.display());
|
||||
return probe2.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
eprintln!("[cinch-rs] ffprobe_path: falling back to 'ffprobe'");
|
||||
"ffprobe".to_string()
|
||||
}
|
||||
|
||||
pub fn detect_hardware_encoders(ffmpeg_path: &str) -> HardwareInfo {
|
||||
let mut info = HardwareInfo {
|
||||
nvenc: false,
|
||||
qsv: false,
|
||||
amf: false,
|
||||
nvenc_codecs: Vec::new(),
|
||||
qsv_codecs: Vec::new(),
|
||||
amf_codecs: Vec::new(),
|
||||
};
|
||||
|
||||
let output = Command::new(ffmpeg_path)
|
||||
.args(["-hide_banner", "-encoders"])
|
||||
.output();
|
||||
|
||||
let output = match output {
|
||||
Ok(o) => o,
|
||||
Err(_) => return info,
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains("nvenc") {
|
||||
let codec = extract_encoder_name(trimmed);
|
||||
if !codec.is_empty() {
|
||||
info.nvenc = true;
|
||||
info.nvenc_codecs.push(codec);
|
||||
}
|
||||
}
|
||||
if trimmed.contains("qsv") {
|
||||
let codec = extract_encoder_name(trimmed);
|
||||
if !codec.is_empty() {
|
||||
info.qsv = true;
|
||||
info.qsv_codecs.push(codec);
|
||||
}
|
||||
}
|
||||
if trimmed.contains("amf") {
|
||||
let codec = extract_encoder_name(trimmed);
|
||||
if !codec.is_empty() {
|
||||
info.amf = true;
|
||||
info.amf_codecs.push(codec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
fn extract_encoder_name(line: &str) -> String {
|
||||
// encoder lines look like: " V..... h264_nvenc NVIDIA NVENC H.264 encoder"
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let name = parts[1];
|
||||
if name.contains("nvenc") || name.contains("qsv") || name.contains("amf") {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
4
src-tauri/src/ffmpeg/mod.rs
Normal file
4
src-tauri/src/ffmpeg/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod commands;
|
||||
pub mod discovery;
|
||||
pub mod probe;
|
||||
pub mod runner;
|
||||
162
src-tauri/src/ffmpeg/probe.rs
Normal file
162
src-tauri/src/ffmpeg/probe.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use std::process::Command;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::ffmpeg::discovery::ffprobe_path;
|
||||
use crate::types::VideoInfo;
|
||||
|
||||
// Step 1: Fast metadata - no packet scanning, instant
|
||||
pub fn probe_metadata(input: &str, ffmpeg_path: &str) -> Result<VideoInfo, String> {
|
||||
let probe = ffprobe_path(ffmpeg_path);
|
||||
eprintln!("[cinch-rs] probe_metadata start: {} using {}", input, probe);
|
||||
|
||||
let output = Command::new(&probe)
|
||||
.args([
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
input,
|
||||
])
|
||||
.creation_flags(0x08000000)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run ffprobe: {}", e))?;
|
||||
|
||||
eprintln!("[cinch-rs] probe_metadata ffprobe returned, status: {}", output.status);
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Couldn't read this file. It may be corrupted or not a video. {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse ffprobe output: {}", e))?;
|
||||
|
||||
let streams = json["streams"].as_array().ok_or("No streams found")?;
|
||||
|
||||
let video_stream = streams
|
||||
.iter()
|
||||
.find(|s| s["codec_type"].as_str() == Some("video"))
|
||||
.ok_or("This file has no video stream.")?;
|
||||
|
||||
let width = video_stream["width"].as_u64().unwrap_or(0) as u32;
|
||||
let height = video_stream["height"].as_u64().unwrap_or(0) as u32;
|
||||
let video_codec = video_stream["codec_name"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let video_bitrate = video_stream["bit_rate"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let frame_rate = parse_frame_rate(
|
||||
video_stream["r_frame_rate"].as_str().unwrap_or("0/1"),
|
||||
);
|
||||
let avg_frame_rate = parse_frame_rate(
|
||||
video_stream["avg_frame_rate"].as_str().unwrap_or("0/1"),
|
||||
);
|
||||
let is_vfr = (frame_rate - avg_frame_rate).abs() > 0.5;
|
||||
|
||||
let format = &json["format"];
|
||||
let duration = format["duration"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
let file_size = format["size"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let container = format["format_name"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// audio from the same data - no extra call
|
||||
let audio_stream = streams
|
||||
.iter()
|
||||
.find(|s| s["codec_type"].as_str() == Some("audio"));
|
||||
let audio_codec = audio_stream
|
||||
.and_then(|s| s["codec_name"].as_str())
|
||||
.map(|s| s.to_string());
|
||||
let audio_bitrate = audio_stream
|
||||
.and_then(|s| s["bit_rate"].as_str())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
let audio_channels = audio_stream
|
||||
.and_then(|s| s["channels"].as_u64())
|
||||
.map(|c| c as u32);
|
||||
|
||||
Ok(VideoInfo {
|
||||
path: input.to_string(),
|
||||
file_size,
|
||||
duration,
|
||||
width,
|
||||
height,
|
||||
video_codec,
|
||||
video_bitrate,
|
||||
frame_rate,
|
||||
is_vfr,
|
||||
audio_codec,
|
||||
audio_bitrate,
|
||||
audio_channels,
|
||||
keyframe_times: Vec::new(), // filled in step 2
|
||||
container,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2: Keyframe extraction - uses -skip_frame nokey for speed
|
||||
pub fn extract_keyframes_fast(input: &str, ffmpeg_path: &str) -> Vec<f64> {
|
||||
let probe = ffprobe_path(ffmpeg_path);
|
||||
|
||||
// use -skip_frame nokey to only decode keyframes, MUCH faster than reading all packets
|
||||
let output = Command::new(&probe)
|
||||
.args([
|
||||
"-v", "quiet",
|
||||
"-select_streams", "v:0",
|
||||
"-skip_frame", "nokey",
|
||||
"-show_entries", "frame=pts_time",
|
||||
"-of", "csv=p=0",
|
||||
input,
|
||||
])
|
||||
.creation_flags(0x08000000)
|
||||
.output();
|
||||
|
||||
let output = match output {
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut times: Vec<f64> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse::<f64>().ok())
|
||||
.collect();
|
||||
|
||||
times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
times.dedup();
|
||||
times
|
||||
}
|
||||
|
||||
// Legacy compat - calls both steps
|
||||
pub fn probe_video(input: &str, ffmpeg_path: &str) -> Result<VideoInfo, String> {
|
||||
let mut info = probe_metadata(input, ffmpeg_path)?;
|
||||
info.keyframe_times = extract_keyframes_fast(input, ffmpeg_path);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn parse_frame_rate(s: &str) -> f64 {
|
||||
let parts: Vec<&str> = s.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
let num = parts[0].parse::<f64>().unwrap_or(0.0);
|
||||
let den = parts[1].parse::<f64>().unwrap_or(1.0);
|
||||
if den > 0.0 {
|
||||
return num / den;
|
||||
}
|
||||
}
|
||||
s.parse::<f64>().unwrap_or(0.0)
|
||||
}
|
||||
303
src-tauri/src/ffmpeg/runner.rs
Normal file
303
src-tauri/src/ffmpeg/runner.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use tauri::{Emitter, AppHandle};
|
||||
|
||||
use crate::config;
|
||||
use crate::ffmpeg::commands::{self, FfmpegCommand};
|
||||
use crate::types::*;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
pub type JobMap = Arc<Mutex<HashMap<String, Child>>>;
|
||||
|
||||
pub fn new_job_map() -> JobMap {
|
||||
Arc::new(Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
pub struct RunResult {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn spawn_hidden(cmd: &mut Command) -> std::io::Result<Child> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd.spawn()
|
||||
}
|
||||
|
||||
pub fn run_ffmpeg(
|
||||
ffmpeg_path: &str,
|
||||
cmd: &FfmpegCommand,
|
||||
job_id: &str,
|
||||
total_duration: f64,
|
||||
phase: &str,
|
||||
app: &AppHandle,
|
||||
jobs: &JobMap,
|
||||
) -> Result<RunResult, String> {
|
||||
let mut proc = Command::new(ffmpeg_path);
|
||||
proc.args(&cmd.args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = spawn_hidden(&mut proc)
|
||||
.map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?;
|
||||
|
||||
let stdout = child.stdout.take();
|
||||
|
||||
{
|
||||
let mut map = jobs.lock().map_err(|e| e.to_string())?;
|
||||
map.insert(job_id.to_string(), child);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let mut last_progress = ProgressEvent {
|
||||
job_id: job_id.to_string(),
|
||||
percent: 0.0,
|
||||
fps: 0.0,
|
||||
bitrate: String::new(),
|
||||
size_current: 0,
|
||||
time_elapsed: 0.0,
|
||||
eta_seconds: 0.0,
|
||||
phase: phase.to_string(),
|
||||
message: None,
|
||||
};
|
||||
|
||||
if let Some(out) = stdout {
|
||||
let reader = BufReader::new(out);
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = line.splitn(2, '=').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = parts[0].trim();
|
||||
let val = parts[1].trim();
|
||||
|
||||
match key {
|
||||
"out_time_us" => {
|
||||
if let Ok(us) = val.parse::<f64>() {
|
||||
let secs = us / 1_000_000.0;
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
last_progress.time_elapsed = elapsed;
|
||||
|
||||
if total_duration > 0.0 {
|
||||
let pct = (secs / total_duration * 100.0).min(100.0).max(0.0);
|
||||
last_progress.percent = pct;
|
||||
|
||||
if pct > 0.0 {
|
||||
let remaining = elapsed / pct * (100.0 - pct);
|
||||
last_progress.eta_seconds = remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"fps" => {
|
||||
last_progress.fps = val.parse::<f64>().unwrap_or(0.0);
|
||||
}
|
||||
"bitrate" => {
|
||||
last_progress.bitrate = val.to_string();
|
||||
}
|
||||
"total_size" => {
|
||||
last_progress.size_current = val.parse::<u64>().unwrap_or(0);
|
||||
}
|
||||
"progress" => {
|
||||
let _ = app.emit("progress", &last_progress);
|
||||
|
||||
if val == "end" {
|
||||
last_progress.percent = 100.0;
|
||||
last_progress.phase = "done".into();
|
||||
let _ = app.emit("progress", &last_progress);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = {
|
||||
let mut map = jobs.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(mut child) = map.remove(job_id) {
|
||||
child.wait().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err("Job was cancelled".into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(RunResult {
|
||||
success: status.success(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_ffmpeg_silent(
|
||||
ffmpeg_path: &str,
|
||||
cmd: &FfmpegCommand,
|
||||
) -> Result<(), String> {
|
||||
let mut proc = Command::new(ffmpeg_path);
|
||||
proc.args(&cmd.args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let child = spawn_hidden(&mut proc)
|
||||
.map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?;
|
||||
|
||||
let status = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
|
||||
if status.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("FFmpeg process failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_job(job_id: &str, jobs: &JobMap) -> Result<(), String> {
|
||||
let mut map = jobs.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(mut child) = map.remove(job_id) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
Ok(())
|
||||
} else {
|
||||
Err("No active job found".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_compress_with_retry(
|
||||
ffmpeg_path: &str,
|
||||
input: &str,
|
||||
output: &str,
|
||||
settings: &CompressSettings,
|
||||
trim: Option<&TrimRange>,
|
||||
hw_info: &HardwareInfo,
|
||||
app_config: &AppConfig,
|
||||
job_id: &str,
|
||||
total_duration: f64,
|
||||
has_audio: bool,
|
||||
app: &AppHandle,
|
||||
jobs: &JobMap,
|
||||
) -> Result<(String, u32), String> {
|
||||
let encoder = commands::select_encoder(&settings.video_codec, &settings.hw_accel, hw_info);
|
||||
let is_hw = commands::is_hw_encoder(&encoder);
|
||||
|
||||
match &settings.strategy {
|
||||
SizingStrategy::CRF { value } => {
|
||||
let cmd = commands::build_crf_command(
|
||||
input, output, settings, *value, &encoder, trim, has_audio,
|
||||
);
|
||||
let result = run_ffmpeg(ffmpeg_path, &cmd, job_id, total_duration, "encoding", app, jobs)?;
|
||||
if !result.success {
|
||||
return Err("Encoding failed".into());
|
||||
}
|
||||
Ok((output.to_string(), 1))
|
||||
}
|
||||
|
||||
SizingStrategy::TargetBitrate { kbps } => {
|
||||
let cmd = commands::build_bitrate_command(
|
||||
input, output, settings, *kbps, &encoder, trim, has_audio,
|
||||
);
|
||||
let result = run_ffmpeg(ffmpeg_path, &cmd, job_id, total_duration, "encoding", app, jobs)?;
|
||||
if !result.success {
|
||||
return Err("Encoding failed".into());
|
||||
}
|
||||
Ok((output.to_string(), 1))
|
||||
}
|
||||
|
||||
SizingStrategy::TargetSize { mb } => {
|
||||
let audio_kbps = match settings.audio_codec {
|
||||
AudioCodec::None => 0,
|
||||
_ => settings.audio_bitrate,
|
||||
};
|
||||
let mut bitrate_kbps = commands::calculate_bitrate(*mb, total_duration, audio_kbps);
|
||||
let target_bytes = (*mb * 1024.0 * 1024.0) as u64;
|
||||
let max_attempts = app_config.max_retry_attempts;
|
||||
let threshold = app_config.retry_threshold_percent;
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
let phase = if attempt == 1 { "encoding" } else { "retrying" };
|
||||
|
||||
if is_hw {
|
||||
let cmd = commands::build_compress_hw(
|
||||
input, output, settings, bitrate_kbps, &encoder, trim, has_audio,
|
||||
);
|
||||
let result = run_ffmpeg(ffmpeg_path, &cmd, job_id, total_duration, phase, app, jobs)?;
|
||||
if !result.success {
|
||||
return Err("Encoding failed".into());
|
||||
}
|
||||
} else {
|
||||
let passlog_dir = config::ensure_temp_subdir(&format!("passlog/{}", job_id));
|
||||
let passlog_prefix = passlog_dir.join("ffmpeg2pass").to_string_lossy().to_string();
|
||||
|
||||
let pass1 = commands::build_compress_pass1(
|
||||
input, settings, bitrate_kbps, &passlog_prefix,
|
||||
);
|
||||
let _ = app.emit("progress", &ProgressEvent {
|
||||
job_id: job_id.to_string(),
|
||||
percent: 0.0,
|
||||
fps: 0.0,
|
||||
bitrate: String::new(),
|
||||
size_current: 0,
|
||||
time_elapsed: 0.0,
|
||||
eta_seconds: 0.0,
|
||||
phase: "analyzing".into(),
|
||||
message: Some(format!("Pass 1 of 2 (attempt {})", attempt)),
|
||||
});
|
||||
|
||||
run_ffmpeg_silent(ffmpeg_path, &pass1)?;
|
||||
|
||||
let pass2 = commands::build_compress_pass2(
|
||||
input, output, settings, bitrate_kbps, &passlog_prefix, trim, has_audio,
|
||||
);
|
||||
let result = run_ffmpeg(ffmpeg_path, &pass2, job_id, total_duration, phase, app, jobs)?;
|
||||
if !result.success {
|
||||
return Err("Encoding failed".into());
|
||||
}
|
||||
}
|
||||
|
||||
let output_size = std::fs::metadata(output)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
if output_size <= target_bytes {
|
||||
return Ok((output.to_string(), attempt));
|
||||
}
|
||||
|
||||
let overshoot_pct = ((output_size as f64 - target_bytes as f64) / target_bytes as f64) * 100.0;
|
||||
|
||||
if overshoot_pct <= threshold {
|
||||
return Ok((output.to_string(), attempt));
|
||||
}
|
||||
|
||||
if attempt < max_attempts {
|
||||
let _ = app.emit("compress-retry", &serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"attempt": attempt + 1,
|
||||
"reason": format!(
|
||||
"Output {:.1}MB exceeds {:.1}MB target by {:.1}%",
|
||||
output_size as f64 / 1024.0 / 1024.0,
|
||||
mb,
|
||||
overshoot_pct
|
||||
),
|
||||
"adjusted_bitrate": bitrate_kbps
|
||||
}));
|
||||
|
||||
let ratio = target_bytes as f64 / output_size as f64;
|
||||
bitrate_kbps = ((bitrate_kbps as f64) * ratio * 0.95) as u32;
|
||||
bitrate_kbps = bitrate_kbps.max(10);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((output.to_string(), max_attempts))
|
||||
}
|
||||
}
|
||||
}
|
||||
153
src-tauri/src/lib.rs
Normal file
153
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod ffmpeg;
|
||||
mod recovery;
|
||||
mod stream_server;
|
||||
mod types;
|
||||
|
||||
use types::*;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub hw_info: Mutex<Option<HardwareInfo>>,
|
||||
pub jobs: ffmpeg::runner::JobMap,
|
||||
pub stream_port: Mutex<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct SavedWindowState {
|
||||
width: u32,
|
||||
height: u32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
maximized: bool,
|
||||
}
|
||||
|
||||
fn exe_dir() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
}
|
||||
|
||||
fn window_state_path() -> PathBuf {
|
||||
exe_dir().join(".window-state")
|
||||
}
|
||||
|
||||
fn load_window_state() -> Option<SavedWindowState> {
|
||||
let path = window_state_path();
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_window_state(state: &SavedWindowState) {
|
||||
let path = window_state_path();
|
||||
if let Ok(json) = serde_json::to_string_pretty(state) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
fn do_save(window: &tauri::Window) {
|
||||
if let Ok(size) = window.inner_size() {
|
||||
if let Ok(pos) = window.outer_position() {
|
||||
let maximized = window.is_maximized().unwrap_or(false);
|
||||
save_window_state(&SavedWindowState {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
maximized,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let cfg = config::load_config();
|
||||
|
||||
let stream_port = match tauri::async_runtime::block_on(stream_server::start()) {
|
||||
Ok(port) => port,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start stream server: {}", e);
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
config: Mutex::new(cfg),
|
||||
hw_info: Mutex::new(None),
|
||||
jobs: ffmpeg::runner::new_job_map(),
|
||||
stream_port: Mutex::new(stream_port),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::analyze::analyze_video,
|
||||
commands::analyze::extract_keyframes,
|
||||
commands::analyze::generate_thumbnails,
|
||||
commands::analyze::generate_preview,
|
||||
commands::analyze::detect_hardware,
|
||||
commands::process::compress,
|
||||
commands::process::trim,
|
||||
commands::process::cancel_job,
|
||||
commands::utility::check_ffmpeg,
|
||||
commands::utility::open_in_explorer,
|
||||
commands::utility::get_config,
|
||||
commands::utility::save_config_cmd,
|
||||
commands::utility::get_output_path,
|
||||
commands::utility::init_app,
|
||||
commands::utility::download_ffmpeg,
|
||||
commands::utility::check_recovery,
|
||||
commands::utility::cleanup_recovery,
|
||||
commands::utility::get_stream_url_cmd,
|
||||
commands::utility::get_stream_port_cmd,
|
||||
])
|
||||
.setup(|app| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let window = window.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
if let Some(state) = load_window_state() {
|
||||
if !state.maximized {
|
||||
let _ = window.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(
|
||||
state.width,
|
||||
state.height,
|
||||
)));
|
||||
let _ = window.set_position(tauri::Position::Physical(tauri::PhysicalPosition::new(
|
||||
state.x,
|
||||
state.y,
|
||||
)));
|
||||
}
|
||||
if state.maximized {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
} else {
|
||||
let _ = window.center();
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
match event {
|
||||
tauri::WindowEvent::Resized(_) | tauri::WindowEvent::Moved(_) => {
|
||||
do_save(window);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run cinch");
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cinch::run();
|
||||
}
|
||||
59
src-tauri/src/recovery.rs
Normal file
59
src-tauri/src/recovery.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const JOB_FILE: &str = "last_job.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InterruptedJob {
|
||||
pub input_path: String,
|
||||
pub output_path: String,
|
||||
pub mode: String,
|
||||
pub settings_json: String,
|
||||
}
|
||||
|
||||
pub fn write_job_info(
|
||||
temp_dir: &Path,
|
||||
input: &str,
|
||||
output: &str,
|
||||
mode: &str,
|
||||
settings_json: &str,
|
||||
) {
|
||||
let job = InterruptedJob {
|
||||
input_path: input.to_string(),
|
||||
output_path: output.to_string(),
|
||||
mode: mode.to_string(),
|
||||
settings_json: settings_json.to_string(),
|
||||
};
|
||||
let path = temp_dir.join(JOB_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(&job) {
|
||||
let _ = fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_job_info(temp_dir: &Path) {
|
||||
let path = temp_dir.join(JOB_FILE);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
pub fn check_interrupted_job(temp_dir: &Path) -> Option<InterruptedJob> {
|
||||
let path = temp_dir.join(JOB_FILE);
|
||||
let contents = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
pub fn cleanup_orphaned_temps(temp_dir: &Path) {
|
||||
if temp_dir.exists() {
|
||||
if let Ok(entries) = fs::read_dir(temp_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
} else {
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
src-tauri/src/stream_server.rs
Normal file
167
src-tauri/src/stream_server.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const MAX_CHUNK: u64 = 1024 * 1024; // 1MB max per response
|
||||
|
||||
pub async fn start() -> Result<u16, std::io::Error> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
tokio::spawn(handle_client(stream));
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn handle_client(mut stream: TcpStream) {
|
||||
// read headers until \r\n\r\n (up to 64KB)
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
let mut temp = [0u8; 4096];
|
||||
loop {
|
||||
let n = match stream.read(&mut temp).await {
|
||||
Ok(0) => return,
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
buf.extend_from_slice(&temp[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if buf.len() > 65536 {
|
||||
return; // headers too large
|
||||
}
|
||||
}
|
||||
|
||||
let request = String::from_utf8_lossy(&buf);
|
||||
let mut lines = request.lines();
|
||||
let request_line = match lines.next() {
|
||||
Some(line) => line,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
||||
if parts.len() < 2 || parts[0] != "GET" {
|
||||
return;
|
||||
}
|
||||
|
||||
let uri_path = percent_encoding::percent_decode_str(&parts[1][1..])
|
||||
.decode_utf8_lossy()
|
||||
.to_string();
|
||||
|
||||
let mut range_header = None;
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
let lower = line.to_lowercase();
|
||||
if lower.starts_with("range:") {
|
||||
range_header = Some(line[6..].trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let response = match serve_file(&uri_path, range_header).await {
|
||||
Some(data) => data,
|
||||
None => {
|
||||
let body = b"Not Found";
|
||||
let header = format!(
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let mut resp = header.into_bytes();
|
||||
resp.extend_from_slice(body);
|
||||
resp
|
||||
}
|
||||
};
|
||||
|
||||
let _ = stream.write_all(&response).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
async fn serve_file(path: &str, range_header: Option<String>) -> Option<Vec<u8>> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = std::fs::File::open(path).ok()?;
|
||||
|
||||
let file_len = {
|
||||
let pos = file.stream_position().ok()?;
|
||||
let len = file.seek(SeekFrom::End(0)).ok()?;
|
||||
file.seek(SeekFrom::Start(pos)).ok()?;
|
||||
len
|
||||
};
|
||||
|
||||
let ext = path.rsplit('.').next().unwrap_or("mp4").to_lowercase();
|
||||
let mime = match ext.as_str() {
|
||||
"webm" => "video/webm",
|
||||
"mkv" => "video/x-matroska",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mov" => "video/quicktime",
|
||||
"flv" => "video/x-flv",
|
||||
"ts" | "m2ts" => "video/mp2t",
|
||||
"wmv" => "video/x-ms-wmv",
|
||||
"mpg" | "mpeg" => "video/mpeg",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
_ => "video/mp4",
|
||||
};
|
||||
|
||||
if let Some(range_str) = range_header {
|
||||
if let Ok(ranges) = http_range::HttpRange::parse(&range_str, file_len) {
|
||||
if let Some(r) = ranges.first() {
|
||||
let start = r.start;
|
||||
let end = (start + r.length - 1).min(file_len - 1);
|
||||
let chunk_len = end + 1 - start;
|
||||
|
||||
file.seek(SeekFrom::Start(start)).ok()?;
|
||||
let mut body = Vec::with_capacity(chunk_len as usize);
|
||||
file.take(chunk_len).read_to_end(&mut body).ok()?;
|
||||
|
||||
let header = format!(
|
||||
"HTTP/1.1 206 Partial Content\r\n\
|
||||
Content-Type: {}\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Content-Range: bytes {}-{}/{}\r\n\
|
||||
Accept-Ranges: bytes\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
Connection: close\r\n\r\n",
|
||||
mime,
|
||||
body.len(),
|
||||
start,
|
||||
end,
|
||||
file_len
|
||||
);
|
||||
|
||||
let mut response = header.into_bytes();
|
||||
response.extend(body);
|
||||
return Some(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let chunk_len = MAX_CHUNK.min(file_len);
|
||||
file.seek(SeekFrom::Start(0)).ok()?;
|
||||
let mut body = Vec::with_capacity(chunk_len as usize);
|
||||
file.take(chunk_len).read_to_end(&mut body).ok()?;
|
||||
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: {}\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Accept-Ranges: bytes\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
Connection: close\r\n\r\n",
|
||||
mime,
|
||||
body.len()
|
||||
);
|
||||
|
||||
let mut response = header.into_bytes();
|
||||
response.extend(body);
|
||||
Some(response)
|
||||
}
|
||||
204
src-tauri/src/types.rs
Normal file
204
src-tauri/src/types.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoInfo {
|
||||
pub path: String,
|
||||
pub file_size: u64,
|
||||
pub duration: f64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub video_codec: String,
|
||||
pub video_bitrate: u64,
|
||||
pub frame_rate: f64,
|
||||
pub is_vfr: bool,
|
||||
pub audio_codec: Option<String>,
|
||||
pub audio_bitrate: Option<u64>,
|
||||
pub audio_channels: Option<u32>,
|
||||
pub keyframe_times: Vec<f64>,
|
||||
pub container: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompressSettings {
|
||||
pub strategy: SizingStrategy,
|
||||
pub video_codec: VideoCodec,
|
||||
pub audio_codec: AudioCodec,
|
||||
pub audio_bitrate: u32,
|
||||
pub container: Container,
|
||||
pub resolution: Resolution,
|
||||
pub speed_preset: String,
|
||||
pub hw_accel: HwAccelMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SizingStrategy {
|
||||
TargetSize { mb: f64 },
|
||||
TargetBitrate { kbps: u32 },
|
||||
CRF { value: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrimRange {
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum VideoCodec {
|
||||
H264,
|
||||
HEVC,
|
||||
AV1,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AudioCodec {
|
||||
AAC,
|
||||
Opus,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Container {
|
||||
MP4,
|
||||
MKV,
|
||||
WebM,
|
||||
MOV,
|
||||
AVI,
|
||||
TS,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum HwAccelMode {
|
||||
Auto,
|
||||
ForceGPU,
|
||||
ForceCPU,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Resolution {
|
||||
Original,
|
||||
P720,
|
||||
P1080,
|
||||
P1440,
|
||||
P4K,
|
||||
Custom { width: u32, height: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutputInfo {
|
||||
pub path: String,
|
||||
pub file_size: u64,
|
||||
pub duration: f64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub video_codec: String,
|
||||
pub video_bitrate: u64,
|
||||
pub audio_codec: Option<String>,
|
||||
pub audio_bitrate: Option<u64>,
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HardwareInfo {
|
||||
pub nvenc: bool,
|
||||
pub qsv: bool,
|
||||
pub amf: bool,
|
||||
pub nvenc_codecs: Vec<String>,
|
||||
pub qsv_codecs: Vec<String>,
|
||||
pub amf_codecs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FFmpegStatus {
|
||||
pub found: bool,
|
||||
pub path: Option<String>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProgressEvent {
|
||||
pub job_id: String,
|
||||
pub percent: f64,
|
||||
pub fps: f64,
|
||||
pub bitrate: String,
|
||||
pub size_current: u64,
|
||||
pub time_elapsed: f64,
|
||||
pub eta_seconds: f64,
|
||||
pub phase: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub theme: String,
|
||||
pub ui_zoom: u32,
|
||||
pub ffmpeg_path: Option<String>,
|
||||
pub default_output_dir: Option<String>,
|
||||
pub last_compress_settings: Option<CompressSettings>,
|
||||
pub default_presets: Vec<u32>,
|
||||
pub auto_retry: bool,
|
||||
pub retry_threshold_percent: f64,
|
||||
pub max_retry_attempts: u32,
|
||||
pub show_ffmpeg_log: bool,
|
||||
pub remember_window_position: bool,
|
||||
pub window_position: Option<WindowPosition>,
|
||||
#[serde(default = "default_target_size")]
|
||||
pub default_target_size: u32,
|
||||
#[serde(default)]
|
||||
pub default_smart_cut: bool,
|
||||
#[serde(default = "default_naming_pattern")]
|
||||
pub naming_pattern: String,
|
||||
#[serde(default)]
|
||||
pub last_open_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_save_dir: Option<String>,
|
||||
#[serde(default = "default_preview_volume")]
|
||||
pub preview_volume: f32,
|
||||
}
|
||||
|
||||
fn default_target_size() -> u32 {
|
||||
8
|
||||
}
|
||||
|
||||
fn default_naming_pattern() -> String {
|
||||
"{name}_{mode}_{timestamp}".into()
|
||||
}
|
||||
|
||||
fn default_preview_volume() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowPosition {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "system".into(),
|
||||
ui_zoom: 100,
|
||||
ffmpeg_path: None,
|
||||
default_output_dir: None,
|
||||
last_compress_settings: None,
|
||||
default_presets: vec![8, 25, 50, 100],
|
||||
auto_retry: true,
|
||||
retry_threshold_percent: 2.0,
|
||||
max_retry_attempts: 3,
|
||||
show_ffmpeg_log: false,
|
||||
remember_window_position: true,
|
||||
window_position: None,
|
||||
default_target_size: 8,
|
||||
default_smart_cut: false,
|
||||
naming_pattern: "{name}_{mode}_{timestamp}".into(),
|
||||
last_open_dir: None,
|
||||
last_save_dir: None,
|
||||
preview_volume: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src-tauri/tauri.conf.json
Normal file
41
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "Cinch",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.cinch.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "Cinch",
|
||||
"width": 960,
|
||||
"height": 700,
|
||||
"minWidth": 700,
|
||||
"minHeight": 500,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"transparent": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"icon": [
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
198
src/app.css
Normal file
198
src/app.css
Normal file
@@ -0,0 +1,198 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ============================================================
|
||||
DESIGN TOKENS - Cinch
|
||||
Style: Polished Modern with Arc Browser energy
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* Typography */
|
||||
--font-display: 'Syne', system-ui, sans-serif;
|
||||
--font-body: 'Nunito Sans', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', 'Consolas', monospace;
|
||||
|
||||
/* Type Scale (1.200 Minor Third) */
|
||||
--text-xs: 0.694rem;
|
||||
--text-sm: 0.833rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.2rem;
|
||||
--text-xl: 1.44rem;
|
||||
--text-2xl: 1.728rem;
|
||||
--text-3xl: 2.074rem;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-2xl: 48px;
|
||||
|
||||
/* Shape */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* Accent Colors (same both themes) */
|
||||
--color-accent-compress: #059669;
|
||||
--color-accent-trim: #2563eb;
|
||||
--color-accent-warning: #f59e0b;
|
||||
--color-accent-error: #ef4444;
|
||||
--color-accent-info: #60a5fa;
|
||||
|
||||
/* Animation */
|
||||
--transition-fast: 150ms ease-out;
|
||||
--transition-default: 250ms ease-out;
|
||||
--transition-spring: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--transition-morph: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Dark Theme (default) */
|
||||
[data-theme='dark'] {
|
||||
--color-bg-base: #0f1a1f;
|
||||
--color-bg-surface: #162228;
|
||||
--color-bg-elevated: #1c2d34;
|
||||
--color-text-primary: rgba(255, 255, 255, 0.87);
|
||||
--color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--color-text-disabled: rgba(255, 255, 255, 0.38);
|
||||
--color-border: rgba(255, 255, 255, 0.1);
|
||||
--color-bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
--shadow-video: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
--shadow-glow-compress: 0 0 12px rgba(5, 150, 105, 0.3);
|
||||
--shadow-glow-trim: 0 0 12px rgba(37, 99, 235, 0.3);
|
||||
--shadow-playhead: 0 0 8px rgba(5, 150, 105, 0.5);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
[data-theme='light'] {
|
||||
--color-bg-base: #f5f8fa;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-text-primary: #1a2b34;
|
||||
--color-text-secondary: #5a6b74;
|
||||
--color-text-disabled: #9aabb4;
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--color-bg-overlay: rgba(0, 0, 0, 0.3);
|
||||
|
||||
--shadow-video: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
--shadow-glow-compress: 0 0 12px rgba(5, 150, 105, 0.2);
|
||||
--shadow-glow-trim: 0 0 12px rgba(37, 99, 235, 0.2);
|
||||
--shadow-playhead: 0 0 8px rgba(5, 150, 105, 0.4);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-base);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Noise texture overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.025;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
[data-theme='light'] body::before {
|
||||
opacity: 0.015;
|
||||
}
|
||||
|
||||
/* Scrollbar hiding */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Focus ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent-compress);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Button press feedback */
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
transition: transform 100ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 150ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.uppercase-label {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
src: url('/fonts/Syne-Variable.woff2') format('woff2');
|
||||
font-weight: 400 800;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
src: url('/fonts/NunitoSans-Variable.woff2') format('woff2');
|
||||
font-weight: 200 1000;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('/fonts/GeistMono-Variable.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
12
src/app.html
Normal file
12
src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Cinch</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-prerender="false">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
387
src/lib/components/AdvancedOptions.svelte
Normal file
387
src/lib/components/AdvancedOptions.svelte
Normal file
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import CustomSelect from './CustomSelect.svelte';
|
||||
import Slider from './Slider.svelte';
|
||||
import { formatFileSize } from '$lib/utils/format';
|
||||
import type { VideoCodec, AudioCodec, Container, HwAccelMode, Resolution, SizingStrategy } from '$lib/types';
|
||||
|
||||
let showCard = $derived(app.advancedOpen);
|
||||
let visible = $derived(app.selectedPreset !== null || app.compressSettings.strategy.type !== 'TargetSize');
|
||||
|
||||
const sizingOptions = [
|
||||
{ value: 'TargetSize', label: 'Target Size', icon: 'ti-file-zip' },
|
||||
{ value: 'TargetBitrate', label: 'Bitrate', icon: 'ti-wave-sine' },
|
||||
{ value: 'CRF', label: 'CRF / Quality', icon: 'ti-diamond' }
|
||||
];
|
||||
|
||||
const codecOptions = [
|
||||
{ value: 'H264', label: 'H.264', icon: 'ti-badge-hd' },
|
||||
{ value: 'HEVC', label: 'HEVC (H.265)', icon: 'ti-badge-4k' },
|
||||
{ value: 'AV1', label: 'AV1', icon: 'ti-atom' }
|
||||
];
|
||||
|
||||
const containerOptions = [
|
||||
{ value: 'MP4', label: 'MP4', icon: 'ti-box' },
|
||||
{ value: 'MKV', label: 'MKV', icon: 'ti-package' },
|
||||
{ value: 'WebM', label: 'WebM', icon: 'ti-world' },
|
||||
{ value: 'MOV', label: 'MOV', icon: 'ti-movie' },
|
||||
{ value: 'AVI', label: 'AVI', icon: 'ti-archive' },
|
||||
{ value: 'TS', label: 'TS', icon: 'ti-broadcast' }
|
||||
];
|
||||
|
||||
const audioCodecOptions = [
|
||||
{ value: 'AAC', label: 'AAC', icon: 'ti-headphones' },
|
||||
{ value: 'Opus', label: 'Opus', icon: 'ti-vinyl' },
|
||||
{ value: 'None', label: 'None (strip)', icon: 'ti-volume-off' }
|
||||
];
|
||||
|
||||
const audioBitrateOptions = [
|
||||
{ value: '64', label: '64 kbps', icon: 'ti-antenna-bars-1' },
|
||||
{ value: '96', label: '96 kbps', icon: 'ti-antenna-bars-2' },
|
||||
{ value: '128', label: '128 kbps', icon: 'ti-antenna-bars-3' },
|
||||
{ value: '192', label: '192 kbps', icon: 'ti-antenna-bars-4' },
|
||||
{ value: '256', label: '256 kbps', icon: 'ti-antenna-bars-5' },
|
||||
{ value: '320', label: '320 kbps', icon: 'ti-antenna-bars-5' }
|
||||
];
|
||||
|
||||
const resolutionOptions = [
|
||||
{ value: 'Original', label: 'Original', icon: 'ti-aspect-ratio' },
|
||||
{ value: 'P720', label: '720p', icon: 'ti-device-laptop' },
|
||||
{ value: 'P1080', label: '1080p', icon: 'ti-device-desktop' },
|
||||
{ value: 'P1440', label: '1440p', icon: 'ti-device-tv' },
|
||||
{ value: 'P4K', label: '4K', icon: 'ti-device-tv' }
|
||||
];
|
||||
|
||||
const speedLabels = [
|
||||
{ value: 0, label: 'Fastest' },
|
||||
{ value: 25, label: 'Fast' },
|
||||
{ value: 50, label: 'Balanced' },
|
||||
{ value: 75, label: 'Quality' },
|
||||
{ value: 100, label: 'Best' }
|
||||
];
|
||||
|
||||
let speedValue = $state(50);
|
||||
let currentSizingType = $derived(app.compressSettings.strategy.type);
|
||||
let currentResolution = $derived(
|
||||
app.compressSettings.resolution.type === 'Custom' ? 'Original' : app.compressSettings.resolution.type
|
||||
);
|
||||
let noAudio = $derived(app.videoInfo?.audio_codec === null);
|
||||
|
||||
$effect(() => {
|
||||
const container = app.compressSettings.container;
|
||||
const audioCodec = app.compressSettings.audio_codec;
|
||||
if ((container === 'MP4' || container === 'MOV' || container === 'AVI') && audioCodec === 'Opus') {
|
||||
app.compressSettings.audio_codec = 'AAC';
|
||||
toasts.add('info', `Opus isn't supported in ${container} - switched to AAC`);
|
||||
}
|
||||
if (container === 'WebM' && audioCodec === 'AAC') {
|
||||
app.compressSettings.audio_codec = 'Opus';
|
||||
toasts.add('info', "WebM uses Opus for audio - switched from AAC");
|
||||
}
|
||||
});
|
||||
|
||||
let estimatedOutput = $derived.by(() => {
|
||||
if (!app.videoInfo) return '';
|
||||
const s = app.compressSettings.strategy;
|
||||
if (s.type === 'TargetSize') return `~${s.mb} MB`;
|
||||
if (s.type === 'TargetBitrate') {
|
||||
const dur = app.videoInfo.duration;
|
||||
const videoBits = s.kbps * 1000 * dur;
|
||||
const audioBits = app.compressSettings.audio_codec !== 'None'
|
||||
? app.compressSettings.audio_bitrate * 1000 * dur : 0;
|
||||
const bytes = (videoBits + audioBits) / 8;
|
||||
return `~${formatFileSize(bytes)}`;
|
||||
}
|
||||
return 'Size varies';
|
||||
});
|
||||
|
||||
function toggle() { app.advancedOpen = !app.advancedOpen; }
|
||||
function setSizing(val: string) {
|
||||
if (val === 'TargetSize') app.compressSettings.strategy = { type: 'TargetSize', mb: 8 };
|
||||
else if (val === 'TargetBitrate') app.compressSettings.strategy = { type: 'TargetBitrate', kbps: 2000 };
|
||||
else app.compressSettings.strategy = { type: 'CRF', value: 23 };
|
||||
}
|
||||
function setCodec(val: string) { app.compressSettings.video_codec = val as VideoCodec; }
|
||||
function setContainer(val: string) { app.compressSettings.container = val as Container; }
|
||||
function setAudioCodec(val: string) { app.compressSettings.audio_codec = val as AudioCodec; }
|
||||
function setAudioBitrate(val: string) { app.compressSettings.audio_bitrate = parseInt(val); }
|
||||
function setResolution(val: string) {
|
||||
if (val === 'Original') app.compressSettings.resolution = { type: 'Original' };
|
||||
else if (val === 'P720') app.compressSettings.resolution = { type: 'P720' };
|
||||
else if (val === 'P1080') app.compressSettings.resolution = { type: 'P1080' };
|
||||
else if (val === 'P1440') app.compressSettings.resolution = { type: 'P1440' };
|
||||
else if (val === 'P4K') app.compressSettings.resolution = { type: 'P4K' };
|
||||
}
|
||||
function setHw(val: string) { app.compressSettings.hw_accel = val as HwAccelMode; }
|
||||
function setSpeed(val: number) {
|
||||
speedValue = val;
|
||||
if (val <= 12) app.compressSettings.speed_preset = 'ultrafast';
|
||||
else if (val <= 37) app.compressSettings.speed_preset = 'fast';
|
||||
else if (val <= 62) app.compressSettings.speed_preset = 'medium';
|
||||
else if (val <= 87) app.compressSettings.speed_preset = 'slow';
|
||||
else app.compressSettings.speed_preset = 'veryslow';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showCard}
|
||||
<div class="adv-card" role="region" aria-label="Advanced options">
|
||||
<div class="adv-header">
|
||||
<div class="adv-header-left">
|
||||
<i class="ti ti-adjustments-horizontal" style="font-size: 16px; color: var(--color-accent-compress)"></i>
|
||||
<span class="adv-title">Advanced options</span>
|
||||
</div>
|
||||
<button type="button" class="adv-close" onclick={toggle} aria-label="Close">
|
||||
<i class="ti ti-chevron-up" style="font-size: 16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="adv-body">
|
||||
|
||||
<!-- VIDEO -->
|
||||
<div class="adv-section">
|
||||
<div class="adv-section-label">
|
||||
<i class="ti ti-video" style="font-size: 14px"></i> Video
|
||||
</div>
|
||||
<div class="adv-row">
|
||||
<div class="adv-field">
|
||||
<span class="adv-field-label">Codec</span>
|
||||
<CustomSelect options={codecOptions} value={app.compressSettings.video_codec} onChange={setCodec} />
|
||||
</div>
|
||||
<div class="adv-field">
|
||||
<span class="adv-field-label">Resolution</span>
|
||||
<CustomSelect options={resolutionOptions} value={currentResolution} onChange={setResolution} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="adv-row">
|
||||
<div class="adv-field">
|
||||
<span class="adv-field-label">Sizing mode</span>
|
||||
<CustomSelect options={sizingOptions} value={currentSizingType} onChange={setSizing} />
|
||||
</div>
|
||||
<div class="adv-field">
|
||||
<span class="adv-field-label">Container</span>
|
||||
<CustomSelect options={containerOptions} value={app.compressSettings.container} onChange={setContainer} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AUDIO -->
|
||||
<div class="adv-section">
|
||||
<div class="adv-section-label">
|
||||
<i class="ti ti-music" style="font-size: 14px"></i> Audio
|
||||
</div>
|
||||
<div class="adv-row">
|
||||
<div class="adv-field">
|
||||
<span class="adv-field-label">Codec</span>
|
||||
<CustomSelect
|
||||
options={audioCodecOptions}
|
||||
value={app.compressSettings.audio_codec}
|
||||
onChange={setAudioCodec}
|
||||
disabled={noAudio}
|
||||
hint={noAudio ? 'No audio stream' : undefined}
|
||||
/>
|
||||
</div>
|
||||
{#if app.compressSettings.audio_codec !== 'None' && !noAudio}
|
||||
<div class="adv-field">
|
||||
<span class="adv-field-label">Bitrate</span>
|
||||
<CustomSelect
|
||||
options={audioBitrateOptions}
|
||||
value={String(app.compressSettings.audio_bitrate)}
|
||||
onChange={setAudioBitrate}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ENCODE QUALITY -->
|
||||
<div class="adv-section">
|
||||
<div class="adv-section-label">
|
||||
<i class="ti ti-gauge" style="font-size: 14px"></i> Encode quality vs speed
|
||||
</div>
|
||||
<Slider min={0} max={100} step={1} value={speedValue} labels={speedLabels} onChange={setSpeed} />
|
||||
</div>
|
||||
|
||||
<!-- HARDWARE -->
|
||||
<div class="adv-section">
|
||||
<div class="adv-section-label">
|
||||
<i class="ti ti-cpu" style="font-size: 14px"></i> Hardware acceleration
|
||||
</div>
|
||||
<div class="adv-hw-row">
|
||||
{#each [
|
||||
{ value: 'Auto', label: 'Auto', icon: 'ti-bolt' },
|
||||
{ value: 'ForceGPU', label: 'GPU', icon: 'ti-chart-arrows' },
|
||||
{ value: 'ForceCPU', label: 'CPU', icon: 'ti-cpu' }
|
||||
] as hw}
|
||||
<button
|
||||
type="button"
|
||||
class="adv-hw-btn"
|
||||
class:adv-hw-btn--active={app.compressSettings.hw_accel === hw.value}
|
||||
onclick={() => setHw(hw.value)}
|
||||
>
|
||||
<i class="ti {hw.icon}" style="font-size: 14px"></i>
|
||||
{hw.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{#if estimatedOutput}
|
||||
<div class="adv-footer">
|
||||
<span class="adv-footer-label">Estimated output</span>
|
||||
<span class="adv-footer-value">{estimatedOutput}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.adv-card {
|
||||
width: 100%;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: expandIn 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.adv-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.adv-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.adv-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.adv-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-disabled);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.adv-close:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.adv-body {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.adv-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.adv-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.adv-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.adv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.adv-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.adv-field-label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-disabled);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.adv-hw-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.adv-hw-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.adv-hw-btn:hover {
|
||||
border-color: var(--color-accent-compress);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.adv-hw-btn--active {
|
||||
background: var(--color-accent-compress);
|
||||
border-color: var(--color-accent-compress);
|
||||
color: white;
|
||||
}
|
||||
.adv-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.adv-footer-label {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.adv-footer-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-compress);
|
||||
}
|
||||
@keyframes expandIn {
|
||||
from { opacity: 0; transform: scale(0.98) translateY(-8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
</style>
|
||||
245
src/lib/components/AnalyzingSkeleton.svelte
Normal file
245
src/lib/components/AnalyzingSkeleton.svelte
Normal file
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let dots = $state('');
|
||||
let elapsed = $state(0);
|
||||
|
||||
let filename = $derived(app.videoInfo?.path?.split(/[\\/]/).pop() ?? 'video');
|
||||
|
||||
onMount(() => {
|
||||
const dotTimer = setInterval(() => {
|
||||
dots = dots.length >= 3 ? '' : dots + '.';
|
||||
}, 500);
|
||||
const elapsedTimer = setInterval(() => {
|
||||
elapsed += 1;
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(dotTimer);
|
||||
clearInterval(elapsedTimer);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="analyze-page">
|
||||
<!-- main content area -->
|
||||
<div class="analyze-content">
|
||||
<!-- icon + message -->
|
||||
<div class="analyze-hero">
|
||||
<div class="analyze-icon-ring">
|
||||
<div class="analyze-spinner"></div>
|
||||
<i class="ti ti-file-analytics analyze-icon"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="analyze-title">Analyzing video{dots}</h2>
|
||||
<p class="analyze-sub">Reading metadata, extracting keyframes, generating thumbnails</p>
|
||||
|
||||
{#if elapsed > 5}
|
||||
<p class="analyze-slow">This may take a moment for large files</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- skeleton preview of what's coming -->
|
||||
<div class="analyze-preview">
|
||||
<div class="skel skel-video"></div>
|
||||
<div class="skel skel-timeline"></div>
|
||||
<div class="skel-row">
|
||||
<div class="skel skel-pill"></div>
|
||||
<div class="skel skel-pill"></div>
|
||||
<div class="skel skel-pill"></div>
|
||||
<div class="skel skel-pill"></div>
|
||||
<div class="skel skel-pill-wide"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- bottom bar -->
|
||||
<div class="analyze-bottom">
|
||||
<i class="ti ti-movie" style="font-size: 13px; color: var(--color-accent-compress)"></i>
|
||||
<span class="analyze-filename">{filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="analyze-cancel"
|
||||
title="Cancel"
|
||||
aria-label="Cancel"
|
||||
onclick={() => app.resetToEmpty()}
|
||||
>
|
||||
<i class="ti ti-x" style="font-size: 11px"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.analyze-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.analyze-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.analyze-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.analyze-icon-ring {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.analyze-spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-accent-compress);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.analyze-icon {
|
||||
font-size: 22px;
|
||||
color: var(--color-accent-compress);
|
||||
}
|
||||
|
||||
.analyze-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 200px;
|
||||
}
|
||||
.analyze-sub {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
max-width: 280px;
|
||||
}
|
||||
.analyze-slow {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-accent-warning);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.analyze-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skel {
|
||||
background: var(--color-bg-surface);
|
||||
border-radius: var(--radius-md);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.04) 50%, transparent 100%);
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skel-video {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.skel-timeline {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
.skel-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.skel-pill {
|
||||
width: 56px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.skel-pill-wide {
|
||||
width: 72px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skel-pill-wide::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.04) 50%, transparent 100%);
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.analyze-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
height: 34px;
|
||||
background: var(--color-bg-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.analyze-filename {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.analyze-cancel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-disabled);
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.analyze-cancel:hover {
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
318
src/lib/components/CompressPresets.svelte
Normal file
318
src/lib/components/CompressPresets.svelte
Normal file
@@ -0,0 +1,318 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
|
||||
interface Props {
|
||||
onAdvancedClick?: () => void;
|
||||
showAdvanced?: boolean;
|
||||
}
|
||||
|
||||
let { onAdvancedClick, showAdvanced = false }: Props = $props();
|
||||
|
||||
const presets = [8, 25, 50, 100];
|
||||
let customActive = $state(false);
|
||||
let customValue = $state(8);
|
||||
let holdTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let activeIdx = $derived.by(() => {
|
||||
if (app.selectedPreset === null) return -1;
|
||||
return presets.indexOf(app.selectedPreset);
|
||||
});
|
||||
|
||||
function handlePresetClick(mb: number) {
|
||||
customActive = false;
|
||||
if (app.selectedPreset === mb) {
|
||||
app.clearPreset();
|
||||
} else {
|
||||
app.setPreset(mb);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomClick() {
|
||||
if (customActive) {
|
||||
customActive = false;
|
||||
app.clearPreset();
|
||||
return;
|
||||
}
|
||||
customActive = true;
|
||||
customValue = 8;
|
||||
app.setCustomSize(8);
|
||||
}
|
||||
|
||||
function adjustValue(delta: number) {
|
||||
customValue = Math.max(0.5, Math.round((customValue + delta) * 10) / 10);
|
||||
app.setCustomSize(customValue);
|
||||
}
|
||||
|
||||
function startHold(delta: number) {
|
||||
adjustValue(delta);
|
||||
let speed = 200;
|
||||
let count = 0;
|
||||
holdTimer = setInterval(() => {
|
||||
adjustValue(delta);
|
||||
count++;
|
||||
// accelerate after holding
|
||||
if (count > 5 && speed > 50) {
|
||||
speed = 80;
|
||||
if (holdTimer) clearInterval(holdTimer);
|
||||
holdTimer = setInterval(() => adjustValue(delta), speed);
|
||||
}
|
||||
}, speed);
|
||||
}
|
||||
|
||||
function stopHold() {
|
||||
if (holdTimer) {
|
||||
clearInterval(holdTimer);
|
||||
holdTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
let isActive = $derived(app.selectedPreset !== null || customActive);
|
||||
</script>
|
||||
|
||||
<div class="cp" class:cp--active={isActive}>
|
||||
<div class="cp-header">
|
||||
<span class="cp-label">Compress to:</span>
|
||||
<!-- always rendered to prevent layout shift, visibility toggled -->
|
||||
<button
|
||||
type="button"
|
||||
class="cp-adv-btn"
|
||||
style="visibility: {showAdvanced ? 'visible' : 'hidden'}"
|
||||
onclick={onAdvancedClick}
|
||||
title="Advanced options"
|
||||
>
|
||||
<i class="ti ti-adjustments-horizontal" style="font-size: 14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cp-row">
|
||||
{#each presets as mb, i}
|
||||
<button
|
||||
type="button"
|
||||
class="cp-btn"
|
||||
class:cp-btn--active={activeIdx === i}
|
||||
onclick={() => handlePresetClick(mb)}
|
||||
>
|
||||
{mb}<span class="cp-unit">MB</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if customActive}
|
||||
<!-- custom spinner replaces the Custom button -->
|
||||
<div class="cp-spinner">
|
||||
<button
|
||||
type="button"
|
||||
class="cp-spin-btn"
|
||||
onpointerdown={() => startHold(-1)}
|
||||
onpointerup={stopHold}
|
||||
onpointerleave={stopHold}
|
||||
>
|
||||
<i class="ti ti-minus" style="font-size: 12px"></i>
|
||||
</button>
|
||||
<div class="cp-spin-value">
|
||||
<input
|
||||
type="number"
|
||||
class="cp-spin-input"
|
||||
bind:value={customValue}
|
||||
oninput={() => { if (customValue > 0) app.setCustomSize(customValue); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
/>
|
||||
<span class="cp-spin-unit">MB</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cp-spin-btn"
|
||||
onpointerdown={() => startHold(1)}
|
||||
onpointerup={stopHold}
|
||||
onpointerleave={stopHold}
|
||||
>
|
||||
<i class="ti ti-plus" style="font-size: 12px"></i>
|
||||
</button>
|
||||
<button type="button" class="cp-spin-close" onclick={handleCustomClick}>
|
||||
<i class="ti ti-x" style="font-size: 11px"></i>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="cp-btn cp-btn--custom"
|
||||
onclick={handleCustomClick}
|
||||
>
|
||||
<i class="ti ti-pencil" style="font-size: 12px"></i>
|
||||
Custom
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cp {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
transition: background var(--transition-default);
|
||||
}
|
||||
.cp--active {
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
}
|
||||
|
||||
.cp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cp-label {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.cp-adv-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-disabled);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.cp-adv-btn:hover {
|
||||
color: var(--color-accent-compress);
|
||||
border-color: var(--color-accent-compress);
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
}
|
||||
|
||||
.cp-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cp-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast),
|
||||
border-color var(--transition-fast), box-shadow var(--transition-default);
|
||||
}
|
||||
.cp-btn:hover:not(.cp-btn--active) {
|
||||
border-color: var(--color-accent-compress);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.cp-btn--active {
|
||||
background: var(--color-accent-compress);
|
||||
border-color: var(--color-accent-compress);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-glow-compress);
|
||||
}
|
||||
.cp-btn--custom {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
gap: 4px;
|
||||
}
|
||||
.cp-unit {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cp-spinner {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-accent-compress);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cp-spin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
align-self: stretch;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-accent-compress);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.cp-spin-btn:hover {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
.cp-spin-btn:active {
|
||||
background: rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
.cp-spin-value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 8px 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
.cp-spin-input {
|
||||
width: 48px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-compress);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.cp-spin-input::-webkit-outer-spin-button,
|
||||
.cp-spin-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.cp-spin-unit {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.cp-spin-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
align-self: stretch;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: none;
|
||||
color: var(--color-text-disabled);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.cp-spin-close:hover {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
473
src/lib/components/CtaBar.svelte
Normal file
473
src/lib/components/CtaBar.svelte
Normal file
@@ -0,0 +1,473 @@
|
||||
<script lang="ts">
|
||||
import { app, view } from '$lib/stores/app.svelte';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import { config } from '$lib/stores/config.svelte';
|
||||
import { formatFileSize, formatEta } from '$lib/utils/format';
|
||||
|
||||
function computeOutputName(path: string, mode: string, ext: string) {
|
||||
const name = path.split(/[\\/]/).pop() ?? 'video';
|
||||
const base = name.replace(/\.[^.]+$/, '');
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().slice(0, 10).replace(/-/g, '') + '_' +
|
||||
now.toTimeString().slice(0, 5).replace(':', '');
|
||||
const pattern = config.current.naming_pattern;
|
||||
const out = pattern.replace('{name}', base).replace('{mode}', mode).replace('{timestamp}', timestamp);
|
||||
return `${out}.${ext}`;
|
||||
}
|
||||
|
||||
let outputName = $derived.by(() => {
|
||||
if (!app.videoInfo) return '';
|
||||
const ext = app.compressSettings.container.toLowerCase();
|
||||
const mode = app.activeMode === 'compress' ? 'compressed'
|
||||
: app.activeMode === 'trim' ? 'trimmed'
|
||||
: app.activeMode === 'both' ? 'trimcomp' : 'output';
|
||||
return computeOutputName(app.videoInfo.path, mode, ext);
|
||||
});
|
||||
|
||||
let estimatedSize = $derived.by(() => {
|
||||
if (!app.videoInfo || app.activeMode === 'none' || app.activeMode === 'trim') return '';
|
||||
const s = app.compressSettings.strategy;
|
||||
if (s.type === 'TargetSize') return `~${s.mb} MB`;
|
||||
if (s.type === 'TargetBitrate') {
|
||||
const dur = app.videoInfo.duration;
|
||||
const videoBits = s.kbps * 1000 * dur;
|
||||
const audioBits = app.compressSettings.audio_codec !== 'None'
|
||||
? app.compressSettings.audio_bitrate * 1000 * dur : 0;
|
||||
const bytes = (videoBits + audioBits) / 8;
|
||||
return `~${formatFileSize(bytes)}`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
let isBoth = $derived(app.activeMode === 'both');
|
||||
let canSave = $derived(app.activeMode !== 'none' && view.current === 'loaded');
|
||||
let isProcessing = $derived(view.current === 'processing');
|
||||
let percent = $derived(app.progress?.percent ?? 0);
|
||||
let eta = $derived(app.progress?.eta_seconds ?? 0);
|
||||
let phase = $derived(app.progress?.phase ?? '');
|
||||
let fps = $derived(app.progress?.fps ?? 0);
|
||||
let bitrate = $derived(app.progress?.bitrate ?? '');
|
||||
let sizeCurrent = $derived(app.progress?.size_current ?? 0);
|
||||
let cancelConfirm = $state(false);
|
||||
|
||||
let actionLabel = $derived.by(() => {
|
||||
if (app.activeMode === 'compress') return 'Compress';
|
||||
if (app.activeMode === 'trim') return 'Trim';
|
||||
if (app.activeMode === 'both') return 'Trim + Compress';
|
||||
return 'Save';
|
||||
});
|
||||
|
||||
async function handleChangePath() {
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const ext = app.compressSettings.container.toLowerCase();
|
||||
// build default path with the filename so user can just change location
|
||||
const dir = config.current.last_save_dir ?? app.videoInfo?.path.replace(/[\\/][^\\/]+$/, '') ?? '';
|
||||
const defaultPath = dir ? `${dir}/${outputName}` : outputName;
|
||||
const result = await save({
|
||||
defaultPath,
|
||||
filters: [{ name: 'Video', extensions: [ext] }]
|
||||
});
|
||||
if (result) {
|
||||
app.overriddenOutputPath = result;
|
||||
const dir = result.replace(/[\\/][^\\/]+$/, '');
|
||||
config.update({ last_save_dir: dir });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!canSave) return;
|
||||
try {
|
||||
await app.startProcess();
|
||||
} catch (err) {
|
||||
toasts.add('error', err instanceof Error ? err.message : 'Processing failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!cancelConfirm) {
|
||||
cancelConfirm = true;
|
||||
setTimeout(() => { cancelConfirm = false; }, 3000);
|
||||
return;
|
||||
}
|
||||
await app.cancelProcess();
|
||||
cancelConfirm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isProcessing}
|
||||
<!-- processing state: the bar becomes a progress indicator -->
|
||||
<div class="cta cta--processing" class:cta--both={isBoth}>
|
||||
<!-- progress fill -->
|
||||
<div
|
||||
class="cta-progress-fill"
|
||||
class:cta-progress-fill--both={isBoth}
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
|
||||
<div class="cta-icon">
|
||||
<span class="cta-spinner"></span>
|
||||
</div>
|
||||
|
||||
<div class="cta-center">
|
||||
<div class="cta-top-row">
|
||||
<span class="cta-label">
|
||||
{#if phase === 'encoding' || phase === 'retrying'}
|
||||
{Math.round(percent)}%
|
||||
{:else if phase === 'muxing'}
|
||||
Finishing...
|
||||
{:else if phase === 'analyzing'}
|
||||
Analyzing...
|
||||
{:else}
|
||||
Processing...
|
||||
{/if}
|
||||
</span>
|
||||
{#if eta > 0 && phase === 'encoding'}
|
||||
<span class="cta-badge">ETA {formatEta(eta)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cta-bottom-row">
|
||||
<span class="cta-filename">{outputName}</span>
|
||||
{#if fps > 0 || bitrate || sizeCurrent > 0}
|
||||
<span class="cta-metrics">
|
||||
{#if fps > 0}{fps.toFixed(0)} fps{/if}
|
||||
{#if bitrate}
|
||||
<span class="cta-metric-sep">·</span>{bitrate}
|
||||
{/if}
|
||||
{#if sizeCurrent > 0}
|
||||
<span class="cta-metric-sep">·</span>{formatFileSize(sizeCurrent)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cta-log-btn"
|
||||
class:cta-log-btn--active={config.current.show_ffmpeg_log}
|
||||
onclick={(e) => { e.stopPropagation(); config.update({ show_ffmpeg_log: !config.current.show_ffmpeg_log }); }}
|
||||
title="Toggle FFmpeg log"
|
||||
>
|
||||
<i class="ti ti-terminal-2" style="font-size: 13px"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cta-cancel"
|
||||
onclick={(e) => { e.stopPropagation(); handleCancel(); }}
|
||||
>
|
||||
{#if cancelConfirm}
|
||||
<i class="ti ti-x" style="font-size: 14px"></i>
|
||||
Confirm
|
||||
{:else}
|
||||
<i class="ti ti-x" style="font-size: 14px"></i>
|
||||
Cancel
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if canSave}
|
||||
<button type="button" class="cta" class:cta--both={isBoth} onclick={handleSave}>
|
||||
<div class="cta-icon">
|
||||
<i class="ti ti-player-play" style="font-size: 20px"></i>
|
||||
</div>
|
||||
|
||||
<div class="cta-center">
|
||||
<div class="cta-top-row">
|
||||
<span class="cta-label">{actionLabel}</span>
|
||||
{#if estimatedSize}
|
||||
<span class="cta-badge">{estimatedSize}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cta-bottom-row">
|
||||
<span class="cta-filename">{outputName}</span>
|
||||
<span
|
||||
class="cta-change"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); handleChangePath(); }}
|
||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter') handleChangePath(); }}
|
||||
>change</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta-shortcut">
|
||||
<kbd>Ctrl</kbd><span class="cta-shortcut-plus">+</span><kbd>S</kbd>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{:else}
|
||||
<div class="cta-empty">
|
||||
<span class="cta-empty-text">Select a preset or set trim points to get started</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.cta {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
background: var(--color-accent-compress);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: box-shadow var(--transition-default), filter var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cta:hover {
|
||||
box-shadow: 0 -2px 24px rgba(5, 150, 105, 0.35);
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
.cta--both {
|
||||
background: linear-gradient(135deg, #059669 30%, #2563EB);
|
||||
}
|
||||
.cta--both:hover {
|
||||
box-shadow: 0 -2px 24px rgba(5, 150, 105, 0.25), 0 -2px 24px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.cta--processing {
|
||||
cursor: default;
|
||||
background: var(--color-bg-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.cta--processing:hover {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.cta-progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: var(--color-accent-compress);
|
||||
transition: width 400ms ease-out;
|
||||
z-index: 0;
|
||||
}
|
||||
.cta-progress-fill--both {
|
||||
background: linear-gradient(135deg, #059669 30%, #2563EB);
|
||||
}
|
||||
|
||||
.cta-icon {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.cta--processing .cta-icon {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
}
|
||||
|
||||
.cta-spinner {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.cta--processing .cta-spinner {
|
||||
border-color: rgba(5, 150, 105, 0.3);
|
||||
border-top-color: var(--color-accent-compress);
|
||||
}
|
||||
|
||||
.cta-center {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.cta-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.cta-label {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.cta--processing .cta-label {
|
||||
color: white;
|
||||
}
|
||||
.cta-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.cta--processing .cta-badge {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cta-bottom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.cta--processing .cta-bottom-row {
|
||||
color: white;
|
||||
}
|
||||
.cta-filename {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cta-change {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cta-change:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cta-shortcut {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.cta-shortcut kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
.cta-shortcut-plus {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cta-cancel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cta-metrics {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cta-metric-sep {
|
||||
margin: 0 5px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cta-log-btn {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cta-log-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.cta-log-btn--active {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.cta-cancel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cta-cancel:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.cta-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
background: var(--color-bg-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.cta-empty-text {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
246
src/lib/components/CustomSelect.svelte
Normal file
246
src/lib/components/CustomSelect.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let { options, value, onChange, label, disabled = false, hint }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerRef: HTMLButtonElement | undefined = $state();
|
||||
let portalRef: HTMLDivElement | undefined = $state();
|
||||
let menuStyle = $state('');
|
||||
|
||||
// global registry: close all other selects when one opens
|
||||
const closeAllKey = 'cinch-select-close';
|
||||
function broadcastClose() {
|
||||
window.dispatchEvent(new CustomEvent(closeAllKey));
|
||||
}
|
||||
function onGlobalClose() {
|
||||
open = false;
|
||||
}
|
||||
$effect(() => {
|
||||
window.addEventListener(closeAllKey, onGlobalClose);
|
||||
return () => window.removeEventListener(closeAllKey, onGlobalClose);
|
||||
});
|
||||
|
||||
let displayOption = $derived(options.find((o) => o.value === value));
|
||||
let displayLabel = $derived(displayOption?.label ?? value);
|
||||
let displayIcon = $derived(displayOption?.icon);
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
if (!open) {
|
||||
broadcastClose(); // close all others first
|
||||
// small delay so the close event doesn't immediately close us
|
||||
requestAnimationFrame(() => {
|
||||
positionMenu();
|
||||
open = true;
|
||||
});
|
||||
} else {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function select(val: string) {
|
||||
onChange(val);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function positionMenu() {
|
||||
if (!triggerRef) return;
|
||||
const rect = triggerRef.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const menuHeight = Math.min(options.length * 36 + 8, 240);
|
||||
|
||||
if (spaceBelow < menuHeight + 8) {
|
||||
// open above
|
||||
menuStyle = `bottom: ${window.innerHeight - rect.top + 4}px; left: ${rect.left}px; width: ${rect.width}px;`;
|
||||
} else {
|
||||
// open below
|
||||
menuStyle = `top: ${rect.bottom + 4}px; left: ${rect.left}px; width: ${rect.width}px;`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (open && triggerRef && !triggerRef.contains(e.target as Node)) {
|
||||
if (portalRef && portalRef.contains(e.target as Node)) return;
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (open && e.key === 'Escape') {
|
||||
open = false;
|
||||
triggerRef?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function portal(node: HTMLElement) {
|
||||
document.body.appendChild(node);
|
||||
return { destroy() { node.remove(); } };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="cs-wrap">
|
||||
<button
|
||||
type="button"
|
||||
bind:this={triggerRef}
|
||||
class="cs-trigger"
|
||||
class:cs-trigger--disabled={disabled}
|
||||
class:cs-trigger--open={open}
|
||||
{disabled}
|
||||
onclick={toggle}
|
||||
>
|
||||
{#if displayIcon}
|
||||
<i class="ti {displayIcon}" style="font-size: 14px; color: var(--color-accent-compress)"></i>
|
||||
{/if}
|
||||
<span class="cs-trigger-label">{displayLabel}</span>
|
||||
<i class="ti ti-chevron-down cs-chevron" class:cs-chevron--open={open}></i>
|
||||
</button>
|
||||
{#if hint}
|
||||
<span class="cs-hint">{hint}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div bind:this={portalRef} class="cs-portal" use:portal style="position:fixed;{menuStyle};z-index:9999">
|
||||
<div class="cs-menu">
|
||||
{#each options as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="cs-option"
|
||||
class:cs-option--active={opt.value === value}
|
||||
onclick={() => select(opt.value)}
|
||||
>
|
||||
{#if opt.icon}
|
||||
<i class="ti {opt.icon}" style="font-size: 14px"></i>
|
||||
{/if}
|
||||
<span class="cs-option-label">{opt.label}</span>
|
||||
{#if opt.value === value}
|
||||
<i class="ti ti-check" style="font-size: 14px; margin-left: auto"></i>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.cs-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.cs-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.cs-trigger:hover {
|
||||
border-color: var(--color-text-disabled);
|
||||
}
|
||||
.cs-trigger--open {
|
||||
border-color: var(--color-accent-compress);
|
||||
}
|
||||
.cs-trigger--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cs-trigger-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.cs-chevron {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-disabled);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.cs-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.cs-hint {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.cs-portal {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.cs-menu {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
padding: 4px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
animation: csMenuIn 150ms ease-out;
|
||||
}
|
||||
.cs-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
.cs-option:hover {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
.cs-option--active {
|
||||
background: var(--color-accent-compress);
|
||||
color: white;
|
||||
}
|
||||
.cs-option--active:hover {
|
||||
background: var(--color-accent-compress);
|
||||
}
|
||||
.cs-option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@keyframes csMenuIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
295
src/lib/components/DropZone.svelte
Normal file
295
src/lib/components/DropZone.svelte
Normal file
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import { config } from '$lib/stores/config.svelte';
|
||||
|
||||
const allowedExts = [
|
||||
'.mp4', '.mkv', '.avi', '.mov', '.webm', '.flv', '.ts',
|
||||
'.m2ts', '.wmv', '.mpg', '.mpeg', '.3gp', '.m4v', '.vob'
|
||||
];
|
||||
|
||||
let dragOver = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const onOpen = () => browseFile();
|
||||
window.addEventListener('cinch-open-file', onOpen);
|
||||
return () => window.removeEventListener('cinch-open-file', onOpen);
|
||||
});
|
||||
|
||||
function getExtension(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot !== -1 ? name.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isVideoFile(name: string): boolean {
|
||||
return allowedExts.includes(getExtension(name));
|
||||
}
|
||||
|
||||
async function browseFile() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const result = await open({
|
||||
multiple: false,
|
||||
defaultPath: config.current.last_open_dir ?? undefined,
|
||||
filters: [{
|
||||
name: 'Video',
|
||||
extensions: allowedExts.map((e) => e.slice(1))
|
||||
}]
|
||||
});
|
||||
if (result) {
|
||||
const path = typeof result === 'string' ? result : result.path;
|
||||
if (path) handleFile(path);
|
||||
}
|
||||
} catch {
|
||||
toasts.add('error', 'Could not open file browser.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFile(path: string) {
|
||||
const name = path.split(/[\\/]/).pop() ?? path;
|
||||
if (!isVideoFile(name)) {
|
||||
toasts.add('error', "This doesn't look like a video file.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.loadVideo(path);
|
||||
// remember the directory we opened from
|
||||
const dir = path.replace(/[\\/][^\\/]+$/, '');
|
||||
config.update({ last_open_dir: dir });
|
||||
} catch (err) {
|
||||
toasts.add('error', err instanceof Error ? err.message : "Couldn't read this file.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
const path = (file as any).path ?? file.name;
|
||||
handleFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOver = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="drop-page"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
role="region"
|
||||
aria-label="File drop area"
|
||||
>
|
||||
<!-- ambient glow -->
|
||||
<div class="drop-glow"></div>
|
||||
|
||||
<!-- main drop target -->
|
||||
<button type="button" class="drop-target" class:drop-target--active={dragOver} onclick={browseFile}>
|
||||
<!-- animated icon -->
|
||||
<div class="drop-icon" class:drop-icon--active={dragOver}>
|
||||
<div class="drop-icon-ring"></div>
|
||||
<i class="ti ti-movie" style="font-size: 2rem"></i>
|
||||
</div>
|
||||
|
||||
<!-- text -->
|
||||
<div class="drop-text">
|
||||
<h2 class="drop-title">
|
||||
{#if dragOver}
|
||||
Release to open
|
||||
{:else}
|
||||
Drop a video here
|
||||
{/if}
|
||||
</h2>
|
||||
<p class="drop-sub">or click to browse your files</p>
|
||||
</div>
|
||||
|
||||
<!-- supported formats -->
|
||||
<div class="drop-formats">
|
||||
<span class="drop-format-tag">MP4</span>
|
||||
<span class="drop-format-tag">MKV</span>
|
||||
<span class="drop-format-tag">AVI</span>
|
||||
<span class="drop-format-tag">MOV</span>
|
||||
<span class="drop-format-tag">WebM</span>
|
||||
<span class="drop-format-tag">FLV</span>
|
||||
<span class="drop-format-dot">+8 more</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- keyboard shortcut hint -->
|
||||
<div class="drop-hint">
|
||||
<kbd>Ctrl</kbd><span>+</span><kbd>O</kbd>
|
||||
<span class="drop-hint-text">to browse</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.drop-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drop-glow {
|
||||
position: absolute;
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(5, 150, 105, 0.08) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
animation: glowPulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.drop-target {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 3rem 4rem;
|
||||
background: transparent;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 300ms ease, background 300ms ease, transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 300ms ease;
|
||||
max-width: 30rem;
|
||||
width: 100%;
|
||||
}
|
||||
.drop-target:hover {
|
||||
border-color: rgba(5, 150, 105, 0.4);
|
||||
background: rgba(5, 150, 105, 0.03);
|
||||
}
|
||||
.drop-target--active {
|
||||
border-color: var(--color-accent-compress);
|
||||
border-style: solid;
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 40px rgba(5, 150, 105, 0.15);
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
position: relative;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-disabled);
|
||||
transition: color 300ms ease, transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.drop-icon:hover, .drop-icon--active {
|
||||
color: var(--color-accent-compress);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.drop-icon-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
transition: border-color 300ms ease, transform 300ms ease;
|
||||
animation: ringRotate 12s linear infinite;
|
||||
}
|
||||
.drop-icon--active .drop-icon-ring {
|
||||
border-color: var(--color-accent-compress);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.drop-text {
|
||||
text-align: center;
|
||||
}
|
||||
.drop-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.drop-sub {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.drop-formats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.drop-format-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-disabled);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.44rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.drop-format-dot {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 1.5rem;
|
||||
opacity: 0.4;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
.drop-hint:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.drop-hint kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.drop-hint span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.drop-hint-text {
|
||||
margin-left: 0.25rem;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.6; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes ringRotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
70
src/lib/components/FfmpegLog.svelte
Normal file
70
src/lib/components/FfmpegLog.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
|
||||
interface Props {
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
let { visible = false }: Props = $props();
|
||||
|
||||
let lines: string[] = $state([]);
|
||||
let logEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// collect log lines from progress events
|
||||
$effect(() => {
|
||||
if (app.progress?.message) {
|
||||
lines = [...lines, app.progress.message];
|
||||
}
|
||||
});
|
||||
|
||||
// auto-scroll
|
||||
$effect(() => {
|
||||
if (logEl && lines.length > 0) {
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
export function clear() {
|
||||
lines = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="w-full rounded-lg overflow-hidden"
|
||||
style="
|
||||
background: #1a1a2e;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
animation: logExpand 250ms ease-out;
|
||||
"
|
||||
>
|
||||
<div
|
||||
bind:this={logEl}
|
||||
class="p-3 overflow-y-auto"
|
||||
style="max-height: 160px; min-height: 80px"
|
||||
>
|
||||
{#each lines as line, i}
|
||||
<div
|
||||
class="text-xs leading-relaxed"
|
||||
style="color: var(--color-text-secondary); font-family: var(--font-mono); white-space: pre-wrap; word-break: break-all"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if lines.length === 0}
|
||||
<div class="text-xs" style="color: var(--color-text-disabled); font-family: var(--font-mono)">
|
||||
Waiting for output...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes logExpand {
|
||||
from { opacity: 0; max-height: 0; }
|
||||
to { opacity: 1; max-height: 200px; }
|
||||
}
|
||||
</style>
|
||||
116
src/lib/components/FileInfoBar.svelte
Normal file
116
src/lib/components/FileInfoBar.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { app, view } from '$lib/stores/app.svelte';
|
||||
import { formatFileSize, formatDuration } from '$lib/utils/format';
|
||||
|
||||
let name = $derived(app.videoInfo?.path.split(/[\\/]/).pop() ?? '');
|
||||
let res = $derived(app.videoInfo ? `${app.videoInfo.width}x${app.videoInfo.height}` : '');
|
||||
let dur = $derived(app.videoInfo ? formatDuration(app.videoInfo.duration) : '');
|
||||
let codec = $derived(app.videoInfo?.video_codec?.toUpperCase() ?? '');
|
||||
let size = $derived(app.videoInfo ? formatFileSize(app.videoInfo.file_size) : '');
|
||||
</script>
|
||||
|
||||
<div class="info-bar">
|
||||
<i class="ti ti-movie" style="font-size: 14px; color: var(--color-accent-compress); flex-shrink: 0"></i>
|
||||
|
||||
<span class="info-name">{name}</span>
|
||||
|
||||
<div class="info-tags">
|
||||
{#if res}
|
||||
<span class="info-tag">{res}</span>
|
||||
{/if}
|
||||
{#if dur}
|
||||
<span class="info-tag">{dur}</span>
|
||||
{/if}
|
||||
{#if codec}
|
||||
<span class="info-tag">{codec}</span>
|
||||
{/if}
|
||||
{#if size}
|
||||
<span class="info-tag info-tag--accent">{size}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="info-close"
|
||||
class:info-close--disabled={view.current === 'processing'}
|
||||
title="Remove video"
|
||||
aria-label="Remove video"
|
||||
disabled={view.current === 'processing'}
|
||||
onclick={() => app.resetToEmpty()}
|
||||
onmouseenter={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.color = 'var(--color-text-primary)';
|
||||
el.style.background = 'var(--color-bg-elevated)';
|
||||
}}
|
||||
onmouseleave={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.color = 'var(--color-text-disabled)';
|
||||
el.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
<i class="ti ti-x" style="font-size: 11px"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.info-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 20px;
|
||||
height: 34px;
|
||||
background: var(--color-bg-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.info-name {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.info-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.info-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-disabled);
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.info-tag--accent {
|
||||
color: var(--color-accent-compress);
|
||||
border-color: rgba(5, 150, 105, 0.2);
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
}
|
||||
.info-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-disabled);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.info-close--disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
166
src/lib/components/MainView.svelte
Normal file
166
src/lib/components/MainView.svelte
Normal file
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { app, view } from '$lib/stores/app.svelte';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import { config } from '$lib/stores/config.svelte';
|
||||
import DropZone from '$lib/components/DropZone.svelte';
|
||||
import AnalyzingSkeleton from '$lib/components/AnalyzingSkeleton.svelte';
|
||||
import VideoPreview from '$lib/components/VideoPreview.svelte';
|
||||
import Timeline from '$lib/components/Timeline.svelte';
|
||||
import CompressPresets from '$lib/components/CompressPresets.svelte';
|
||||
import AdvancedOptions from '$lib/components/AdvancedOptions.svelte';
|
||||
import CtaBar from '$lib/components/CtaBar.svelte';
|
||||
import StatsCard from '$lib/components/StatsCard.svelte';
|
||||
import FileInfoBar from '$lib/components/FileInfoBar.svelte';
|
||||
import FfmpegLog from '$lib/components/FfmpegLog.svelte';
|
||||
import SetupWizard from '$lib/components/SetupWizard.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const allowedExts = [
|
||||
'.mp4', '.mkv', '.avi', '.mov', '.webm', '.flv', '.ts',
|
||||
'.m2ts', '.wmv', '.mpg', '.mpeg', '.3gp', '.m4v', '.vob'
|
||||
];
|
||||
|
||||
let currentTime = $state(0);
|
||||
let windowDragOver = $state(false);
|
||||
let videoPreview: VideoPreview | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
const s = app.state;
|
||||
if (s === 'loaded' && app.videoInfo?.is_vfr) {
|
||||
toasts.add('warning', 'Variable frame rate video. Compression will convert to constant frame rate.');
|
||||
}
|
||||
});
|
||||
|
||||
function isVideoFile(name: string) {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot !== -1 && allowedExts.includes(name.slice(dot).toLowerCase());
|
||||
}
|
||||
|
||||
async function handleWindowDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
windowDragOver = false;
|
||||
if (view.current === 'setup' || view.current === 'analyzing') return;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const path = (file as any).path ?? file.name;
|
||||
if (!isVideoFile(path.split(/[\\/]/).pop() ?? '')) {
|
||||
toasts.add('error', "Not a video file.");
|
||||
return;
|
||||
}
|
||||
try { await app.loadVideo(path); }
|
||||
catch (err) { toasts.add('error', String(err)); }
|
||||
}
|
||||
|
||||
function handleSeek(time: number) {
|
||||
currentTime = time;
|
||||
videoPreview?.seekTo(time);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
app.registerSeekCallback((time: number) => {
|
||||
currentTime = time;
|
||||
videoPreview?.seekTo(time);
|
||||
});
|
||||
app.registerPlaybackCallback(() => {
|
||||
videoPreview?.togglePlay();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 relative"
|
||||
class:window-drag-over={windowDragOver}
|
||||
ondrop={handleWindowDrop}
|
||||
ondragover={(e) => { e.preventDefault(); windowDragOver = true; }}
|
||||
ondragleave={() => { windowDragOver = false; }}
|
||||
role="main"
|
||||
>
|
||||
{#if windowDragOver && (view.current === 'loaded' || view.current === 'processing')}
|
||||
<div class="window-drag-overlay">Drop a new video to replace</div>
|
||||
{/if}
|
||||
{#if view.current === 'setup'}
|
||||
<SetupWizard />
|
||||
|
||||
{:else if view.current === 'empty'}
|
||||
<DropZone />
|
||||
|
||||
{:else if view.current === 'analyzing'}
|
||||
<AnalyzingSkeleton />
|
||||
|
||||
{:else if view.current === 'done'}
|
||||
<StatsCard />
|
||||
|
||||
{:else if view.current === 'loaded' || view.current === 'processing'}
|
||||
<FileInfoBar />
|
||||
|
||||
<div class="loaded-layout">
|
||||
<div class="video-area">
|
||||
<VideoPreview
|
||||
bind:this={videoPreview}
|
||||
bind:currentTime
|
||||
onTimeUpdate={(t) => { currentTime = t; app.currentTime = t; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="controls-panel">
|
||||
<div><Timeline {currentTime} onSeek={handleSeek} /></div>
|
||||
<div>
|
||||
<CompressPresets
|
||||
showAdvanced={app.selectedPreset !== null || app.compressSettings.strategy.type !== 'TargetSize'}
|
||||
onAdvancedClick={() => { app.advancedOpen = !app.advancedOpen; }}
|
||||
/>
|
||||
</div>
|
||||
<AdvancedOptions />
|
||||
|
||||
{#if view.current === 'processing' && config.current.show_ffmpeg_log}
|
||||
<div><FfmpegLog visible={true} /></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CtaBar />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loaded-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.video-area {
|
||||
padding: 16px 20px 8px;
|
||||
}
|
||||
.controls-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 8px 20px 16px;
|
||||
}
|
||||
|
||||
.window-drag-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
border: 2px dashed var(--color-accent-compress);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: 8px;
|
||||
color: white;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-lg);
|
||||
pointer-events: none;
|
||||
animation: dragPulse 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes dragPulse {
|
||||
from { opacity: 0; transform: scale(0.98); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
97
src/lib/components/SegmentedControl.svelte
Normal file
97
src/lib/components/SegmentedControl.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
options: string[];
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
small?: boolean;
|
||||
}
|
||||
|
||||
let { options, selected, onChange, small = false }: Props = $props();
|
||||
|
||||
let containerRef: HTMLDivElement | undefined = $state();
|
||||
let pillStyle = $state('');
|
||||
|
||||
function updatePill() {
|
||||
if (!containerRef) return;
|
||||
const idx = options.indexOf(selected);
|
||||
if (idx === -1) { pillStyle = 'opacity:0'; return; }
|
||||
const btns = containerRef.querySelectorAll<HTMLButtonElement>('[data-seg-btn]');
|
||||
const btn = btns[idx];
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const parentRect = containerRef.getBoundingClientRect();
|
||||
pillStyle = `left:${rect.left - parentRect.left}px;width:${rect.width}px;opacity:1`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
selected;
|
||||
options;
|
||||
requestAnimationFrame(updatePill);
|
||||
window.addEventListener('resize', updatePill);
|
||||
return () => window.removeEventListener('resize', updatePill);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="seg-container"
|
||||
class:seg-small={small}
|
||||
>
|
||||
<div class="seg-pill" style={pillStyle}></div>
|
||||
{#each options as opt}
|
||||
<button
|
||||
type="button"
|
||||
data-seg-btn
|
||||
class="seg-btn"
|
||||
class:seg-btn-active={selected === opt}
|
||||
class:seg-btn-small={small}
|
||||
onclick={() => onChange(opt)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.seg-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.seg-pill {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-compress);
|
||||
transition: left var(--transition-spring), width var(--transition-spring);
|
||||
opacity: 0;
|
||||
}
|
||||
.seg-btn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 6px 14px;
|
||||
font-size: var(--text-sm);
|
||||
transition: color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.seg-btn-small {
|
||||
padding: 4px 12px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.seg-btn-active {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
533
src/lib/components/SetupWizard.svelte
Normal file
533
src/lib/components/SetupWizard.svelte
Normal file
@@ -0,0 +1,533 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { checkFfmpeg, downloadFfmpeg } from '$lib/utils/tauri';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
|
||||
let step = $state(1);
|
||||
let detecting = $state(false);
|
||||
let downloading = $state(false);
|
||||
let dlPercent = $state(0);
|
||||
let dlMessage = $state('');
|
||||
let dlPhase = $state('');
|
||||
let copied = $state(false);
|
||||
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
async function setupProgressListener() {
|
||||
unlisten = await listen<{ phase: string; percent: number; message: string }>('ffmpeg-download-progress', (ev) => {
|
||||
dlPercent = ev.payload.percent;
|
||||
dlMessage = ev.payload.message;
|
||||
dlPhase = ev.payload.phase;
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => { unlisten?.(); });
|
||||
|
||||
async function detect() {
|
||||
detecting = true;
|
||||
try {
|
||||
const status = await checkFfmpeg();
|
||||
app.ffmpegStatus = status;
|
||||
if (status.found) {
|
||||
step = 2;
|
||||
} else {
|
||||
toasts.add('warning', 'FFmpeg not found. Install it or browse to the binary.');
|
||||
}
|
||||
} catch {
|
||||
toasts.add('error', 'Could not detect FFmpeg.');
|
||||
}
|
||||
detecting = false;
|
||||
}
|
||||
|
||||
async function browse() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const result = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: 'FFmpeg', extensions: ['exe'] }]
|
||||
});
|
||||
if (result) {
|
||||
const path = typeof result === 'string' ? result : result.path;
|
||||
if (path) {
|
||||
app.ffmpegStatus = { ...app.ffmpegStatus, path };
|
||||
await detect();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toasts.add('error', 'Could not open file browser.');
|
||||
}
|
||||
}
|
||||
|
||||
async function autoDownload() {
|
||||
downloading = true;
|
||||
dlPercent = 0;
|
||||
dlMessage = 'Connecting...';
|
||||
dlPhase = 'downloading';
|
||||
await setupProgressListener();
|
||||
try {
|
||||
const status = await downloadFfmpeg();
|
||||
app.ffmpegStatus = status;
|
||||
if (status.found) {
|
||||
step = 2;
|
||||
} else {
|
||||
toasts.add('error', 'Download completed but FFmpeg could not be verified.');
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.add('error', err instanceof Error ? err.message : 'Download failed. Check your internet connection.');
|
||||
}
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
downloading = false;
|
||||
}
|
||||
|
||||
function copyWinget() {
|
||||
navigator.clipboard.writeText('winget install Gyan.FFmpeg');
|
||||
copied = true;
|
||||
setTimeout(() => { copied = false; }, 2000);
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
app.state = 'empty';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center overflow-y-auto" style="padding: 32px 32px; scrollbar-width: none;">
|
||||
<!-- progress dots -->
|
||||
<div class="flex items-center gap-3" style="margin-bottom: 24px; margin-top: auto;">
|
||||
<div
|
||||
style="
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: {step >= 1 ? 'var(--color-accent-compress)' : 'var(--color-border)'};
|
||||
transition: background var(--transition-default);
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
style="
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: {step >= 2 ? 'var(--color-accent-compress)' : 'var(--color-border)'};
|
||||
transition: background var(--transition-default);
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
style="
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: {step >= 2 ? 'var(--color-accent-compress)' : 'var(--color-border)'};
|
||||
transition: background var(--transition-default);
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{#if step === 1}
|
||||
<div class="flex flex-col items-center" style="max-width: 420px; width: 100%;">
|
||||
<!-- icon -->
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
style="
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
border: 1px solid rgba(5, 150, 105, 0.15);
|
||||
margin-bottom: 20px;
|
||||
"
|
||||
>
|
||||
<i class="ti ti-video" style="font-size: 28px; color: var(--color-accent-compress)"></i>
|
||||
</div>
|
||||
|
||||
<!-- heading -->
|
||||
<h1
|
||||
style="
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
Cinch needs FFmpeg
|
||||
</h1>
|
||||
|
||||
<!-- description -->
|
||||
<p
|
||||
style="
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
"
|
||||
>
|
||||
FFmpeg is the video engine that powers compression and trimming.
|
||||
</p>
|
||||
|
||||
<!-- auto download button -->
|
||||
<button
|
||||
type="button"
|
||||
class="relative cursor-pointer w-full overflow-hidden"
|
||||
style="
|
||||
height: 48px;
|
||||
background: {downloading ? 'var(--color-bg-surface)' : 'var(--color-accent-compress)'};
|
||||
border: {downloading ? '1px solid var(--color-border)' : 'none'};
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 20px;
|
||||
transition: box-shadow var(--transition-default), background var(--transition-default);
|
||||
"
|
||||
disabled={downloading || detecting}
|
||||
onclick={autoDownload}
|
||||
onmouseenter={(e) => { if (!downloading) (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-glow-compress)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||
>
|
||||
<!-- progress fill -->
|
||||
{#if downloading}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: {dlPercent}%;
|
||||
background: var(--color-accent-compress);
|
||||
transition: width 400ms ease-out;
|
||||
border-radius: var(--radius-md);
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- label -->
|
||||
<div class="relative flex items-center justify-center gap-2" style="height: 100%; color: {downloading ? 'white' : 'white'}; z-index: 1;">
|
||||
{#if downloading}
|
||||
{#if dlPhase === 'downloading'}
|
||||
<span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
<span>{dlMessage || `${dlPercent}%`}</span>
|
||||
{:else}
|
||||
<span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
<span>{dlMessage}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<i class="ti ti-download" style="font-size: 18px"></i>
|
||||
Download FFmpeg for me
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- manual options divider -->
|
||||
<div class="flex items-center gap-3 w-full" style="margin-bottom: 16px;">
|
||||
<div style="flex: 1; height: 1px; background: var(--color-border)"></div>
|
||||
<span style="font-family: var(--font-body); font-size: var(--text-xs); color: var(--color-text-disabled);">or install manually</span>
|
||||
<div style="flex: 1; height: 1px; background: var(--color-border)"></div>
|
||||
</div>
|
||||
|
||||
<!-- download card -->
|
||||
<div
|
||||
style="
|
||||
width: 100%;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
"
|
||||
>
|
||||
<!-- download links -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a
|
||||
href="https://www.gyan.dev/ffmpeg/builds/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2"
|
||||
style="
|
||||
color: var(--color-accent-compress);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(5, 150, 105, 0.06);
|
||||
transition: background var(--transition-fast);
|
||||
margin-bottom: 8px;
|
||||
"
|
||||
onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(5,150,105,0.12)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(5,150,105,0.06)'; }}
|
||||
>
|
||||
<i class="ti ti-download" style="font-size: 16px"></i>
|
||||
gyan.dev builds
|
||||
<span style="font-weight: 400; color: var(--color-text-disabled); margin-left: auto; font-size: var(--text-xs)">recommended</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/BtbN/FFmpeg-Builds/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2"
|
||||
style="
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
"
|
||||
onmouseenter={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = 'var(--color-bg-elevated)';
|
||||
el.style.color = 'var(--color-text-primary)';
|
||||
}}
|
||||
onmouseleave={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = 'transparent';
|
||||
el.style.color = 'var(--color-text-secondary)';
|
||||
}}
|
||||
>
|
||||
<i class="ti ti-brand-github" style="font-size: 16px"></i>
|
||||
BtbN GitHub builds
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- divider -->
|
||||
<div style="height: 1px; background: var(--color-border); margin-bottom: 16px;"></div>
|
||||
|
||||
<!-- winget -->
|
||||
<span
|
||||
style="
|
||||
display: block;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
margin-bottom: 8px;
|
||||
"
|
||||
>
|
||||
Or install via winget:
|
||||
</span>
|
||||
<div
|
||||
class="flex items-center"
|
||||
style="
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
<code
|
||||
style="
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
user-select: all;
|
||||
"
|
||||
>
|
||||
winget install Gyan.FFmpeg
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center cursor-pointer"
|
||||
style="
|
||||
width: 42px;
|
||||
align-self: stretch;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
color: {copied ? 'var(--color-accent-compress)' : 'var(--color-text-secondary)'};
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
"
|
||||
onclick={copyWinget}
|
||||
title={copied ? 'Copied!' : 'Copy command'}
|
||||
aria-label="Copy winget command"
|
||||
onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-elevated)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.background = 'none'; }}
|
||||
>
|
||||
<i class={copied ? 'ti ti-check' : 'ti ti-copy'} style="font-size: 16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="flex gap-3 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-2 cursor-pointer"
|
||||
style="
|
||||
height: 44px;
|
||||
background: var(--color-accent-compress);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: {detecting ? 0.7 : 1};
|
||||
transition: box-shadow var(--transition-default);
|
||||
"
|
||||
disabled={detecting}
|
||||
onclick={detect}
|
||||
onmouseenter={(e) => { if (!detecting) (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-glow-compress)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||
>
|
||||
{#if detecting}
|
||||
<span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
{:else}
|
||||
<i class="ti ti-search" style="font-size: 16px"></i>
|
||||
{/if}
|
||||
Detect
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-2 cursor-pointer"
|
||||
style="
|
||||
height: 44px;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
"
|
||||
onclick={browse}
|
||||
onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-elevated)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-surface)'; }}
|
||||
>
|
||||
<i class="ti ti-folder" style="font-size: 16px"></i>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- skip link -->
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer"
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-disabled);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 16px;
|
||||
margin-bottom: auto;
|
||||
text-decoration: underline;
|
||||
transition: color var(--transition-fast);
|
||||
"
|
||||
onclick={proceed}
|
||||
onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.color = 'var(--color-text-disabled)'; }}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Step 2: FFmpeg found -->
|
||||
<div class="flex flex-col items-center" style="max-width: 420px; width: 100%; text-align: center;">
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
style="
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
margin-bottom: 24px;
|
||||
animation: scaleIn 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
"
|
||||
>
|
||||
<i class="ti ti-circle-check" style="font-size: 40px; color: var(--color-accent-compress)"></i>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style="
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 16px;
|
||||
"
|
||||
>
|
||||
FFmpeg found
|
||||
</h1>
|
||||
|
||||
<div style="margin-bottom: 32px;">
|
||||
{#if app.ffmpegStatus.path}
|
||||
<span
|
||||
style="
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
margin-bottom: 6px;
|
||||
word-break: break-all;
|
||||
"
|
||||
>
|
||||
{app.ffmpegStatus.path}
|
||||
</span>
|
||||
{/if}
|
||||
{#if app.ffmpegStatus.version}
|
||||
<span
|
||||
style="
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent-compress);
|
||||
"
|
||||
>
|
||||
Version {app.ffmpegStatus.version}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-2 cursor-pointer"
|
||||
style="
|
||||
height: 44px;
|
||||
padding: 0 32px;
|
||||
background: var(--color-accent-compress);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
transition: box-shadow var(--transition-default);
|
||||
"
|
||||
onclick={proceed}
|
||||
onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow-glow-compress)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||
>
|
||||
Get started
|
||||
<i class="ti ti-arrow-right" style="font-size: 16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
156
src/lib/components/Slider.svelte
Normal file
156
src/lib/components/Slider.svelte
Normal file
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
labels?: { value: number; label: string }[];
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
let { min, max, step, value, labels, onChange }: Props = $props();
|
||||
|
||||
let trackRef: HTMLDivElement | undefined = $state();
|
||||
let dragging = $state(false);
|
||||
|
||||
let fillPercent = $derived(((value - min) / (max - min)) * 100);
|
||||
|
||||
let currentLabel = $derived.by(() => {
|
||||
if (!labels) return null;
|
||||
let closest = labels[0];
|
||||
let dist = Math.abs(value - closest.value);
|
||||
for (const l of labels) {
|
||||
const d = Math.abs(value - l.value);
|
||||
if (d < dist) { closest = l; dist = d; }
|
||||
}
|
||||
return closest?.label ?? null;
|
||||
});
|
||||
|
||||
function valueFromEvent(e: MouseEvent | PointerEvent) {
|
||||
if (!trackRef) return value;
|
||||
const rect = trackRef.getBoundingClientRect();
|
||||
let pct = (e.clientX - rect.left) / rect.width;
|
||||
pct = Math.max(0, Math.min(1, pct));
|
||||
let raw = min + pct * (max - min);
|
||||
|
||||
if (labels && labels.length > 0) {
|
||||
let closest = labels[0].value;
|
||||
let dist = Math.abs(raw - closest);
|
||||
for (const l of labels) {
|
||||
const d = Math.abs(raw - l.value);
|
||||
if (d < dist) { closest = l.value; dist = d; }
|
||||
}
|
||||
const snapThreshold = (max - min) * 0.08;
|
||||
if (dist < snapThreshold) raw = closest;
|
||||
}
|
||||
|
||||
raw = Math.round(raw / step) * step;
|
||||
return Math.max(min, Math.min(max, raw));
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
dragging = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onChange(valueFromEvent(e));
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
onChange(valueFromEvent(e));
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
dragging = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="slider-wrap">
|
||||
{#if currentLabel}
|
||||
<div class="slider-value">{currentLabel}</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={trackRef}
|
||||
class="slider-track"
|
||||
role="slider"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
tabindex="0"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
>
|
||||
<div class="slider-fill" style="width: {fillPercent}%"></div>
|
||||
<div
|
||||
class="slider-thumb"
|
||||
style="left: {fillPercent}%; transition: {dragging ? 'none' : 'left var(--transition-fast)'};"
|
||||
></div>
|
||||
</div>
|
||||
{#if labels}
|
||||
<div class="slider-labels">
|
||||
{#each labels as stop, i}
|
||||
{@const pct = ((stop.value - min) / (max - min)) * 100}
|
||||
<span
|
||||
class="slider-label"
|
||||
style="left: {pct}%; {i === 0 ? 'transform: none; text-align: left' : i === labels.length - 1 ? 'transform: translateX(-100%); text-align: right' : 'transform: translateX(-50%); text-align: center'}"
|
||||
>
|
||||
{stop.label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.slider-value {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent-compress);
|
||||
text-align: right;
|
||||
}
|
||||
.slider-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--color-accent-compress);
|
||||
}
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid var(--color-accent-compress);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
.slider-labels {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.slider-label {
|
||||
position: absolute;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-disabled);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
276
src/lib/components/StatsCard.svelte
Normal file
276
src/lib/components/StatsCard.svelte
Normal file
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { config } from '$lib/stores/config.svelte';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import { openInExplorer } from '$lib/utils/tauri';
|
||||
import { formatFileSize, formatPercent, formatDuration } from '$lib/utils/format';
|
||||
|
||||
$effect(() => {
|
||||
const attempts = app.outputInfo?.attempts ?? 0;
|
||||
const max = config.current.max_retry_attempts;
|
||||
if (attempts >= max && attempts > 1) {
|
||||
toasts.add('warning', `Couldn't hit target size exactly - closest result after ${attempts} attempts.`);
|
||||
}
|
||||
});
|
||||
|
||||
let beforeSize = $derived(app.videoInfo?.file_size ?? 0);
|
||||
let afterSize = $derived(app.outputInfo?.file_size ?? 0);
|
||||
let reduction = $derived(beforeSize > 0 ? ((beforeSize - afterSize) / beforeSize) * 100 : 0);
|
||||
let isTrimOnly = $derived(app.activeMode === 'trim');
|
||||
let outPath = $derived(app.outputInfo?.path ?? '');
|
||||
let outDir = $derived(outPath.replace(/[\\/][^\\/]+$/, ''));
|
||||
let fileName = $derived(outPath.split(/[\\/]/).pop() ?? '');
|
||||
|
||||
async function openFile() {
|
||||
if (outPath) {
|
||||
const { openPath } = await import('@tauri-apps/plugin-opener');
|
||||
openPath(outPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function openFolder() {
|
||||
if (outPath) {
|
||||
const { revealItemInDir } = await import('@tauri-apps/plugin-opener');
|
||||
revealItemInDir(outPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function newVideo() {
|
||||
app.resetToEmpty();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="stats-page">
|
||||
<div class="stats-card">
|
||||
<!-- success icon -->
|
||||
<div class="stats-icon">
|
||||
<i class="ti ti-circle-check" style="font-size: 32px"></i>
|
||||
</div>
|
||||
|
||||
<!-- big percentage -->
|
||||
<div class="stats-hero">
|
||||
{#if isTrimOnly}
|
||||
<span class="stats-percent">{formatDuration(app.outputInfo?.duration ?? 0)}</span>
|
||||
<span class="stats-label">trimmed</span>
|
||||
{:else}
|
||||
<span class="stats-percent">{formatPercent(reduction)}</span>
|
||||
<span class="stats-label">smaller</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- before / after comparison -->
|
||||
<div class="stats-compare">
|
||||
<div class="stats-col">
|
||||
<span class="stats-col-label">Before</span>
|
||||
<span class="stats-col-size">{formatFileSize(beforeSize)}</span>
|
||||
<span class="stats-col-meta">{app.videoInfo?.video_codec ?? ''} - {app.videoInfo ? `${app.videoInfo.width}x${app.videoInfo.height}` : ''}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-arrow">
|
||||
<i class="ti ti-chevron-right" style="font-size: 18px"></i>
|
||||
</div>
|
||||
|
||||
<div class="stats-col stats-col--after">
|
||||
<span class="stats-col-label">After</span>
|
||||
<span class="stats-col-size stats-col-size--accent">{formatFileSize(afterSize)}</span>
|
||||
<span class="stats-col-meta">{app.outputInfo?.video_codec ?? ''} - {app.outputInfo ? `${app.outputInfo.width}x${app.outputInfo.height}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- output file name -->
|
||||
<div class="stats-file">
|
||||
<i class="ti ti-file-check" style="font-size: 14px; color: var(--color-accent-compress)"></i>
|
||||
<span class="stats-file-name">{fileName}</span>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="stats-actions">
|
||||
<button type="button" class="stats-btn stats-btn--primary" onclick={openFile}>
|
||||
<i class="ti ti-player-play" style="font-size: 16px"></i>
|
||||
Open file
|
||||
</button>
|
||||
<button type="button" class="stats-btn" onclick={openFolder}>
|
||||
<i class="ti ti-folder" style="font-size: 16px"></i>
|
||||
Show in folder
|
||||
</button>
|
||||
<button type="button" class="stats-btn" onclick={newVideo}>
|
||||
<i class="ti ti-plus" style="font-size: 16px"></i>
|
||||
New video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if app.outputInfo && app.outputInfo.attempts > 1}
|
||||
<div class="stats-retry">
|
||||
Took {app.outputInfo.attempts} attempts to reach target size
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
animation: statsIn 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 32px 28px 28px;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(5, 150, 105, 0.12);
|
||||
color: var(--color-accent-compress);
|
||||
}
|
||||
|
||||
.stats-hero {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.stats-percent {
|
||||
font-family: var(--font-display);
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
color: var(--color-accent-compress);
|
||||
line-height: 1;
|
||||
}
|
||||
.stats-label {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stats-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-base);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.stats-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.stats-col--after {
|
||||
text-align: right;
|
||||
}
|
||||
.stats-col-label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.stats-col-size {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.stats-col-size--accent {
|
||||
color: var(--color-accent-compress);
|
||||
}
|
||||
.stats-col-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.stats-arrow {
|
||||
color: var(--color-text-disabled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.stats-file-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stats-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.stats-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 0;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.stats-btn:hover {
|
||||
background: var(--color-bg-surface);
|
||||
border-color: var(--color-text-disabled);
|
||||
}
|
||||
.stats-btn--primary {
|
||||
background: var(--color-accent-compress);
|
||||
border-color: var(--color-accent-compress);
|
||||
color: white;
|
||||
}
|
||||
.stats-btn--primary:hover {
|
||||
background: var(--color-accent-compress);
|
||||
box-shadow: var(--shadow-glow-compress);
|
||||
}
|
||||
|
||||
.stats-retry {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-accent-warning);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes statsIn {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
</style>
|
||||
659
src/lib/components/Timeline.svelte
Normal file
659
src/lib/components/Timeline.svelte
Normal file
@@ -0,0 +1,659 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { streamUrl } from '$lib/utils/tauri';
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { formatTimecode, formatTimecodeShort } from '$lib/utils/format';
|
||||
// SegmentedControl replaced with custom toggle
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
let { currentTime, onSeek }: Props = $props();
|
||||
|
||||
let trackRef: HTMLDivElement | undefined = $state();
|
||||
let innerRef: HTMLDivElement | undefined = $state();
|
||||
let dragging: 'in' | 'out' | 'scrub' | null = $state(null);
|
||||
let hoverX: number | null = $state(null);
|
||||
let hoverTime: number | null = $state(null);
|
||||
let zoomLevel = $state(1);
|
||||
let animating = $state(false); // true when trim handles should animate (button clicks, not drags)
|
||||
|
||||
let inInput = $state('');
|
||||
let outInput = $state('');
|
||||
|
||||
function parseTimecode(str: string): number {
|
||||
const m = str.trim().match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
||||
if (!m) return -1;
|
||||
const h = parseInt(m[1]);
|
||||
const min = parseInt(m[2]);
|
||||
const sec = parseInt(m[3]);
|
||||
const ms = m[4] ? parseInt(m[4].padEnd(3, '0')) : 0;
|
||||
return h * 3600 + min * 60 + sec + ms / 1000;
|
||||
}
|
||||
|
||||
function commitIn() {
|
||||
const t = parseTimecode(inInput);
|
||||
if (t >= 0 && t < outTime) {
|
||||
app.setTrimRange(Math.max(0, t), outTime);
|
||||
}
|
||||
inInput = formatTimecode(inTime);
|
||||
}
|
||||
|
||||
function commitOut() {
|
||||
const t = parseTimecode(outInput);
|
||||
if (t > inTime && t <= duration) {
|
||||
app.setTrimRange(inTime, t);
|
||||
}
|
||||
outInput = formatTimecode(outTime);
|
||||
}
|
||||
|
||||
function handleInKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
}
|
||||
|
||||
function handleOutKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
}
|
||||
|
||||
// keep input values in sync when trim changes externally (drag etc)
|
||||
$effect(() => { inInput = formatTimecode(inTime); });
|
||||
$effect(() => { outInput = formatTimecode(outTime); });
|
||||
|
||||
let duration = $derived(app.videoInfo?.duration ?? 0);
|
||||
let keyframes = $derived(app.videoInfo?.keyframe_times ?? []);
|
||||
|
||||
let inTime = $derived(app.trimRange?.start ?? 0);
|
||||
let outTime = $derived(app.trimRange?.end ?? duration);
|
||||
let inPct = $derived(duration > 0 ? (inTime / duration) * 100 : 0);
|
||||
let outPct = $derived(duration > 0 ? (outTime / duration) * 100 : 100);
|
||||
let playheadPct = $derived(duration > 0 ? (currentTime / duration) * 100 : 0);
|
||||
let selectedDuration = $derived(outTime - inTime);
|
||||
|
||||
let trimModeLabel = $derived(app.trimMode === 'keyframe' ? 'Keyframe snap' : 'Smart cut');
|
||||
let modeOptions = ['Keyframe snap', 'Smart cut'];
|
||||
|
||||
function pctFromEvent(e: MouseEvent): number {
|
||||
if (!trackRef || !innerRef) return 0;
|
||||
const rect = trackRef.getBoundingClientRect();
|
||||
const scrollLeft = trackRef.scrollLeft;
|
||||
const innerWidth = innerRef.offsetWidth;
|
||||
const x = e.clientX - rect.left + scrollLeft;
|
||||
return Math.max(0, Math.min(100, (x / innerWidth) * 100));
|
||||
}
|
||||
|
||||
function timeFromPct(pct: number): number {
|
||||
return (pct / 100) * duration;
|
||||
}
|
||||
|
||||
function nearestKeyframe(time: number): number {
|
||||
if (keyframes.length === 0) return time;
|
||||
let best = keyframes[0];
|
||||
let dist = Math.abs(time - best);
|
||||
for (const kf of keyframes) {
|
||||
const d = Math.abs(time - kf);
|
||||
if (d < dist) { best = kf; dist = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function snapTime(time: number): number {
|
||||
if (app.trimMode === 'keyframe') return nearestKeyframe(time);
|
||||
return time;
|
||||
}
|
||||
|
||||
let scrubStartX = 0;
|
||||
let scrubStartTime = 0;
|
||||
|
||||
function handlePointerDown(e: PointerEvent, mode: 'scrub' | 'in' | 'out') {
|
||||
if (dragging) return;
|
||||
dragging = mode;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
if (mode === 'scrub') {
|
||||
const pct = pctFromEvent(e);
|
||||
const time = timeFromPct(pct);
|
||||
onSeek(time);
|
||||
scrubStartX = e.clientX;
|
||||
scrubStartTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!trackRef || !innerRef || !dragging) return;
|
||||
const rect = trackRef.getBoundingClientRect();
|
||||
const innerWidth = innerRef.offsetWidth;
|
||||
|
||||
if (dragging === 'scrub') {
|
||||
const vertDist = Math.abs(e.clientY - (rect.top + rect.height / 2));
|
||||
const speed = vertDist < 30 ? 1 : vertDist < 80 ? 0.5 : vertDist < 150 ? 0.25 : 0.1;
|
||||
const dx = e.clientX - scrubStartX;
|
||||
const pxPerSecond = innerWidth / duration;
|
||||
const timeDelta = (dx * speed) / pxPerSecond;
|
||||
const newTime = Math.max(0, Math.min(duration, scrubStartTime + timeDelta));
|
||||
scrubStartX = e.clientX;
|
||||
scrubStartTime = newTime;
|
||||
onSeek(newTime);
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollLeft = trackRef.scrollLeft;
|
||||
const x = e.clientX - rect.left + scrollLeft;
|
||||
const pct = Math.max(0, Math.min(100, (x / innerWidth) * 100));
|
||||
const time = timeFromPct(pct);
|
||||
|
||||
if (dragging === 'in') {
|
||||
const snapped = snapTime(Math.min(time, outTime - 0.1));
|
||||
app.setTrimRange(Math.max(0, snapped), outTime);
|
||||
onSeek(Math.max(0, snapped));
|
||||
} else if (dragging === 'out') {
|
||||
const snapped = snapTime(Math.max(time, inTime + 0.1));
|
||||
app.setTrimRange(inTime, Math.min(duration, snapped));
|
||||
onSeek(Math.min(duration, snapped));
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
dragging = null;
|
||||
}
|
||||
|
||||
function handleHoverMove(e: MouseEvent) {
|
||||
if (!trackRef || !innerRef || dragging) return;
|
||||
const rect = trackRef.getBoundingClientRect();
|
||||
const scrollLeft = trackRef.scrollLeft;
|
||||
const innerWidth = innerRef.offsetWidth;
|
||||
const x = e.clientX - rect.left + scrollLeft;
|
||||
hoverTime = timeFromPct(Math.max(0, Math.min(100, (x / innerWidth) * 100)));
|
||||
// viewport-relative tooltip position
|
||||
hoverX = Math.max(62, Math.min(rect.width - 62, e.clientX - rect.left));
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
hoverX = null;
|
||||
hoverTime = null;
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
if (e.deltaY < 0) zoomLevel = Math.min(8, zoomLevel * 1.15);
|
||||
else zoomLevel = Math.max(1, zoomLevel / 1.15);
|
||||
}
|
||||
|
||||
function handleModeChange(val: string) {
|
||||
app.trimMode = val === 'Keyframe snap' ? 'keyframe' : 'smart';
|
||||
}
|
||||
|
||||
function handleTrackKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
app.seek(app.currentTime - (e.shiftKey ? 5 : 1));
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
app.seek(app.currentTime + (e.shiftKey ? 5 : 1));
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
app.seek(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
app.seek(duration);
|
||||
} else if (e.key === 'i' || e.key === 'I') {
|
||||
e.preventDefault();
|
||||
app.setTrimRange(currentTime, outTime);
|
||||
onSeek(currentTime);
|
||||
} else if (e.key === 'o' || e.key === 'O') {
|
||||
e.preventDefault();
|
||||
app.setTrimRange(inTime, currentTime);
|
||||
onSeek(currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<!-- main filmstrip area -->
|
||||
<div class="tl-track-outer">
|
||||
<div
|
||||
bind:this={trackRef}
|
||||
class="relative w-full cursor-crosshair select-none"
|
||||
class:tl-animating={animating}
|
||||
style="height: 72px; border-radius: var(--radius-md); overflow-x: auto; overflow-y: visible; background: var(--color-bg-elevated); border: 1px solid var(--color-border); scrollbar-width: none;"
|
||||
role="slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={currentTime}
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => handlePointerDown(e, 'scrub')}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onmousemove={handleHoverMove}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onwheel={handleWheel}
|
||||
onkeydown={handleTrackKeydown}
|
||||
>
|
||||
<div bind:this={innerRef} class="tl-inner" style="width: calc(100% * {zoomLevel}); position: relative; height: 100%;">
|
||||
<!-- thumbnail strip -->
|
||||
{#if app.thumbnails.length > 0}
|
||||
<div
|
||||
class="absolute inset-0 flex"
|
||||
style="opacity: 0.8"
|
||||
>
|
||||
{#each app.thumbnails as thumb, i}
|
||||
<div
|
||||
class="h-full flex-1"
|
||||
style="background: url('{streamUrl(thumb)}') center/cover no-repeat"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- keyframe color segments -->
|
||||
{#each keyframes as kf, i}
|
||||
{#if i > 0}
|
||||
{@const prevPct = (keyframes[i - 1] / duration) * 100}
|
||||
{@const kfPct = (kf / duration) * 100}
|
||||
<div
|
||||
class="absolute top-0 h-full"
|
||||
style="
|
||||
left: {prevPct}%;
|
||||
width: {kfPct - prevPct}%;
|
||||
background: {i % 2 === 0 ? 'rgba(5,150,105,0.05)' : 'rgba(37,99,235,0.05)'};
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- dark overlay for unselected regions -->
|
||||
{#if app.trimRange}
|
||||
<div
|
||||
class="absolute top-0 h-full"
|
||||
style="left: 0; width: {inPct}%; background: rgba(0,0,0,0.35)"
|
||||
></div>
|
||||
<div
|
||||
class="absolute top-0 h-full"
|
||||
style="left: {outPct}%; right: 0; background: rgba(0,0,0,0.35)"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- selected region top/bottom border -->
|
||||
{#if app.trimRange}
|
||||
<div
|
||||
class="absolute z-10 pointer-events-none"
|
||||
style="
|
||||
left: calc({inPct}%);
|
||||
width: calc({outPct - inPct}%);
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-top: 2px solid var(--color-accent-trim);
|
||||
border-bottom: 2px solid var(--color-accent-trim);
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- IN handle -->
|
||||
{#if app.trimRange}
|
||||
<div
|
||||
class="trim-handle trim-handle--in"
|
||||
style="left: calc({inPct}% - {inPct > 0 ? 12 : 0}px);"
|
||||
onpointerdown={(e) => handlePointerDown(e, 'in')}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
role="slider"
|
||||
aria-label="Trim in point"
|
||||
aria-valuenow={inTime}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="trim-handle-inner">
|
||||
<svg width="6" height="20" viewBox="0 0 6 20" fill="none">
|
||||
<line x1="1" y1="3" x2="1" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="4.5" y1="3" x2="4.5" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OUT handle -->
|
||||
<div
|
||||
class="trim-handle trim-handle--out"
|
||||
style="left: calc({outPct}% - {outPct >= 100 ? 12 : 0}px);"
|
||||
onpointerdown={(e) => handlePointerDown(e, 'out')}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
role="slider"
|
||||
aria-label="Trim out point"
|
||||
aria-valuenow={outTime}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="trim-handle-inner">
|
||||
<svg width="6" height="20" viewBox="0 0 6 20" fill="none">
|
||||
<line x1="1" y1="3" x2="1" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="4.5" y1="3" x2="4.5" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- playhead -->
|
||||
<div
|
||||
class="playhead-wrapper"
|
||||
style="--ph-pct: {playheadPct}"
|
||||
>
|
||||
<div class="playhead-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- hover tooltip -->
|
||||
{#if hoverX !== null && hoverTime !== null && !dragging}
|
||||
<div
|
||||
class="absolute z-40 pointer-events-none"
|
||||
style="
|
||||
bottom: calc(100% + 8px);
|
||||
left: {hoverX}px;
|
||||
transform: translateX(-50%);
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-1"
|
||||
style="
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
"
|
||||
>
|
||||
<!-- mini thumb -->
|
||||
{#if duration > 0 && app.thumbnails.length > 0}
|
||||
{@const thumbIdx = Math.min(Math.floor((hoverTime / duration) * app.thumbnails.length), app.thumbnails.length - 1)}
|
||||
<div
|
||||
class="rounded"
|
||||
style="width: 120px; height: 68px; background: url('{streamUrl(app.thumbnails[thumbIdx])}') center/cover no-repeat"
|
||||
></div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded"
|
||||
style="width: 120px; height: 68px; background: var(--color-bg-surface)"
|
||||
></div>
|
||||
{/if}
|
||||
<span class="font-mono text-xs" style="color: var(--color-text-secondary)">
|
||||
{formatTimecode(hoverTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- below timeline: controls row -->
|
||||
<div class="tl-controls">
|
||||
<!-- left: cut mode toggle -->
|
||||
<div class="tl-mode">
|
||||
<button
|
||||
type="button"
|
||||
class="tl-mode-btn"
|
||||
class:tl-mode-btn--active={app.trimMode === 'keyframe'}
|
||||
onclick={() => handleModeChange('Keyframe snap')}
|
||||
>
|
||||
<i class="ti ti-scissors" style="font-size: 13px"></i>
|
||||
Keyframe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tl-mode-btn"
|
||||
class:tl-mode-btn--active={app.trimMode === 'smart'}
|
||||
onclick={() => handleModeChange('Smart cut')}
|
||||
>
|
||||
<i class="ti ti-cut" style="font-size: 13px"></i>
|
||||
Smart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- right: timecodes -->
|
||||
<div class="tl-times">
|
||||
<div class="tl-time-field">
|
||||
<button
|
||||
type="button"
|
||||
class="tl-time-label tl-time-label--btn"
|
||||
title="Set in point to current position"
|
||||
onclick={() => { animating = true; app.setTrimRange(currentTime, outTime); onSeek(currentTime); setTimeout(() => { animating = false; }, 500); }}
|
||||
>IN</button>
|
||||
<input
|
||||
type="text"
|
||||
class="tl-time-input"
|
||||
bind:value={inInput}
|
||||
onblur={commitIn}
|
||||
onkeydown={handleInKey}
|
||||
onfocus={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<span class="tl-kbd-hint"><kbd>I</kbd></span>
|
||||
</div>
|
||||
<div class="tl-time-field">
|
||||
<button
|
||||
type="button"
|
||||
class="tl-time-label tl-time-label--btn"
|
||||
title="Set out point to current position"
|
||||
onclick={() => { animating = true; app.setTrimRange(inTime, currentTime); onSeek(currentTime); setTimeout(() => { animating = false; }, 500); }}
|
||||
>OUT</button>
|
||||
<input
|
||||
type="text"
|
||||
class="tl-time-input"
|
||||
bind:value={outInput}
|
||||
onblur={commitOut}
|
||||
onkeydown={handleOutKey}
|
||||
onfocus={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<span class="tl-kbd-hint"><kbd>O</kbd></span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="tl-duration"
|
||||
title="Reset trim to full video"
|
||||
onclick={() => { animating = true; app.clearTrimRange(); setTimeout(() => { animating = false; }, 500); }}
|
||||
>
|
||||
<i class="ti ti-clock" style="font-size: 12px"></i>
|
||||
{formatTimecodeShort(selectedDuration)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tl-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tl-mode {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
.tl-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--color-text-disabled);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-mode-btn:hover {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.tl-mode-btn--active {
|
||||
background: var(--color-accent-trim);
|
||||
color: white;
|
||||
}
|
||||
.tl-mode-btn--active:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tl-times {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tl-time-field {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tl-time-field:focus-within {
|
||||
border-color: var(--color-accent-trim);
|
||||
}
|
||||
|
||||
.tl-time-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-disabled);
|
||||
background: var(--color-bg-elevated);
|
||||
border: none;
|
||||
border-right: 1px solid var(--color-border);
|
||||
user-select: none;
|
||||
}
|
||||
.tl-time-label--btn {
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.tl-time-label--btn:hover {
|
||||
color: var(--color-accent-trim);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.tl-time-input {
|
||||
width: 90px;
|
||||
padding: 5px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
.tl-time-input:focus {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tl-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.tl-duration:hover {
|
||||
border-color: var(--color-text-secondary);
|
||||
color: var(--color-accent-compress);
|
||||
}
|
||||
|
||||
/* when animating is active (button clicks), transition all positioned elements */
|
||||
.tl-animating .trim-handle,
|
||||
.tl-animating .trim-handle ~ div,
|
||||
:global(.tl-animating) :is([style*="left:"], [style*="width:"]) {
|
||||
transition: left 500ms ease-in-out, width 500ms ease-in-out, right 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.tl-track-outer {
|
||||
position: relative;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.tl-inner {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.playhead-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
will-change: transform;
|
||||
transform: translateX(calc(var(--ph-pct) * 1% - 1px));
|
||||
}
|
||||
.playhead-line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: var(--color-accent-compress);
|
||||
box-shadow: 0 0 6px rgba(5,150,105,0.7), 0 0 12px rgba(5,150,105,0.4);
|
||||
}
|
||||
|
||||
.tl-kbd-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-disabled);
|
||||
user-select: none;
|
||||
}
|
||||
.tl-kbd-hint kbd {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 14px;
|
||||
z-index: 20;
|
||||
cursor: ew-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-accent-trim);
|
||||
}
|
||||
.trim-handle--in {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
.trim-handle--out {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.trim-handle-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-fast), opacity var(--transition-fast);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.trim-handle:hover .trim-handle-inner {
|
||||
opacity: 1;
|
||||
transform: scaleX(1.4);
|
||||
}
|
||||
</style>
|
||||
189
src/lib/components/TitleBar.svelte
Normal file
189
src/lib/components/TitleBar.svelte
Normal file
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { exit } from '@tauri-apps/plugin-process';
|
||||
import type { Theme } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
onSettingsClick?: () => void;
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
let { onSettingsClick, showBack = false }: Props = $props();
|
||||
|
||||
const themes: { value: Theme; icon: string; label: string }[] = [
|
||||
{ value: 'light', icon: 'ti-sun', label: 'Light' },
|
||||
{ value: 'dark', icon: 'ti-moon', label: 'Dark' },
|
||||
{ value: 'system', icon: 'ti-device-desktop', label: 'System' }
|
||||
];
|
||||
|
||||
|
||||
async function minimize() { (await getCurrentWindow()).minimize(); }
|
||||
async function toggleMaximize() { (await getCurrentWindow()).toggleMaximize(); }
|
||||
async function close() {
|
||||
try {
|
||||
const win = getCurrentWindow();
|
||||
await win.close();
|
||||
} catch {
|
||||
// fallback: force exit
|
||||
await exit(0);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="flex items-center select-none shrink-0"
|
||||
style="
|
||||
height: 40px;
|
||||
background: var(--color-bg-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-left: 12px;
|
||||
padding-right: 0;
|
||||
-webkit-app-region: drag;
|
||||
"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<!-- app name -->
|
||||
<span
|
||||
class="font-bold"
|
||||
style="font-family: var(--font-display); font-size: 15px; color: var(--color-accent-compress); -webkit-app-region: drag; margin-right: auto;"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
Cinch
|
||||
</span>
|
||||
|
||||
<!-- theme segmented control -->
|
||||
<div
|
||||
class="flex items-center"
|
||||
style="
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 2px;
|
||||
gap: 1px;
|
||||
margin-right: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
"
|
||||
>
|
||||
{#each themes as t}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center cursor-pointer"
|
||||
style="
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: {themeStore.theme === t.value ? 'var(--color-accent-compress)' : 'transparent'};
|
||||
border: none;
|
||||
color: {themeStore.theme === t.value ? 'white' : 'var(--color-text-secondary)'};
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
"
|
||||
title={t.label}
|
||||
aria-label="{t.label} theme"
|
||||
onclick={() => themeStore.setTheme(t.value)}
|
||||
>
|
||||
<i class="ti {t.icon}" style="font-size: 15px"></i>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- settings / back -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center cursor-pointer"
|
||||
style="
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
-webkit-app-region: no-drag;
|
||||
"
|
||||
title={showBack ? 'Back' : 'Settings'}
|
||||
aria-label={showBack ? 'Back' : 'Settings'}
|
||||
onclick={onSettingsClick}
|
||||
onmouseenter={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.color = 'var(--color-text-primary)';
|
||||
el.style.background = 'var(--color-bg-elevated)';
|
||||
}}
|
||||
onmouseleave={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.color = 'var(--color-text-secondary)';
|
||||
el.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
<i class={showBack ? 'ti ti-arrow-left' : 'ti ti-settings'} style="font-size: 18px"></i>
|
||||
</button>
|
||||
|
||||
<!-- divider -->
|
||||
<div style="width: 1px; height: 18px; background: var(--color-border); margin: 0 6px; -webkit-app-region: no-drag"></div>
|
||||
|
||||
<!-- window controls -->
|
||||
<div class="flex items-center" style="-webkit-app-region: no-drag">
|
||||
<button
|
||||
type="button"
|
||||
class="win-btn"
|
||||
title="Minimize"
|
||||
aria-label="Minimize"
|
||||
onclick={minimize}
|
||||
onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-elevated)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.background = 'none'; }}
|
||||
>
|
||||
<!-- custom minimize dash - ti-minus is invisible at small sizes -->
|
||||
<svg width="10" height="1" viewBox="0 0 10 1" fill="currentColor"><rect width="10" height="1" rx="0.5"/></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="win-btn"
|
||||
title="Maximize"
|
||||
aria-label="Maximize"
|
||||
onclick={toggleMaximize}
|
||||
onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-elevated)'; }}
|
||||
onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.background = 'none'; }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="0.6" y="0.6" width="8.8" height="8.8" rx="1"/></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="win-btn win-btn--close"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
onclick={close}
|
||||
onmouseenter={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = 'var(--color-accent-error)';
|
||||
el.style.color = 'white';
|
||||
}}
|
||||
onmouseleave={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = 'none';
|
||||
el.style.color = 'var(--color-text-secondary)';
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><line x1="1" y1="1" x2="9" y2="9"/><line x1="9" y1="1" x2="1" y2="9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.win-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.win-btn--close {
|
||||
/* no border-radius so it reaches the corner */
|
||||
}
|
||||
</style>
|
||||
68
src/lib/components/Toast.svelte
Normal file
68
src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Toast as ToastType } from '$lib/types';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
|
||||
interface Props {
|
||||
toast: ToastType;
|
||||
}
|
||||
|
||||
let { toast }: Props = $props();
|
||||
|
||||
const borderColors: Record<string, string> = {
|
||||
error: 'var(--color-accent-error)',
|
||||
warning: 'var(--color-accent-warning)',
|
||||
info: 'var(--color-accent-info)',
|
||||
success: 'var(--color-accent-compress)'
|
||||
};
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
error: 'ti ti-circle-x',
|
||||
warning: 'ti ti-alert-triangle',
|
||||
info: 'ti ti-info-circle',
|
||||
success: 'ti ti-circle-check'
|
||||
};
|
||||
|
||||
let visible = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
requestAnimationFrame(() => { visible = true; });
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
visible = false;
|
||||
setTimeout(() => toasts.remove(toast.id), 150);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
style="
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid {borderColors[toast.type]};
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
min-width: 300px;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
transform: translateY({visible ? '0' : '-16px'});
|
||||
opacity: {visible ? 1 : 0};
|
||||
transition: transform {visible ? '200ms ease-out' : '150ms ease-in'}, opacity {visible ? '200ms ease-out' : '150ms ease-in'};
|
||||
"
|
||||
>
|
||||
<i class={iconMap[toast.type]} style="font-size: 18px; color: {borderColors[toast.type]}; flex-shrink: 0"></i>
|
||||
|
||||
<span class="flex-1 text-sm" style="color: var(--color-text-primary); font-family: var(--font-body)">
|
||||
{toast.message}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 cursor-pointer"
|
||||
style="background: none; border: none; padding: 2px; color: var(--color-text-secondary)"
|
||||
onclick={dismiss}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<i class="ti ti-x" style="font-size: 14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
16
src/lib/components/ToastContainer.svelte
Normal file
16
src/lib/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import Toast from './Toast.svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-12 left-1/2 -translate-x-1/2 z-50 flex flex-col items-center gap-2 pointer-events-none"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{#each toasts.items as toast (toast.id)}
|
||||
<div class="pointer-events-auto">
|
||||
<Toast {toast} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
62
src/lib/components/Toggle.svelte
Normal file
62
src/lib/components/Toggle.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked, onChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label="Toggle setting"
|
||||
class="toggle"
|
||||
class:toggle--on={checked}
|
||||
onclick={() => onChange(!checked)}
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.toggle:hover {
|
||||
border-color: var(--color-text-disabled);
|
||||
}
|
||||
.toggle--on {
|
||||
background: var(--color-accent-compress);
|
||||
border-color: var(--color-accent-compress);
|
||||
}
|
||||
.toggle--on:hover {
|
||||
background: #047857;
|
||||
border-color: #047857;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-disabled);
|
||||
transition: transform var(--transition-spring), background var(--transition-fast);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toggle--on .toggle-thumb {
|
||||
transform: translateX(18px);
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
282
src/lib/components/VideoPreview.svelte
Normal file
282
src/lib/components/VideoPreview.svelte
Normal file
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { config } from '$lib/stores/config.svelte';
|
||||
import { generatePreview, streamUrl } from '$lib/utils/tauri';
|
||||
|
||||
interface Props {
|
||||
currentTime?: number;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
let { currentTime = $bindable(0), onTimeUpdate, onSeek }: Props = $props();
|
||||
|
||||
let videoEl: HTMLVideoElement | undefined = $state();
|
||||
let playing = $state(false);
|
||||
let volume = $state(config.current.preview_volume);
|
||||
let showControls = $state(false);
|
||||
let showVolume = $state(false);
|
||||
let errorMsg = $state('');
|
||||
let previewPath = $state('');
|
||||
let generating = $state(false);
|
||||
let videoReady = $state(false);
|
||||
|
||||
// try the original file first, fall back to generated H.264 proxy
|
||||
let src = $derived(previewPath ? streamUrl(previewPath) : app.videoInfo?.path ? streamUrl(app.videoInfo.path) : '');
|
||||
|
||||
let prevVideoPath = '';
|
||||
|
||||
$effect(() => {
|
||||
const path = app.videoInfo?.path;
|
||||
if (!path || path === prevVideoPath) return;
|
||||
prevVideoPath = path;
|
||||
previewPath = '';
|
||||
errorMsg = '';
|
||||
generating = false;
|
||||
videoReady = false;
|
||||
});
|
||||
|
||||
async function handleVideoError() {
|
||||
const path = app.videoInfo?.path;
|
||||
if (!path || generating || previewPath) {
|
||||
// already tried the proxy and it also failed
|
||||
errorMsg = "Can't preview this video. It will still compress and trim correctly.";
|
||||
return;
|
||||
}
|
||||
|
||||
// original file can't play (probably unsupported codec) - generate H.264 proxy
|
||||
generating = true;
|
||||
errorMsg = '';
|
||||
try {
|
||||
const proxy = await generatePreview(path, app.videoInfo?.video_codec);
|
||||
previewPath = proxy;
|
||||
// src will update via $derived, video element will reload
|
||||
} catch {
|
||||
errorMsg = "Can't preview this video. It will still compress and trim correctly.";
|
||||
}
|
||||
generating = false;
|
||||
}
|
||||
|
||||
export function seekTo(time: number) {
|
||||
if (videoEl) {
|
||||
videoEl.currentTime = time;
|
||||
currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
export function togglePlay() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) {
|
||||
// if we're outside the trim region, jump to the in point first
|
||||
const inPt = app.trimRange?.start ?? 0;
|
||||
const outPt = app.trimRange?.end ?? (app.videoInfo?.duration ?? 0);
|
||||
if (videoEl.currentTime < inPt || videoEl.currentTime >= outPt) {
|
||||
videoEl.currentTime = inPt;
|
||||
}
|
||||
videoEl.play();
|
||||
playing = true;
|
||||
} else {
|
||||
videoEl.pause();
|
||||
playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
let lastReportedTime = 0;
|
||||
|
||||
function handleTimeUpdate() {
|
||||
if (!videoEl) return;
|
||||
|
||||
// loop within trim region - always check, don't throttle
|
||||
if (playing && app.trimRange) {
|
||||
if (videoEl.currentTime >= app.trimRange.end) {
|
||||
videoEl.currentTime = app.trimRange.start;
|
||||
}
|
||||
}
|
||||
|
||||
// throttle reactive updates to ~10Hz to prevent DOM thrashing
|
||||
const now = performance.now();
|
||||
if (now - lastReportedTime < 100) return;
|
||||
lastReportedTime = now;
|
||||
|
||||
currentTime = videoEl.currentTime;
|
||||
onTimeUpdate?.(videoEl.currentTime);
|
||||
}
|
||||
|
||||
function handleEnded() {
|
||||
// if trim range is set, loop back to in point
|
||||
if (videoEl && app.trimRange) {
|
||||
videoEl.currentTime = app.trimRange.start;
|
||||
videoEl.play();
|
||||
return;
|
||||
}
|
||||
playing = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (videoEl) videoEl.volume = volume;
|
||||
});
|
||||
|
||||
// persist volume to config
|
||||
$effect(() => {
|
||||
if (volume !== config.current.preview_volume) {
|
||||
config.update({ preview_volume: volume });
|
||||
}
|
||||
});
|
||||
|
||||
// sync external seek
|
||||
$effect(() => {
|
||||
if (videoEl && Math.abs(videoEl.currentTime - currentTime) > 0.1) {
|
||||
videoEl.currentTime = currentTime;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex items-center justify-center overflow-hidden cursor-pointer video-container"
|
||||
style="aspect-ratio: {app.videoInfo?.width ?? 16} / {app.videoInfo?.height ?? 9}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={togglePlay}
|
||||
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePlay(); } }}
|
||||
onmouseenter={() => { showControls = true; }}
|
||||
onmouseleave={() => { showControls = false; showVolume = false; }}
|
||||
>
|
||||
<!-- skeleton - visible until video is ready -->
|
||||
{#if !videoReady}
|
||||
<div class="video-skeleton">
|
||||
{#if generating}
|
||||
<span class="inline-block w-6 h-6 border-2 border-t-transparent rounded-full animate-spin" style="border-color: var(--color-accent-compress); border-top-color: transparent"></span>
|
||||
<span style="font-family: var(--font-body); font-size: var(--text-xs); color: var(--color-text-disabled)">Generating preview...</span>
|
||||
{:else}
|
||||
<i class="ti ti-movie" style="font-size: 48px; color: var(--color-text-disabled); opacity: 0.3"></i>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- video element - hidden until loaded, then fades in -->
|
||||
{#if src && !generating}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={src}
|
||||
class="video-element"
|
||||
class:video-element--ready={videoReady}
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
onended={handleEnded}
|
||||
onerror={handleVideoError}
|
||||
onloadeddata={() => { errorMsg = ''; videoReady = true; }}
|
||||
preload="metadata"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{#if errorMsg}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2" style="background: var(--color-bg-elevated); border-radius: var(--radius-lg)">
|
||||
<i class="ti ti-alert-circle" style="font-size: 32px; color: var(--color-accent-warning)"></i>
|
||||
<span style="font-family: var(--font-body); font-size: var(--text-sm); color: var(--color-text-secondary); text-align: center; padding: 0 16px;">
|
||||
{errorMsg}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- play/pause overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
style="
|
||||
background: rgba(0,0,0,0.15);
|
||||
opacity: {showControls ? 1 : 0};
|
||||
transition: opacity var(--transition-fast);
|
||||
pointer-events: none;
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full"
|
||||
style="width: 48px; height: 48px; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px)"
|
||||
>
|
||||
<i
|
||||
class={playing ? 'ti ti-player-pause' : 'ti ti-player-play'}
|
||||
style="font-size: 22px; color: white; margin-left: {playing ? '0' : '2px'}"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- volume control -->
|
||||
<div
|
||||
class="absolute bottom-2 right-2 flex items-center gap-1"
|
||||
style="
|
||||
opacity: {showControls ? 1 : 0};
|
||||
transition: opacity var(--transition-fast);
|
||||
pointer-events: {showControls ? 'auto' : 'none'};
|
||||
"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="group"
|
||||
>
|
||||
{#if showVolume}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
bind:value={volume}
|
||||
class="volume-slider w-16"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded cursor-pointer"
|
||||
style="width: 28px; height: 28px; background: rgba(0,0,0,0.5); border: none; color: white; backdrop-filter: blur(4px)"
|
||||
onclick={(e) => { e.stopPropagation(); showVolume = !showVolume; }}
|
||||
>
|
||||
<i class={volume === 0 ? 'ti ti-volume-off' : 'ti ti-volume'} style="font-size: 14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.video-container {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.video-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.video-element {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-lg);
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-in-out;
|
||||
}
|
||||
.video-element--ready {
|
||||
opacity: 1;
|
||||
}
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg-elevated);
|
||||
outline: none;
|
||||
}
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-compress);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
238
src/lib/stores/app.svelte.ts
Normal file
238
src/lib/stores/app.svelte.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type {
|
||||
AppState,
|
||||
VideoInfo,
|
||||
CompressSettings,
|
||||
TrimRange,
|
||||
TrimMode,
|
||||
ActiveMode,
|
||||
ProgressEvent,
|
||||
OutputInfo,
|
||||
HardwareInfo,
|
||||
FFmpegStatus,
|
||||
SizingStrategy
|
||||
} from '$lib/types';
|
||||
import * as tauri from '$lib/utils/tauri';
|
||||
|
||||
const defaultSettings: CompressSettings = {
|
||||
strategy: { type: 'TargetSize', mb: 8 },
|
||||
video_codec: 'H264',
|
||||
audio_codec: 'AAC',
|
||||
audio_bitrate: 128,
|
||||
container: 'MP4',
|
||||
resolution: { type: 'Original' },
|
||||
speed_preset: 'medium',
|
||||
hw_accel: 'Auto'
|
||||
};
|
||||
|
||||
// plain $state object for cross-component reactivity (class $state fields
|
||||
// don't reliably trigger {#if} re-evaluation across component boundaries)
|
||||
export const view = $state({ current: 'empty' as AppState });
|
||||
|
||||
class AppStore {
|
||||
state: AppState = $state('empty');
|
||||
videoInfo: VideoInfo | null = $state(null);
|
||||
compressSettings: CompressSettings = $state({ ...defaultSettings });
|
||||
trimRange: TrimRange | null = $state(null);
|
||||
trimMode: TrimMode = $state('keyframe');
|
||||
progress: ProgressEvent | null = $state(null);
|
||||
outputInfo: OutputInfo | null = $state(null);
|
||||
hardwareInfo: HardwareInfo | null = $state(null);
|
||||
ffmpegStatus: FFmpegStatus = $state({ found: false, path: null, version: null });
|
||||
|
||||
thumbnails: string[] = $state([]);
|
||||
selectedPreset: number | null = $state(null);
|
||||
customSize: number = $state(8);
|
||||
advancedOpen: boolean = $state(false);
|
||||
overriddenOutputPath: string | null = $state(null);
|
||||
analyzeStep: string = $state('');
|
||||
|
||||
currentTime: number = $state(0);
|
||||
private seekCallback: ((time: number) => void) | null = null;
|
||||
private playbackCallback: (() => void) | null = null;
|
||||
|
||||
private currentJobId: string | null = null;
|
||||
private progressUnlisten: (() => void) | null = null;
|
||||
|
||||
get activeMode(): ActiveMode {
|
||||
const compressActive = this.selectedPreset !== null;
|
||||
const hasTrim = this.trimRange !== null;
|
||||
if (compressActive && hasTrim) return 'both';
|
||||
if (compressActive) return 'compress';
|
||||
if (hasTrim) return 'trim';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
private loadingPath: string | null = null;
|
||||
|
||||
async loadVideo(path: string) {
|
||||
// prevent re-entry: never reload the same path
|
||||
if (this.loadingPath === path) return;
|
||||
this.loadingPath = path;
|
||||
this.state = 'analyzing';
|
||||
this.analyzeStep = 'Reading metadata...';
|
||||
this.videoInfo = null;
|
||||
this.outputInfo = null;
|
||||
this.progress = null;
|
||||
this.trimRange = null;
|
||||
this.selectedPreset = null;
|
||||
this.thumbnails = [];
|
||||
|
||||
try {
|
||||
const info = await tauri.analyzeVideo(path);
|
||||
this.videoInfo = info;
|
||||
this.state = 'loaded';
|
||||
|
||||
// step 2: keyframes (background)
|
||||
this.analyzeStep = 'Extracting keyframes...';
|
||||
tauri.extractKeyframes(path).then((kf) => {
|
||||
if (this.videoInfo && this.videoInfo.path === path) {
|
||||
this.videoInfo.keyframe_times = kf;
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// step 3: thumbnails
|
||||
this.analyzeStep = 'Generating thumbnails...';
|
||||
tauri.generateThumbnails(path, 5, info.duration).then((coarse) => {
|
||||
if (this.videoInfo?.path === path) this.thumbnails = coarse;
|
||||
return tauri.generateThumbnails(path, 20, info.duration);
|
||||
}).then((detail) => {
|
||||
if (this.videoInfo?.path === path) this.thumbnails = detail;
|
||||
this.analyzeStep = '';
|
||||
}).catch(() => {
|
||||
this.analyzeStep = '';
|
||||
});
|
||||
} catch (err) {
|
||||
this.state = 'empty';
|
||||
this.analyzeStep = '';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async startProcess() {
|
||||
if (!this.videoInfo) return;
|
||||
this.state = 'processing';
|
||||
this.progress = null;
|
||||
|
||||
this.progressUnlisten = await tauri.listenProgress((ev) => {
|
||||
this.progress = ev;
|
||||
this.currentJobId = ev.job_id;
|
||||
// don't change state here - let the await below handle it
|
||||
// so outputInfo is set before transitioning to 'done'
|
||||
});
|
||||
|
||||
const mode = this.activeMode === 'both' ? 'trimcomp' : this.activeMode === 'trim' ? 'trim' : 'compress';
|
||||
|
||||
try {
|
||||
const outPath = this.overriddenOutputPath ?? await tauri.getOutputPath(this.videoInfo.path, mode, this.compressSettings.container.toLowerCase());
|
||||
this.overriddenOutputPath = null;
|
||||
|
||||
let result: OutputInfo;
|
||||
if (this.activeMode === 'trim' && this.trimRange) {
|
||||
const strip = this.compressSettings.audio_codec === 'None';
|
||||
result = await tauri.trim(this.videoInfo.path, outPath, this.trimRange, this.trimMode === 'smart', strip);
|
||||
} else {
|
||||
result = await tauri.compress(this.videoInfo.path, outPath, this.compressSettings, this.trimRange ?? undefined);
|
||||
}
|
||||
|
||||
this.outputInfo = result;
|
||||
this.state = 'done';
|
||||
} catch (err) {
|
||||
this.state = 'loaded';
|
||||
throw err;
|
||||
} finally {
|
||||
this.progressUnlisten?.();
|
||||
this.progressUnlisten = null;
|
||||
this.currentJobId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async cancelProcess() {
|
||||
if (this.currentJobId) {
|
||||
await tauri.cancelJob(this.currentJobId);
|
||||
}
|
||||
this.progressUnlisten?.();
|
||||
this.progressUnlisten = null;
|
||||
this.currentJobId = null;
|
||||
this.progress = null;
|
||||
this.state = this.videoInfo ? 'loaded' : 'empty';
|
||||
}
|
||||
|
||||
resetToEmpty() {
|
||||
this.state = 'empty';
|
||||
this.videoInfo = null;
|
||||
this.compressSettings = { ...defaultSettings };
|
||||
this.trimRange = null;
|
||||
this.trimMode = 'keyframe';
|
||||
this.progress = null;
|
||||
this.outputInfo = null;
|
||||
this.thumbnails = [];
|
||||
this.selectedPreset = null;
|
||||
this.customSize = 8;
|
||||
this.advancedOpen = false;
|
||||
this.overriddenOutputPath = null;
|
||||
}
|
||||
|
||||
setPreset(mb: number) {
|
||||
this.selectedPreset = mb;
|
||||
this.customSize = mb;
|
||||
this.compressSettings.strategy = { type: 'TargetSize', mb };
|
||||
}
|
||||
|
||||
clearPreset() {
|
||||
this.selectedPreset = null;
|
||||
this.compressSettings.strategy = { ...defaultSettings.strategy };
|
||||
}
|
||||
|
||||
setCustomSize(mb: number) {
|
||||
this.selectedPreset = null;
|
||||
this.customSize = mb;
|
||||
this.compressSettings.strategy = { type: 'TargetSize', mb };
|
||||
}
|
||||
|
||||
setStrategy(strategy: SizingStrategy) {
|
||||
this.selectedPreset = null;
|
||||
this.compressSettings.strategy = strategy;
|
||||
}
|
||||
|
||||
setTrimRange(start: number, end: number) {
|
||||
const duration = this.videoInfo?.duration ?? 0;
|
||||
const s = Math.max(0, Math.min(start, duration));
|
||||
const e = Math.max(0, Math.min(end, duration));
|
||||
// never allow in > out
|
||||
if (s >= e) return;
|
||||
this.trimRange = { start: s, end: e };
|
||||
}
|
||||
|
||||
clearTrimRange() {
|
||||
this.trimRange = null;
|
||||
}
|
||||
|
||||
registerSeekCallback(fn: (time: number) => void) {
|
||||
this.seekCallback = fn;
|
||||
}
|
||||
|
||||
registerPlaybackCallback(fn: () => void) {
|
||||
this.playbackCallback = fn;
|
||||
}
|
||||
|
||||
seek(time: number) {
|
||||
const clamped = Math.max(0, time);
|
||||
this.currentTime = clamped;
|
||||
this.seekCallback?.(clamped);
|
||||
}
|
||||
|
||||
togglePlayback() {
|
||||
this.playbackCallback?.();
|
||||
}
|
||||
}
|
||||
|
||||
export const app = new AppStore();
|
||||
|
||||
// poll sync: class $state fields don't reliably trigger effects across components
|
||||
if (typeof window !== 'undefined') {
|
||||
setInterval(() => {
|
||||
if (view.current !== app.state) {
|
||||
view.current = app.state;
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
52
src/lib/stores/config.svelte.ts
Normal file
52
src/lib/stores/config.svelte.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { AppConfig } from '$lib/types';
|
||||
import * as tauri from '$lib/utils/tauri';
|
||||
|
||||
const defaults: AppConfig = {
|
||||
theme: 'system',
|
||||
ui_zoom: 100,
|
||||
ffmpeg_path: null,
|
||||
default_output_dir: null,
|
||||
last_compress_settings: null,
|
||||
default_presets: [8, 25, 50, 100],
|
||||
auto_retry: true,
|
||||
retry_threshold_percent: 2.0,
|
||||
max_retry_attempts: 3,
|
||||
show_ffmpeg_log: false,
|
||||
remember_window_position: true,
|
||||
window_position: null,
|
||||
default_target_size: 8,
|
||||
default_smart_cut: false,
|
||||
naming_pattern: '{name}_{mode}_{timestamp}',
|
||||
last_open_dir: null,
|
||||
last_save_dir: null,
|
||||
preview_volume: 1
|
||||
};
|
||||
|
||||
class ConfigStore {
|
||||
current: AppConfig = $state({ ...defaults });
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const loaded = await tauri.getConfig();
|
||||
this.current = { ...defaults, ...loaded };
|
||||
} catch {
|
||||
this.current = { ...defaults };
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
await tauri.saveConfig(this.current);
|
||||
}
|
||||
|
||||
async update(partial: Partial<AppConfig>) {
|
||||
Object.assign(this.current, partial);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.current = { ...defaults };
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
export const config = new ConfigStore();
|
||||
56
src/lib/stores/theme.svelte.ts
Normal file
56
src/lib/stores/theme.svelte.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Theme, ResolvedTheme } from '$lib/types';
|
||||
|
||||
class ThemeStore {
|
||||
theme: Theme = $state('system');
|
||||
resolved: ResolvedTheme = $state('dark');
|
||||
|
||||
private mediaQuery: MediaQueryList | null = null;
|
||||
private mediaHandler: ((e: MediaQueryListEvent) => void) | null = null;
|
||||
|
||||
private getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
private resolve(): ResolvedTheme {
|
||||
return this.theme === 'system' ? this.getSystemTheme() : this.theme;
|
||||
}
|
||||
|
||||
private apply(t: ResolvedTheme) {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}
|
||||
|
||||
setTheme(t: Theme) {
|
||||
this.theme = t;
|
||||
this.resolved = this.resolve();
|
||||
this.apply(this.resolved);
|
||||
}
|
||||
|
||||
init(saved?: Theme) {
|
||||
if (saved) this.theme = saved;
|
||||
this.resolved = this.resolve();
|
||||
this.apply(this.resolved);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this.mediaHandler = () => {
|
||||
if (this.theme === 'system') {
|
||||
this.resolved = this.getSystemTheme();
|
||||
this.apply(this.resolved);
|
||||
}
|
||||
};
|
||||
this.mediaQuery.addEventListener('change', this.mediaHandler);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.mediaQuery && this.mediaHandler) {
|
||||
this.mediaQuery.removeEventListener('change', this.mediaHandler);
|
||||
this.mediaQuery = null;
|
||||
this.mediaHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themeStore = new ThemeStore();
|
||||
34
src/lib/stores/toast.svelte.ts
Normal file
34
src/lib/stores/toast.svelte.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Toast, ToastType } from '$lib/types';
|
||||
|
||||
const defaultDurations: Record<ToastType, number> = {
|
||||
success: 4000,
|
||||
info: 4000,
|
||||
warning: 6000,
|
||||
error: 0
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
class ToastStore {
|
||||
items: Toast[] = $state([]);
|
||||
|
||||
add(type: ToastType, message: string, duration?: number): string {
|
||||
const id = String(nextId++);
|
||||
const dur = duration ?? defaultDurations[type];
|
||||
|
||||
this.items.push({ id, type, message, duration: dur });
|
||||
|
||||
if (dur > 0) {
|
||||
setTimeout(() => this.remove(id), dur);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
const idx = this.items.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) this.items.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export const toasts = new ToastStore();
|
||||
143
src/lib/types.ts
Normal file
143
src/lib/types.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// App states
|
||||
export type AppState = 'empty' | 'analyzing' | 'loaded' | 'processing' | 'done' | 'settings' | 'setup';
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
export type TrimMode = 'keyframe' | 'smart';
|
||||
export type ActiveMode = 'none' | 'compress' | 'trim' | 'both';
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
// Video codecs - auto-selects hw variant based on available hardware
|
||||
export type VideoCodec = 'H264' | 'HEVC' | 'AV1';
|
||||
export type AudioCodec = 'AAC' | 'Opus' | 'None';
|
||||
export type Container = 'MP4' | 'MKV' | 'WebM' | 'MOV' | 'AVI' | 'TS';
|
||||
export type HwAccelMode = 'Auto' | 'ForceGPU' | 'ForceCPU';
|
||||
|
||||
export type Resolution =
|
||||
| { type: 'Original' }
|
||||
| { type: 'P720' }
|
||||
| { type: 'P1080' }
|
||||
| { type: 'P1440' }
|
||||
| { type: 'P4K' }
|
||||
| { type: 'Custom'; width: number; height: number };
|
||||
|
||||
export type SizingStrategy =
|
||||
| { type: 'TargetSize'; mb: number }
|
||||
| { type: 'TargetBitrate'; kbps: number }
|
||||
| { type: 'CRF'; value: number };
|
||||
|
||||
export interface TrimRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
path: string;
|
||||
file_size: number;
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
video_codec: string;
|
||||
video_bitrate: number;
|
||||
frame_rate: number;
|
||||
is_vfr: boolean;
|
||||
audio_codec: string | null;
|
||||
audio_bitrate: number | null;
|
||||
audio_channels: number | null;
|
||||
keyframe_times: number[];
|
||||
container: string;
|
||||
}
|
||||
|
||||
export interface CompressSettings {
|
||||
strategy: SizingStrategy;
|
||||
video_codec: VideoCodec;
|
||||
audio_codec: AudioCodec;
|
||||
audio_bitrate: number;
|
||||
container: Container;
|
||||
resolution: Resolution;
|
||||
speed_preset: string;
|
||||
hw_accel: HwAccelMode;
|
||||
}
|
||||
|
||||
export interface OutputInfo {
|
||||
path: string;
|
||||
file_size: number;
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
video_codec: string;
|
||||
video_bitrate: number;
|
||||
audio_codec: string | null;
|
||||
audio_bitrate: number | null;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface HardwareInfo {
|
||||
nvenc: boolean;
|
||||
qsv: boolean;
|
||||
amf: boolean;
|
||||
nvenc_codecs: string[];
|
||||
qsv_codecs: string[];
|
||||
amf_codecs: string[];
|
||||
}
|
||||
|
||||
export interface FFmpegStatus {
|
||||
found: boolean;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export type ProgressPhase = 'analyzing' | 'encoding' | 'retrying' | 'muxing' | 'done' | 'error';
|
||||
|
||||
export interface ProgressEvent {
|
||||
job_id: string;
|
||||
percent: number;
|
||||
fps: number;
|
||||
bitrate: string;
|
||||
size_current: number;
|
||||
time_elapsed: number;
|
||||
eta_seconds: number;
|
||||
phase: ProgressPhase;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RetryEvent {
|
||||
job_id: string;
|
||||
attempt: number;
|
||||
reason: string;
|
||||
adjusted_bitrate: number;
|
||||
}
|
||||
|
||||
export interface WindowPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
theme: Theme;
|
||||
ui_zoom: number;
|
||||
ffmpeg_path: string | null;
|
||||
default_output_dir: string | null;
|
||||
last_compress_settings: CompressSettings | null;
|
||||
default_presets: number[];
|
||||
auto_retry: boolean;
|
||||
retry_threshold_percent: number;
|
||||
max_retry_attempts: number;
|
||||
show_ffmpeg_log: boolean;
|
||||
remember_window_position: boolean;
|
||||
window_position: WindowPosition | null;
|
||||
default_target_size: number;
|
||||
default_smart_cut: boolean;
|
||||
naming_pattern: string;
|
||||
last_open_dir: string | null;
|
||||
last_save_dir: string | null;
|
||||
preview_volume: number;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration: number;
|
||||
}
|
||||
53
src/lib/utils/format.ts
Normal file
53
src/lib/utils/format.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) {
|
||||
const gb = bytes / 1_073_741_824;
|
||||
return gb >= 10 ? `${Math.round(gb)} GB` : `${gb.toFixed(1)} GB`;
|
||||
}
|
||||
const mb = bytes / 1_048_576;
|
||||
if (mb >= 10) return `${Math.round(mb)} MB`;
|
||||
if (mb >= 0.1) return `${mb.toFixed(1)} MB`;
|
||||
const kb = bytes / 1024;
|
||||
return `${Math.round(kb)} KB`;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatTimecode(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.round((seconds % 1) * 100);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatTimecodeShort(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatBitrate(bps: number): string {
|
||||
const kbps = Math.round(bps / 1000);
|
||||
return `${kbps} kbps`;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number): string {
|
||||
if (value >= 100) return '100%';
|
||||
if (value >= 10) return `${value.toFixed(1)}%`;
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (seconds <= 0) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
51
src/lib/utils/keyboard.ts
Normal file
51
src/lib/utils/keyboard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
type ShortcutHandler = {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
handler: () => void;
|
||||
when?: () => boolean;
|
||||
};
|
||||
|
||||
let registered = false;
|
||||
let shortcuts: ShortcutHandler[] = [];
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
export function setShortcuts(handlers: ShortcutHandler[]) {
|
||||
shortcuts = handlers;
|
||||
}
|
||||
|
||||
export function registerShortcuts() {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// skip when typing in inputs
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
|
||||
for (const s of shortcuts) {
|
||||
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : !(e.ctrlKey || e.metaKey);
|
||||
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
|
||||
const keyMatch = e.key === s.key || e.code === s.key;
|
||||
|
||||
if (keyMatch && ctrlMatch && shiftMatch) {
|
||||
if (s.when && !s.when()) continue;
|
||||
e.preventDefault();
|
||||
s.handler();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
cleanup = () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
registered = false;
|
||||
};
|
||||
}
|
||||
|
||||
export function unregisterShortcuts() {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
shortcuts = [];
|
||||
}
|
||||
117
src/lib/utils/tauri.ts
Normal file
117
src/lib/utils/tauri.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type {
|
||||
VideoInfo,
|
||||
CompressSettings,
|
||||
TrimRange,
|
||||
OutputInfo,
|
||||
HardwareInfo,
|
||||
FFmpegStatus,
|
||||
ProgressEvent,
|
||||
AppConfig
|
||||
} from '$lib/types';
|
||||
|
||||
export function analyzeVideo(path: string): Promise<VideoInfo> {
|
||||
return invoke('analyze_video', { path });
|
||||
}
|
||||
|
||||
export function extractKeyframes(path: string): Promise<number[]> {
|
||||
return invoke('extract_keyframes', { path });
|
||||
}
|
||||
|
||||
export function generateThumbnails(path: string, count: number, duration?: number): Promise<string[]> {
|
||||
return invoke('generate_thumbnails', { path, count, duration: duration ?? null });
|
||||
}
|
||||
|
||||
export function generatePreview(path: string, codec?: string): Promise<string> {
|
||||
return invoke('generate_preview', { path, codec: codec ?? null });
|
||||
}
|
||||
|
||||
export function detectHardware(): Promise<HardwareInfo> {
|
||||
return invoke('detect_hardware');
|
||||
}
|
||||
|
||||
export function compress(
|
||||
input: string,
|
||||
output: string,
|
||||
settings: CompressSettings,
|
||||
trim?: TrimRange
|
||||
): Promise<OutputInfo> {
|
||||
return invoke('compress', { input, output, settings, trim: trim ?? null });
|
||||
}
|
||||
|
||||
export function trim(
|
||||
input: string,
|
||||
output: string,
|
||||
range: TrimRange,
|
||||
smartCut: boolean,
|
||||
stripAudio: boolean = false
|
||||
): Promise<OutputInfo> {
|
||||
return invoke('trim', { input, output, range, smartCut, stripAudio: stripAudio ? true : null });
|
||||
}
|
||||
|
||||
export function cancelJob(jobId: string): Promise<void> {
|
||||
return invoke('cancel_job', { jobId });
|
||||
}
|
||||
|
||||
export function checkFfmpeg(): Promise<FFmpegStatus> {
|
||||
return invoke('check_ffmpeg');
|
||||
}
|
||||
|
||||
export function openInExplorer(path: string): Promise<void> {
|
||||
return invoke('open_in_explorer', { path });
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<AppConfig> {
|
||||
return invoke('get_config');
|
||||
}
|
||||
|
||||
export function saveConfig(cfg: AppConfig): Promise<void> {
|
||||
return invoke('save_config_cmd', { newConfig: cfg });
|
||||
}
|
||||
|
||||
export function getOutputPath(input: string, mode: string, container: string = 'mp4'): Promise<string> {
|
||||
return invoke('get_output_path', { input, mode, container });
|
||||
}
|
||||
|
||||
export function initApp(): Promise<FFmpegStatus> {
|
||||
return invoke('init_app');
|
||||
}
|
||||
|
||||
export function downloadFfmpeg(): Promise<FFmpegStatus> {
|
||||
return invoke('download_ffmpeg');
|
||||
}
|
||||
|
||||
export interface InterruptedJob {
|
||||
input_path: string;
|
||||
output_path: string;
|
||||
mode: string;
|
||||
settings_json: string;
|
||||
}
|
||||
|
||||
export function checkRecovery(): Promise<InterruptedJob | null> {
|
||||
return invoke('check_recovery');
|
||||
}
|
||||
|
||||
export function cleanupRecovery(): Promise<void> {
|
||||
return invoke('cleanup_recovery');
|
||||
}
|
||||
|
||||
export function listenProgress(callback: (event: ProgressEvent) => void): Promise<UnlistenFn> {
|
||||
return listen<ProgressEvent>('progress', (ev) => callback(ev.payload));
|
||||
}
|
||||
|
||||
export function getStreamPort(): Promise<number> {
|
||||
return invoke('get_stream_port_cmd');
|
||||
}
|
||||
|
||||
let streamBase = '';
|
||||
|
||||
export function setStreamBase(base: string) {
|
||||
streamBase = base;
|
||||
}
|
||||
|
||||
export function streamUrl(path: string): string {
|
||||
if (!streamBase) return '';
|
||||
return streamBase + encodeURIComponent(path);
|
||||
}
|
||||
202
src/routes/+layout.svelte
Normal file
202
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import '@tabler/icons-webfont/dist/tabler-icons.min.css';
|
||||
import TitleBar from '$lib/components/TitleBar.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
import { config } from '$lib/stores/config.svelte';
|
||||
import { registerShortcuts, unregisterShortcuts, setShortcuts } from '$lib/utils/keyboard';
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import { initApp, checkRecovery, cleanupRecovery } from '$lib/utils/tauri';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getStreamPort, setStreamBase } from '$lib/utils/tauri';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let onSettings = $derived(page.url.pathname === '/settings');
|
||||
let prevSettings = $state(false);
|
||||
let slideDir = $state<'left' | 'right' | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (onSettings !== prevSettings) {
|
||||
slideDir = onSettings ? 'left' : 'right';
|
||||
prevSettings = onSettings;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSettingsClick() {
|
||||
if (onSettings) {
|
||||
goto('/');
|
||||
} else {
|
||||
goto('/settings');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await config.load();
|
||||
themeStore.init(config.current.theme);
|
||||
|
||||
// apply ui zoom
|
||||
if (config.current.ui_zoom && config.current.ui_zoom !== 100) {
|
||||
document.documentElement.style.fontSize = `${(config.current.ui_zoom / 100) * 16}px`;
|
||||
}
|
||||
|
||||
// check ffmpeg availability (with timeout so app doesn't hang)
|
||||
try {
|
||||
const status = await Promise.race([
|
||||
initApp(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
|
||||
]);
|
||||
app.ffmpegStatus = status;
|
||||
if (!status.found) {
|
||||
app.state = 'setup';
|
||||
}
|
||||
} catch {
|
||||
app.state = 'setup';
|
||||
}
|
||||
|
||||
// initialize stream server base URL
|
||||
try {
|
||||
const port = await getStreamPort();
|
||||
if (port > 0) {
|
||||
setStreamBase(`http://127.0.0.1:${port}/`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get stream port:', e);
|
||||
}
|
||||
|
||||
// check for interrupted job (non-blocking - don't hold up the app)
|
||||
checkRecovery().then(async (interrupted) => {
|
||||
if (interrupted) {
|
||||
toasts.add('info', 'A previous job was interrupted.');
|
||||
await cleanupRecovery();
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
setShortcuts([
|
||||
{
|
||||
key: 's',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
if (app.state === 'loaded' && app.activeMode !== 'none') {
|
||||
app.startProcess();
|
||||
}
|
||||
},
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: ',',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
goto(onSettings ? '/' : '/settings');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'o',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
window.dispatchEvent(new CustomEvent('cinch-open-file'));
|
||||
},
|
||||
when: () => app.state !== 'processing' && app.state !== 'analyzing'
|
||||
},
|
||||
{
|
||||
key: ' ',
|
||||
handler: () => { app.togglePlayback(); },
|
||||
when: () => app.state === 'loaded' || app.state === 'processing'
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
handler: () => {
|
||||
if (app.state === 'processing') {
|
||||
app.cancelProcess();
|
||||
} else if (onSettings) {
|
||||
goto('/');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'i',
|
||||
handler: () => {
|
||||
const out = app.trimRange?.end ?? app.videoInfo?.duration ?? 0;
|
||||
app.setTrimRange(app.currentTime, out);
|
||||
},
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: 'o',
|
||||
handler: () => {
|
||||
const inPt = app.trimRange?.start ?? 0;
|
||||
app.setTrimRange(inPt, app.currentTime);
|
||||
},
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: 'ArrowLeft',
|
||||
handler: () => { app.seek(app.currentTime - 1); },
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: 'ArrowRight',
|
||||
handler: () => { app.seek(app.currentTime + 1); },
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: 'ArrowLeft',
|
||||
shift: true,
|
||||
handler: () => { app.seek(app.currentTime - 5); },
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: 'ArrowRight',
|
||||
shift: true,
|
||||
handler: () => { app.seek(app.currentTime + 5); },
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: 'Home',
|
||||
handler: () => { app.seek(0); },
|
||||
when: () => app.state === 'loaded'
|
||||
},
|
||||
{
|
||||
key: 'End',
|
||||
handler: () => { app.seek(app.videoInfo?.duration ?? 0); },
|
||||
when: () => app.state === 'loaded'
|
||||
}
|
||||
]);
|
||||
registerShortcuts();
|
||||
|
||||
return () => {
|
||||
unregisterShortcuts();
|
||||
themeStore.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-screen overflow-hidden" style="background: var(--color-bg-base)">
|
||||
<TitleBar onSettingsClick={handleSettingsClick} showBack={onSettings} />
|
||||
<ToastContainer />
|
||||
<div
|
||||
class="flex-1 flex flex-col overflow-hidden"
|
||||
class:slide-in-left={slideDir === 'left'}
|
||||
class:slide-in-right={slideDir === 'right'}
|
||||
onanimationend={() => { slideDir = null; }}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.slide-in-left {
|
||||
animation: fadeIn 200ms ease-out;
|
||||
}
|
||||
.slide-in-right {
|
||||
animation: fadeIn 200ms ease-out;
|
||||
}
|
||||
</style>
|
||||
2
src/routes/+layout.ts
Normal file
2
src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
5
src/routes/+page.svelte
Normal file
5
src/routes/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import MainView from '$lib/components/MainView.svelte';
|
||||
</script>
|
||||
|
||||
<MainView />
|
||||
774
src/routes/settings/+page.svelte
Normal file
774
src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,774 @@
|
||||
<script lang="ts">
|
||||
import { config } from '$lib/stores/config.svelte';
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
import { app } from '$lib/stores/app.svelte';
|
||||
import { checkFfmpeg } from '$lib/utils/tauri';
|
||||
import { toasts } from '$lib/stores/toast.svelte';
|
||||
import CustomSelect from '$lib/components/CustomSelect.svelte';
|
||||
import Slider from '$lib/components/Slider.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import type { Theme, CompressSettings } from '$lib/types';
|
||||
|
||||
let detecting = $state(false);
|
||||
|
||||
const themeChoices: { value: Theme; icon: string; label: string }[] = [
|
||||
{ value: 'light', icon: 'ti-sun', label: 'Light' },
|
||||
{ value: 'dark', icon: 'ti-moon', label: 'Dark' },
|
||||
{ value: 'system', icon: 'ti-device-desktop', label: 'System' }
|
||||
];
|
||||
|
||||
function handleThemeChange(t: Theme) {
|
||||
themeStore.setTheme(t);
|
||||
config.update({ theme: t });
|
||||
}
|
||||
|
||||
function handleZoom(val: number) {
|
||||
const zoom = Math.round(val);
|
||||
config.update({ ui_zoom: zoom });
|
||||
document.documentElement.style.fontSize = `${(zoom / 100) * 16}px`;
|
||||
}
|
||||
|
||||
const targetOptions = [
|
||||
{ value: '8', label: '8 MB', icon: 'ti-file-zip' },
|
||||
{ value: '25', label: '25 MB', icon: 'ti-file-zip' },
|
||||
{ value: '50', label: '50 MB', icon: 'ti-file-zip' },
|
||||
{ value: '100', label: '100 MB', icon: 'ti-file-zip' }
|
||||
];
|
||||
|
||||
const codecOptions = [
|
||||
{ value: 'H264', label: 'H.264', icon: 'ti-badge-hd' },
|
||||
{ value: 'HEVC', label: 'HEVC', icon: 'ti-badge-4k' },
|
||||
{ value: 'AV1', label: 'AV1', icon: 'ti-atom' }
|
||||
];
|
||||
|
||||
const containerOptions = [
|
||||
{ value: 'MP4', label: 'MP4', icon: 'ti-box' },
|
||||
{ value: 'MKV', label: 'MKV', icon: 'ti-package' },
|
||||
{ value: 'WebM', label: 'WebM', icon: 'ti-world' },
|
||||
{ value: 'MOV', label: 'MOV', icon: 'ti-movie' },
|
||||
{ value: 'AVI', label: 'AVI', icon: 'ti-archive' },
|
||||
{ value: 'TS', label: 'TS', icon: 'ti-broadcast' }
|
||||
];
|
||||
|
||||
const audioCodecOptions = [
|
||||
{ value: 'AAC', label: 'AAC', icon: 'ti-headphones' },
|
||||
{ value: 'Opus', label: 'Opus', icon: 'ti-vinyl' },
|
||||
{ value: 'None', label: 'None (strip)', icon: 'ti-volume-off' }
|
||||
];
|
||||
|
||||
const audioBitrateOptions = [
|
||||
{ value: '64', label: '64 kbps', icon: 'ti-antenna-bars-1' },
|
||||
{ value: '96', label: '96 kbps', icon: 'ti-antenna-bars-2' },
|
||||
{ value: '128', label: '128 kbps', icon: 'ti-antenna-bars-3' },
|
||||
{ value: '192', label: '192 kbps', icon: 'ti-antenna-bars-4' },
|
||||
{ value: '256', label: '256 kbps', icon: 'ti-antenna-bars-5' },
|
||||
{ value: '320', label: '320 kbps', icon: 'ti-antenna-bars-5' }
|
||||
];
|
||||
|
||||
const retryThresholdOptions = [
|
||||
{ value: '1', label: '1%', icon: 'ti-target' },
|
||||
{ value: '2', label: '2%', icon: 'ti-target' },
|
||||
{ value: '3', label: '3%', icon: 'ti-target' },
|
||||
{ value: '5', label: '5%', icon: 'ti-target' }
|
||||
];
|
||||
|
||||
const maxRetryOptions = [
|
||||
{ value: '1', label: '1 attempt', icon: 'ti-refresh' },
|
||||
{ value: '2', label: '2 attempts', icon: 'ti-refresh' },
|
||||
{ value: '3', label: '3 attempts', icon: 'ti-refresh' },
|
||||
{ value: '5', label: '5 attempts', icon: 'ti-refresh' },
|
||||
{ value: '10', label: '10 attempts', icon: 'ti-refresh' }
|
||||
];
|
||||
|
||||
const namingOptions = [
|
||||
{ value: '{name}_{mode}_{timestamp}', label: 'Name + mode + time', icon: 'ti-file-text' },
|
||||
{ value: '{name}_{mode}', label: 'Name + mode', icon: 'ti-file-text' },
|
||||
{ value: '{name}_cinch', label: 'Name + cinch', icon: 'ti-file-text' }
|
||||
];
|
||||
|
||||
let defaultCodec = $derived(config.current.last_compress_settings?.video_codec ?? 'H264');
|
||||
let defaultContainer = $derived(config.current.last_compress_settings?.container ?? 'MP4');
|
||||
let defaultAudioCodec = $derived(config.current.last_compress_settings?.audio_codec ?? 'AAC');
|
||||
let defaultAudioBitrate = $derived(String(config.current.last_compress_settings?.audio_bitrate ?? 128));
|
||||
|
||||
const defaultCompressSettings: CompressSettings = {
|
||||
strategy: { type: 'TargetSize', mb: 8 },
|
||||
video_codec: 'H264',
|
||||
audio_codec: 'AAC',
|
||||
audio_bitrate: 128,
|
||||
container: 'MP4',
|
||||
resolution: { type: 'Original' },
|
||||
speed_preset: 'medium',
|
||||
hw_accel: 'Auto'
|
||||
};
|
||||
|
||||
function updateLastSetting<K extends keyof CompressSettings>(key: K, value: CompressSettings[K]) {
|
||||
config.update({
|
||||
last_compress_settings: { ...defaultCompressSettings, ...config.current.last_compress_settings, [key]: value }
|
||||
});
|
||||
}
|
||||
|
||||
async function detectFfmpeg() {
|
||||
detecting = true;
|
||||
try {
|
||||
const status = await checkFfmpeg();
|
||||
app.ffmpegStatus = status;
|
||||
if (status.found) {
|
||||
config.update({ ffmpeg_path: status.path });
|
||||
toasts.add('success', `FFmpeg ${status.version} detected.`);
|
||||
} else {
|
||||
toasts.add('warning', 'FFmpeg not found on this system.');
|
||||
}
|
||||
} catch {
|
||||
toasts.add('error', 'Detection failed.');
|
||||
}
|
||||
detecting = false;
|
||||
}
|
||||
|
||||
async function browseFfmpeg() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const result = await open({ multiple: false, filters: [{ name: 'FFmpeg', extensions: ['exe'] }] });
|
||||
if (result) {
|
||||
const path = typeof result === 'string' ? result : result.path;
|
||||
if (path) {
|
||||
config.update({ ffmpeg_path: path });
|
||||
app.ffmpegStatus = { ...app.ffmpegStatus, path };
|
||||
toasts.add('success', 'FFmpeg path updated.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toasts.add('error', 'Could not open file browser.');
|
||||
}
|
||||
}
|
||||
|
||||
async function browseOutputDir() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const result = await open({ directory: true });
|
||||
if (result) {
|
||||
const path = typeof result === 'string' ? result : result.path;
|
||||
if (path) config.update({ default_output_dir: path });
|
||||
}
|
||||
} catch {
|
||||
toasts.add('error', 'Could not open folder browser.');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults() {
|
||||
await config.reset();
|
||||
themeStore.setTheme(config.current.theme);
|
||||
document.documentElement.style.fontSize = `${(config.current.ui_zoom / 100) * 16}px`;
|
||||
toasts.add('success', 'Settings reset to defaults.');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings-page">
|
||||
<div class="settings-header">
|
||||
<h1 class="settings-title">Settings</h1>
|
||||
<p class="settings-subtitle">Configure how Cinch works</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- Appearance -->
|
||||
<div class="settings-card settings-card--small">
|
||||
<div class="card-header">
|
||||
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-compress)">
|
||||
<i class="ti ti-palette card-icon"></i>
|
||||
</div>
|
||||
<h2 class="card-title">Appearance</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Theme</span>
|
||||
<div class="theme-pills">
|
||||
{#each themeChoices as t}
|
||||
<button
|
||||
type="button"
|
||||
class="theme-pill"
|
||||
class:theme-pill--active={themeStore.theme === t.value}
|
||||
onclick={() => handleThemeChange(t.value)}
|
||||
>
|
||||
<i class="ti {t.icon}"></i>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<span class="field-label">UI Zoom</span>
|
||||
<div class="zoom-row">
|
||||
<button type="button" class="zoom-btn" onclick={() => handleZoom(Math.max(75, config.current.ui_zoom - 5))} aria-label="Decrease zoom">
|
||||
<i class="ti ti-minus"></i>
|
||||
</button>
|
||||
<div class="zoom-slider"><Slider min={75} max={200} step={5} value={config.current.ui_zoom} onChange={handleZoom} /></div>
|
||||
<button type="button" class="zoom-btn" onclick={() => handleZoom(Math.min(200, config.current.ui_zoom + 5))} aria-label="Increase zoom">
|
||||
<i class="ti ti-plus"></i>
|
||||
</button>
|
||||
<span class="zoom-value">{config.current.ui_zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Defaults -->
|
||||
<div class="settings-card settings-card--large">
|
||||
<div class="card-header">
|
||||
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-trim)">
|
||||
<i class="ti ti-settings card-icon"></i>
|
||||
</div>
|
||||
<h2 class="card-title">Default settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="selects-grid">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Target size</span>
|
||||
<CustomSelect options={targetOptions} value={String(config.current.default_target_size)} onChange={(v) => config.update({ default_target_size: parseInt(v) })} />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<span class="field-label">Video codec</span>
|
||||
<CustomSelect options={codecOptions} value={defaultCodec} onChange={(v) => updateLastSetting('video_codec', v as any)} />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<span class="field-label">Container</span>
|
||||
<CustomSelect options={containerOptions} value={defaultContainer} onChange={(v) => updateLastSetting('container', v as any)} />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<span class="field-label">Audio codec</span>
|
||||
<CustomSelect options={audioCodecOptions} value={defaultAudioCodec} onChange={(v) => updateLastSetting('audio_codec', v as any)} />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<span class="field-label">Audio bitrate</span>
|
||||
<CustomSelect options={audioBitrateOptions} value={defaultAudioBitrate} onChange={(v) => updateLastSetting('audio_bitrate', parseInt(v))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" style="margin-top: 12px;">
|
||||
<div class="field-row-left">
|
||||
<i class="ti ti-cut"></i>
|
||||
<div>
|
||||
<span>Smart cut by default</span>
|
||||
<span class="field-hint">Use smart re-encode instead of keyframe snap</span>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle checked={config.current.default_smart_cut} onChange={(v) => config.update({ default_smart_cut: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing -->
|
||||
<div class="settings-card settings-card--medium">
|
||||
<div class="card-header">
|
||||
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-warning)">
|
||||
<i class="ti ti-bolt card-icon"></i>
|
||||
</div>
|
||||
<h2 class="card-title">Processing</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="field-row">
|
||||
<div class="field-row-left">
|
||||
<i class="ti ti-refresh"></i>
|
||||
<div>
|
||||
<span>Auto-retry when over target</span>
|
||||
<span class="field-hint">Automatically re-encode if output exceeds target</span>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle checked={config.current.auto_retry} onChange={(v) => config.update({ auto_retry: v })} />
|
||||
</div>
|
||||
|
||||
{#if config.current.auto_retry}
|
||||
<div class="selects-grid selects-grid--2">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Threshold</span>
|
||||
<CustomSelect options={retryThresholdOptions} value={String(config.current.retry_threshold_percent)} onChange={(v) => config.update({ retry_threshold_percent: parseFloat(v) })} />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<span class="field-label">Max retries</span>
|
||||
<CustomSelect options={maxRetryOptions} value={String(config.current.max_retry_attempts)} onChange={(v) => config.update({ max_retry_attempts: parseInt(v) })} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field-row" style="margin-top: 8px;">
|
||||
<div class="field-row-left">
|
||||
<i class="ti ti-terminal-2"></i>
|
||||
<div>
|
||||
<span>Show FFmpeg log</span>
|
||||
<span class="field-hint">Display raw FFmpeg output during encoding</span>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle checked={config.current.show_ffmpeg_log} onChange={(v) => config.update({ show_ffmpeg_log: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FFmpeg -->
|
||||
<div class="settings-card settings-card--medium">
|
||||
<div class="card-header">
|
||||
<div class="card-icon-wrap" style="--card-icon-color: #a78bfa">
|
||||
<i class="ti ti-terminal-2 card-icon"></i>
|
||||
</div>
|
||||
<h2 class="card-title">FFmpeg</h2>
|
||||
{#if app.ffmpegStatus.found}
|
||||
<span class="card-badge card-badge--ok"><i class="ti ti-circle-check"></i> Ready</span>
|
||||
{:else}
|
||||
<span class="card-badge card-badge--warn"><i class="ti ti-alert-circle"></i> Missing</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Binary path</span>
|
||||
<div class="path-bar">
|
||||
<i class="ti ti-folder-code path-bar-icon"></i>
|
||||
<span class="path-bar-text" class:path-bar-text--empty={!config.current.ffmpeg_path}>
|
||||
{config.current.ffmpeg_path ?? 'Not configured - will search system PATH'}
|
||||
</span>
|
||||
<div class="path-bar-actions">
|
||||
<button type="button" class="path-bar-btn" onclick={browseFfmpeg} title="Browse for ffmpeg.exe">
|
||||
<i class="ti ti-folder-open"></i> Browse
|
||||
</button>
|
||||
<button type="button" class="path-bar-btn" class:path-bar-btn--spinning={detecting} disabled={detecting} onclick={detectFfmpeg} title="Auto-detect FFmpeg">
|
||||
<i class="ti ti-search"></i> Detect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if app.ffmpegStatus.version}
|
||||
<div class="version-pill">
|
||||
<i class="ti ti-circle-check"></i>
|
||||
Version {app.ffmpegStatus.version} detected
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="settings-card settings-card--wide">
|
||||
<div class="card-header">
|
||||
<div class="card-icon-wrap" style="--card-icon-color: #f472b6">
|
||||
<i class="ti ti-download card-icon"></i>
|
||||
</div>
|
||||
<h2 class="card-title">Output</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Default folder</span>
|
||||
<div class="path-bar">
|
||||
<i class="ti ti-folder path-bar-icon"></i>
|
||||
<span class="path-bar-text" class:path-bar-text--empty={!config.current.default_output_dir}>
|
||||
{config.current.default_output_dir ?? 'Same folder as source file'}
|
||||
</span>
|
||||
<div class="path-bar-actions">
|
||||
<button type="button" class="path-bar-btn" onclick={browseOutputDir} title="Choose output folder">
|
||||
<i class="ti ti-folder-open"></i> Browse
|
||||
</button>
|
||||
{#if config.current.default_output_dir}
|
||||
<button type="button" class="path-bar-btn path-bar-btn--danger" onclick={() => config.update({ default_output_dir: null })} title="Reset to source folder">
|
||||
<i class="ti ti-rotate"></i> Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group" style="margin-top: 12px;">
|
||||
<span class="field-label">Naming pattern</span>
|
||||
<CustomSelect options={namingOptions} value={config.current.naming_pattern} onChange={(v) => config.update({ naming_pattern: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button type="button" class="reset-btn" onclick={resetDefaults}>
|
||||
<i class="ti ti-rotate"></i>
|
||||
Reset all to defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page shell ── */
|
||||
.settings-page {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 28px 32px 48px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.settings-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.settings-subtitle {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Bento grid ── */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.settings-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.settings-card--small { grid-column: span 4; }
|
||||
.settings-card--large { grid-column: span 8; }
|
||||
.settings-card--medium { grid-column: span 6; }
|
||||
.settings-card--wide { grid-column: span 12; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-card--small,
|
||||
.settings-card--large,
|
||||
.settings-card--medium,
|
||||
.settings-card--wide {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Card header ── */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.card-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--card-icon-color) 12%, transparent);
|
||||
color: var(--card-icon-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.card-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-badge--ok {
|
||||
background: rgba(5, 150, 105, 0.12);
|
||||
color: var(--color-accent-compress);
|
||||
}
|
||||
.card-badge--warn {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: var(--color-accent-warning);
|
||||
}
|
||||
|
||||
/* ── Card body ── */
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Field groups ── */
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-disabled);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.field-hint {
|
||||
display: block;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-disabled);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Field rows (toggle rows) ── */
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.field-row-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.field-row-left > i {
|
||||
font-size: 16px;
|
||||
color: var(--color-text-disabled);
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.field-row-left > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Selects grid ── */
|
||||
.selects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.selects-grid--2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.selects-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Theme pills ── */
|
||||
.theme-pills {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 3px;
|
||||
}
|
||||
.theme-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.theme-pill:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.theme-pill--active {
|
||||
background: var(--color-accent-compress);
|
||||
color: white;
|
||||
}
|
||||
.theme-pill--active:hover {
|
||||
color: white;
|
||||
}
|
||||
.theme-pill > i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Zoom row ── */
|
||||
.zoom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.zoom-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
padding: 0;
|
||||
}
|
||||
.zoom-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.zoom-btn > i {
|
||||
font-size: 12px;
|
||||
}
|
||||
.zoom-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.zoom-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Path bar ── */
|
||||
.path-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
min-height: 38px;
|
||||
}
|
||||
.path-bar-icon {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-disabled);
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.path-bar-text {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.path-bar-text--empty {
|
||||
color: var(--color-text-disabled);
|
||||
font-style: italic;
|
||||
}
|
||||
.path-bar-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.path-bar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.path-bar-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.path-bar-btn--danger:hover {
|
||||
color: var(--color-accent-error);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
.path-bar-btn--spinning {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.path-bar-btn > i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.version-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-accent-compress);
|
||||
}
|
||||
.version-pill > i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.reset-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.reset-btn:hover {
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-accent-error);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.reset-btn > i {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
BIN
static/fonts/GeistMono-Variable.woff2
Normal file
BIN
static/fonts/GeistMono-Variable.woff2
Normal file
Binary file not shown.
BIN
static/fonts/NunitoSans-Variable.woff2
Normal file
BIN
static/fonts/NunitoSans-Variable.woff2
Normal file
Binary file not shown.
BIN
static/fonts/Syne-Variable.woff2
Normal file
BIN
static/fonts/Syne-Variable.woff2
Normal file
Binary file not shown.
12
svelte.config.js
Normal file
12
svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: 'index.html'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_']
|
||||
});
|
||||
Reference in New Issue
Block a user