chore: stage all pending work — caption styling, media server, processing modal, docs, summaries
Includes: - Extended caption styling (font, shadow, dimmed color, bg toggle) - Media server, subtitle downloader, VTT parser, processing modal - Waveform tiers, thumbnail/timeline improvements, transport controls - Hybrid download model, dependency management, clip export enhancements - 21 chat summaries, 2 implementation plans, 2 design specs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
129
src-tauri/Cargo.lock
generated
129
src-tauri/Cargo.lock
generated
@@ -82,6 +82,58 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -1306,12 +1358,24 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.11.1"
|
||||
@@ -1325,6 +1389,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -1823,6 +1888,12 @@ dependencies = [
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -1844,6 +1915,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2671,6 +2752,12 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2832,6 +2919,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
@@ -2861,6 +2959,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.23.0"
|
||||
@@ -3297,6 +3407,7 @@ dependencies = [
|
||||
name = "tauri-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3309,6 +3420,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3861,6 +3973,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3871,10 +3984,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -3899,6 +4021,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -4005,6 +4128,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
|
||||
@@ -30,6 +30,8 @@ thiserror = "2.0.20"
|
||||
uuid = { version = "1.26.1", features = ["v4"] }
|
||||
tempfile = "3.27.0"
|
||||
dirs = "6.0.0"
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
|
||||
@@ -12,6 +12,11 @@ pub fn check_dependencies(cookie_source: CookieSource) -> Vec<DependencyStatus>
|
||||
dependency_manager::get_all_dependency_statuses(&cookie_source)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_subtitles_filter_available() -> bool {
|
||||
dependency_manager::check_subtitles_filter()
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
|
||||
pub enum InstallEvent {
|
||||
|
||||
@@ -11,6 +11,12 @@ pub enum ExportEvent {
|
||||
total: usize,
|
||||
label: String,
|
||||
},
|
||||
ClipProgress {
|
||||
current: usize,
|
||||
total: usize,
|
||||
label: String,
|
||||
percent: f64,
|
||||
},
|
||||
Finished {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
@@ -27,6 +33,10 @@ pub async fn export_clips(
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output_directory = clip_exporter::expand_tilde_path(&config.output_directory);
|
||||
let ext = clip_exporter::get_extension(&config.source_file_path, &config.cut_mode);
|
||||
let use_subs = config.include_captions && config.caption_file_path.is_some();
|
||||
let burn_in = config.burn_in_captions;
|
||||
let caption_path = config.caption_file_path.as_deref().unwrap_or("");
|
||||
let caption_style = config.caption_style.as_ref();
|
||||
|
||||
match config.export_scope {
|
||||
ExportScope::Individual => {
|
||||
@@ -34,10 +44,13 @@ pub async fn export_clips(
|
||||
let mut output_paths = Vec::new();
|
||||
|
||||
for (i, clip) in config.clips.iter().enumerate() {
|
||||
let clip_num = i + 1;
|
||||
let label = clip.label.clone();
|
||||
|
||||
let _ = on_event.send(ExportEvent::Progress {
|
||||
current: i + 1,
|
||||
current: clip_num,
|
||||
total,
|
||||
label: clip.label.clone(),
|
||||
label: label.clone(),
|
||||
});
|
||||
|
||||
let output = clip_exporter::generate_output_path(
|
||||
@@ -47,12 +60,35 @@ pub async fn export_clips(
|
||||
&ext,
|
||||
);
|
||||
|
||||
clip_exporter::export_single_clip(
|
||||
clip,
|
||||
&config.source_file_path,
|
||||
&output,
|
||||
&config.cut_mode,
|
||||
)?;
|
||||
let progress_cb = |pct: f64| {
|
||||
let _ = on_event.send(ExportEvent::ClipProgress {
|
||||
current: clip_num,
|
||||
total,
|
||||
label: label.clone(),
|
||||
percent: pct,
|
||||
});
|
||||
};
|
||||
|
||||
if use_subs {
|
||||
clip_exporter::export_single_clip_with_subs(
|
||||
clip,
|
||||
&config.source_file_path,
|
||||
&output,
|
||||
&config.cut_mode,
|
||||
caption_path,
|
||||
burn_in,
|
||||
caption_style,
|
||||
&progress_cb,
|
||||
)?;
|
||||
} else {
|
||||
clip_exporter::export_single_clip(
|
||||
clip,
|
||||
&config.source_file_path,
|
||||
&output,
|
||||
&config.cut_mode,
|
||||
&progress_cb,
|
||||
)?;
|
||||
}
|
||||
|
||||
output_paths.push(output);
|
||||
}
|
||||
@@ -63,6 +99,7 @@ pub async fn export_clips(
|
||||
Ok(output_paths)
|
||||
}
|
||||
ExportScope::Merged => {
|
||||
let total_clips = config.clips.len();
|
||||
let _ = on_event.send(ExportEvent::Progress {
|
||||
current: 1,
|
||||
total: 1,
|
||||
@@ -89,14 +126,45 @@ pub async fn export_clips(
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let clip_labels: Vec<String> = sorted_clips.iter().map(|c| c.label.clone()).collect();
|
||||
let temp_dir_str = temp_dir.to_string_lossy().to_string();
|
||||
let merge_result = clip_exporter::export_merged(
|
||||
&sorted_clips,
|
||||
&config.source_file_path,
|
||||
&output,
|
||||
&config.cut_mode,
|
||||
&temp_dir_str,
|
||||
);
|
||||
|
||||
let merge_progress = |clip_idx: usize, pct: f64| {
|
||||
let label = if clip_idx < clip_labels.len() {
|
||||
format!("Encoding {}", clip_labels[clip_idx])
|
||||
} else {
|
||||
"Concatenating…".to_string()
|
||||
};
|
||||
let _ = on_event.send(ExportEvent::ClipProgress {
|
||||
current: clip_idx + 1,
|
||||
total: total_clips,
|
||||
label,
|
||||
percent: pct,
|
||||
});
|
||||
};
|
||||
|
||||
let merge_result = if use_subs {
|
||||
clip_exporter::export_merged_with_subs(
|
||||
&sorted_clips,
|
||||
&config.source_file_path,
|
||||
&output,
|
||||
&config.cut_mode,
|
||||
&temp_dir_str,
|
||||
caption_path,
|
||||
burn_in,
|
||||
caption_style,
|
||||
&merge_progress,
|
||||
)
|
||||
} else {
|
||||
clip_exporter::export_merged(
|
||||
&sorted_clips,
|
||||
&config.source_file_path,
|
||||
&output,
|
||||
&config.cut_mode,
|
||||
&temp_dir_str,
|
||||
&merge_progress,
|
||||
)
|
||||
};
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
merge_result?;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::models::{CookieSource, VideoMetadata};
|
||||
use crate::services::{download_manager, video_resolver};
|
||||
use crate::services::{download_manager, subtitle_downloader, video_resolver};
|
||||
use serde::Serialize;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use tauri::ipc::Channel;
|
||||
@@ -23,24 +23,36 @@ pub enum DownloadEvent {
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
fn variant_dir(variant: &str) -> String {
|
||||
std::env::temp_dir()
|
||||
.join("video-clipper")
|
||||
.join(variant)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_download(
|
||||
url: String,
|
||||
cookie_source: CookieSource,
|
||||
format_spec: String,
|
||||
variant: String,
|
||||
on_event: Channel<DownloadEvent>,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let temp_dir = std::env::temp_dir()
|
||||
.join("video-clipper")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
std::fs::create_dir_all(&temp_dir)
|
||||
.map_err(|e| format!("Failed to create temp dir: {e}"))?;
|
||||
let output_dir = variant_dir(&variant);
|
||||
std::fs::create_dir_all(&output_dir)
|
||||
.map_err(|e| format!("Failed to create dir: {e}"))?;
|
||||
|
||||
eprintln!(
|
||||
"[video-clipper:download:{}] starting — format='{}' dir='{}'",
|
||||
variant, format_spec, output_dir
|
||||
);
|
||||
|
||||
let (mut child, _template) =
|
||||
download_manager::start_download(&url, &cookie_source, &temp_dir)?;
|
||||
download_manager::start_download(&url, &cookie_source, &output_dir, &format_spec)?;
|
||||
|
||||
let Some(stderr) = child.stderr.take() else {
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Download failed: {e}"))?;
|
||||
@@ -51,7 +63,7 @@ pub async fn start_download(
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let reader = BufReader::new(stderr);
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut last_file_path: Option<String> = None;
|
||||
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
@@ -59,9 +71,7 @@ pub async fn start_download(
|
||||
let _ = on_event.send(DownloadEvent::Progress { percent: pct });
|
||||
}
|
||||
|
||||
if line.contains("[download] Destination:")
|
||||
|| line.contains("[Merger] Merging formats into")
|
||||
{
|
||||
if line.contains("[download] Destination:") {
|
||||
if let Some(path) = line.split(": ").nth(1) {
|
||||
let trimmed = path.trim().trim_matches('"').to_string();
|
||||
let _ = on_event.send(DownloadEvent::FilePath {
|
||||
@@ -69,6 +79,30 @@ pub async fn start_download(
|
||||
});
|
||||
last_file_path = Some(trimmed);
|
||||
}
|
||||
} else if line.contains("[Merger] Merging formats into") {
|
||||
let trimmed = line
|
||||
.trim_start_matches("[Merger] Merging formats into ")
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
if !trimmed.is_empty() {
|
||||
let _ = on_event.send(DownloadEvent::FilePath {
|
||||
path: trimmed.clone(),
|
||||
});
|
||||
last_file_path = Some(trimmed);
|
||||
}
|
||||
} else if line.contains("has already been downloaded") {
|
||||
if let Some(rest) = line.strip_prefix("[download] ") {
|
||||
if let Some(path) = rest.strip_suffix(" has already been downloaded") {
|
||||
let trimmed = path.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
let _ = on_event.send(DownloadEvent::FilePath {
|
||||
path: trimmed.clone(),
|
||||
});
|
||||
last_file_path = Some(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +110,12 @@ pub async fn start_download(
|
||||
.wait()
|
||||
.map_err(|e| format!("Download failed: {e}"))?;
|
||||
let path = last_file_path.unwrap_or_default();
|
||||
eprintln!(
|
||||
"[video-clipper:download:{}] finished — success={}, path='{}'",
|
||||
variant,
|
||||
status.success(),
|
||||
path
|
||||
);
|
||||
let _ = on_event.send(DownloadEvent::Finished {
|
||||
success: status.success(),
|
||||
path,
|
||||
@@ -86,3 +126,67 @@ pub async fn start_download(
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Check if a video with the given title exists in the variant's download dir.
|
||||
#[tauri::command]
|
||||
pub async fn check_cached_download(title: String, variant: String) -> Option<String> {
|
||||
let dir = variant_dir(&variant);
|
||||
let dir_path = std::path::Path::new(&dir);
|
||||
if !dir_path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let entries = std::fs::read_dir(dir_path).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
if stem == title {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
if let Ok(meta) = std::fs::metadata(&path_str) {
|
||||
if meta.len() > 0 {
|
||||
eprintln!(
|
||||
"[video-clipper:cache:{}] found: '{}'",
|
||||
variant, path_str
|
||||
);
|
||||
return Some(path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Download English VTT subtitles for a video URL.
|
||||
/// Returns the path to the .vtt file, or an error string.
|
||||
#[tauri::command]
|
||||
pub async fn download_subtitles(
|
||||
url: String,
|
||||
cookie_source: CookieSource,
|
||||
is_auto: bool,
|
||||
) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output_dir = std::env::temp_dir()
|
||||
.join("video-clipper")
|
||||
.join("subtitles")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
eprintln!(
|
||||
"[video-clipper:subtitles] downloading (auto={}) to '{}'",
|
||||
is_auto, output_dir
|
||||
);
|
||||
|
||||
let result =
|
||||
subtitle_downloader::download_subtitles(&url, &cookie_source, &output_dir, is_auto);
|
||||
|
||||
match &result {
|
||||
Ok(path) => eprintln!("[video-clipper:subtitles] done: '{}'", path),
|
||||
Err(e) => eprintln!("[video-clipper:subtitles] FAILED: {}", e),
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
use crate::models::{CookieSource, DependencyStatus};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Cached resolved paths for ffmpeg and ffprobe binaries.
|
||||
/// Prefers ffmpeg-full (Homebrew keg) if available, otherwise falls back to PATH.
|
||||
static FFMPEG_PATH: OnceLock<String> = OnceLock::new();
|
||||
static FFPROBE_PATH: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Returns the best available ffmpeg binary path.
|
||||
/// Checks for Homebrew's ffmpeg-full keg first (has libass), then falls back to PATH ffmpeg.
|
||||
pub fn ffmpeg_bin() -> &'static str {
|
||||
FFMPEG_PATH.get_or_init(|| {
|
||||
let keg = PathBuf::from("/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg");
|
||||
if keg.exists() {
|
||||
keg.to_string_lossy().into_owned()
|
||||
} else {
|
||||
"ffmpeg".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the best available ffprobe binary path.
|
||||
pub fn ffprobe_bin() -> &'static str {
|
||||
FFPROBE_PATH.get_or_init(|| {
|
||||
let keg = PathBuf::from("/opt/homebrew/opt/ffmpeg-full/bin/ffprobe");
|
||||
if keg.exists() {
|
||||
keg.to_string_lossy().into_owned()
|
||||
} else {
|
||||
"ffprobe".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_tool_exists(name: &str) -> Option<String> {
|
||||
let output = Command::new("which").arg(name).output().ok()?;
|
||||
@@ -80,6 +112,26 @@ pub fn get_all_dependency_statuses(cookie_source: &CookieSource) -> Vec<Dependen
|
||||
]
|
||||
}
|
||||
|
||||
/// Check whether ffmpeg's `subtitles` video filter is available.
|
||||
/// Requires ffmpeg to be compiled with libass support.
|
||||
pub fn check_subtitles_filter() -> bool {
|
||||
let output = Command::new(ffmpeg_bin())
|
||||
.arg("-filters")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
stdout.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
// Filter list lines look like: " T. subtitles V->V ..."
|
||||
trimmed.contains("subtitles") && trimmed.contains("V->V")
|
||||
})
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_install_args(dep_name: &str) -> Result<Vec<String>, String> {
|
||||
match dep_name {
|
||||
"ffmpeg" => Ok(vec![
|
||||
|
||||
@@ -16,9 +16,10 @@ pub fn parse_progress_line(line: &str) -> Option<f64> {
|
||||
pub fn start_download(
|
||||
url: &str,
|
||||
cookie_source: &CookieSource,
|
||||
temp_dir: &str,
|
||||
output_dir: &str,
|
||||
format_spec: &str,
|
||||
) -> Result<(Child, String), String> {
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", temp_dir);
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", output_dir);
|
||||
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("-o")
|
||||
@@ -27,15 +28,17 @@ pub fn start_download(
|
||||
.arg("--no-part")
|
||||
.arg("-c")
|
||||
.arg("--remote-components")
|
||||
.arg("ejs:github");
|
||||
.arg("ejs:github")
|
||||
.arg("-f")
|
||||
.arg(format_spec);
|
||||
|
||||
for arg in cookie_source.to_ytdlp_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
cmd.arg(url)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::services::dependency_manager::ffprobe_bin;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
|
||||
@@ -17,7 +18,7 @@ pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
|
||||
}
|
||||
|
||||
pub fn extract_keyframes(file_path: &str) -> Result<Vec<f64>, String> {
|
||||
let output = Command::new("ffprobe")
|
||||
let output = Command::new(ffprobe_bin())
|
||||
.args([
|
||||
"-select_streams",
|
||||
"v",
|
||||
|
||||
44
src-tauri/src/services/media_server.rs
Normal file
44
src-tauri/src/services/media_server.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::net::TcpListener;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
/// Bind a local HTTP file server to a random port and return the port + std listener.
|
||||
/// The caller is responsible for spawning the server on a Tokio runtime.
|
||||
///
|
||||
/// WKWebView plays audio correctly from http://localhost URLs with proper
|
||||
/// range-request support (provided by tower-http's ServeDir).
|
||||
pub fn bind_media_server() -> Result<(u16, TcpListener), String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("Failed to bind media server: {e}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| format!("Failed to set non-blocking: {e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local addr: {e}"))?
|
||||
.port();
|
||||
Ok((port, listener))
|
||||
}
|
||||
|
||||
/// Spawn the axum server on the Tauri async runtime.
|
||||
/// Must be called after a Tokio runtime is available.
|
||||
pub fn spawn_media_server(listener: TcpListener) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let tokio_listener = tokio::net::TcpListener::from_std(listener)
|
||||
.expect("Failed to convert std listener to tokio");
|
||||
|
||||
let service = ServeDir::new("/");
|
||||
let app = axum::Router::new()
|
||||
.fallback_service(service)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
eprintln!(
|
||||
"[video-clipper:media-server] serving on {:?}",
|
||||
tokio_listener.local_addr()
|
||||
);
|
||||
|
||||
if let Err(e) = axum::serve(tokio_listener, app).await {
|
||||
eprintln!("[video-clipper:media-server] server error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ pub mod clip_exporter;
|
||||
pub mod dependency_manager;
|
||||
pub mod download_manager;
|
||||
pub mod keyframe_index;
|
||||
pub mod media_server;
|
||||
pub mod subtitle_downloader;
|
||||
pub mod thumbnail_extractor;
|
||||
pub mod video_resolver;
|
||||
pub mod waveform_generator;
|
||||
|
||||
76
src-tauri/src/services/subtitle_downloader.rs
Normal file
76
src-tauri/src/services/subtitle_downloader.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::models::CookieSource;
|
||||
use std::process::Command;
|
||||
|
||||
/// Download English VTT subtitles for a URL.
|
||||
/// `is_auto` controls whether to use --write-subs (manual) or --write-auto-subs (auto-generated).
|
||||
/// Returns the path to the downloaded .vtt file, or an error.
|
||||
pub fn download_subtitles(
|
||||
url: &str,
|
||||
cookie_source: &CookieSource,
|
||||
output_dir: &str,
|
||||
is_auto: bool,
|
||||
) -> Result<String, String> {
|
||||
std::fs::create_dir_all(output_dir)
|
||||
.map_err(|e| format!("Failed to create subtitle dir: {e}"))?;
|
||||
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", output_dir);
|
||||
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("--skip-download")
|
||||
.arg("--remote-components")
|
||||
.arg("ejs:github");
|
||||
|
||||
if is_auto {
|
||||
cmd.arg("--write-auto-subs");
|
||||
} else {
|
||||
cmd.arg("--write-subs");
|
||||
}
|
||||
|
||||
cmd.arg("--sub-langs").arg("en.*,en");
|
||||
cmd.arg("--sub-format").arg("vtt");
|
||||
cmd.arg("--convert-subs").arg("vtt");
|
||||
cmd.arg("-o").arg(&output_template);
|
||||
|
||||
for arg in cookie_source.to_ytdlp_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
cmd.arg(url);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run yt-dlp for subtitles: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("yt-dlp subtitle download failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Find the downloaded .vtt file in the output directory
|
||||
find_vtt_file(output_dir)
|
||||
}
|
||||
|
||||
fn find_vtt_file(dir: &str) -> Result<String, String> {
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.map_err(|e| format!("Failed to read subtitle dir: {e}"))?;
|
||||
|
||||
// Prefer .en.vtt, then any .vtt
|
||||
let mut vtt_files: Vec<String> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("vtt") {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
vtt_files.push(path_str);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer files with ".en." in the name
|
||||
if let Some(en_file) = vtt_files.iter().find(|f| f.contains(".en.")) {
|
||||
return Ok(en_file.clone());
|
||||
}
|
||||
|
||||
// Fall back to any VTT file
|
||||
vtt_files
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "No .vtt subtitle file found after download".to_string())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::models::ThumbnailSpritesheet;
|
||||
use crate::services::dependency_manager::ffmpeg_bin;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn compute_interval(duration: f64) -> f64 {
|
||||
@@ -27,7 +28,7 @@ pub fn extract_thumbnails(
|
||||
let frames_per_sheet = (columns * rows_per_sheet) as usize;
|
||||
|
||||
let frames_pattern = format!("{output_dir}/frame_%06d.jpg");
|
||||
let status = Command::new("ffmpeg")
|
||||
let status = Command::new(ffmpeg_bin())
|
||||
.args([
|
||||
"-i",
|
||||
file_path,
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
use crate::models::{CookieSource, VideoMetadata};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubtitleFormat {
|
||||
pub ext: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct YtDlpJson {
|
||||
pub title: String,
|
||||
pub duration: Option<f64>,
|
||||
pub fps: Option<f64>,
|
||||
pub thumbnail: Option<String>,
|
||||
/// Manually authored subtitles: language_code -> [formats]
|
||||
#[serde(default)]
|
||||
pub subtitles: HashMap<String, Vec<SubtitleFormat>>,
|
||||
/// Auto-generated subtitles: language_code -> [formats]
|
||||
#[serde(default)]
|
||||
pub automatic_captions: HashMap<String, Vec<SubtitleFormat>>,
|
||||
}
|
||||
|
||||
/// Check if subtitles have a VTT format for English (en, en-US, en-GB, en-orig).
|
||||
fn find_english_key(map: &HashMap<String, Vec<SubtitleFormat>>) -> Option<&str> {
|
||||
// Prefer exact "en", then any en-* variant
|
||||
for key in ["en", "en-US", "en-GB", "en-orig"] {
|
||||
if map.contains_key(key) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
// Try any key starting with "en"
|
||||
for key in map.keys() {
|
||||
if key.starts_with("en") {
|
||||
return Some(key.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Determine caption availability: (has_captions, is_auto)
|
||||
pub fn detect_captions(meta: &YtDlpJson) -> (bool, bool) {
|
||||
// Priority 1: manual English subtitles
|
||||
if find_english_key(&meta.subtitles).is_some() {
|
||||
return (true, false);
|
||||
}
|
||||
// Priority 2: auto-generated English captions
|
||||
if find_english_key(&meta.automatic_captions).is_some() {
|
||||
return (true, true);
|
||||
}
|
||||
(false, false)
|
||||
}
|
||||
|
||||
pub fn parse_ytdlp_json(json_str: &str) -> Result<YtDlpJson, String> {
|
||||
@@ -41,6 +84,8 @@ pub fn resolve(url: &str, cookie_source: &CookieSource) -> Result<VideoMetadata,
|
||||
let json_str = String::from_utf8_lossy(&meta_output.stdout);
|
||||
let meta = parse_ytdlp_json(&json_str)?;
|
||||
|
||||
let (has_captions, captions_are_auto) = detect_captions(&meta);
|
||||
|
||||
let mut stream_cmd = Command::new("yt-dlp");
|
||||
stream_cmd
|
||||
.arg("-g")
|
||||
@@ -79,6 +124,8 @@ pub fn resolve(url: &str, cookie_source: &CookieSource) -> Result<VideoMetadata,
|
||||
fps: meta.fps.unwrap_or(30.0),
|
||||
thumbnail_url: meta.thumbnail,
|
||||
stream_url,
|
||||
has_captions,
|
||||
captions_are_auto,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,6 +154,8 @@ mod tests {
|
||||
assert!(result.duration.is_none());
|
||||
assert!(result.fps.is_none());
|
||||
assert!(result.thumbnail.is_none());
|
||||
assert!(result.subtitles.is_empty());
|
||||
assert!(result.automatic_captions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -114,4 +163,39 @@ mod tests {
|
||||
let result = parse_ytdlp_json("not json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_manual() {
|
||||
let json = r#"{"title": "T", "subtitles": {"en": [{"ext": "vtt", "url": "http://x"}]}, "automatic_captions": {}}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, is_auto) = detect_captions(&meta);
|
||||
assert!(has);
|
||||
assert!(!is_auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_auto_only() {
|
||||
let json = r#"{"title": "T", "subtitles": {}, "automatic_captions": {"en": [{"ext": "vtt", "url": "http://x"}]}}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, is_auto) = detect_captions(&meta);
|
||||
assert!(has);
|
||||
assert!(is_auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_none() {
|
||||
let json = r#"{"title": "T"}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, _) = detect_captions(&meta);
|
||||
assert!(!has);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_en_variant() {
|
||||
let json = r#"{"title": "T", "subtitles": {"en-US": [{"ext": "vtt"}]}}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, is_auto) = detect_captions(&meta);
|
||||
assert!(has);
|
||||
assert!(!is_auto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,110 @@
|
||||
use std::process::Command;
|
||||
use crate::models::WaveformTiers;
|
||||
use crate::services::dependency_manager::ffmpeg_bin;
|
||||
use std::io::{BufRead, BufReader, Read as _};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
pub fn extract_waveform(file_path: &str, sample_count: usize) -> Result<Vec<f64>, String> {
|
||||
let output = Command::new("ffmpeg")
|
||||
const TIER0_SIZE: usize = 2_000;
|
||||
const TIER1_SIZE: usize = 10_000;
|
||||
|
||||
/// Extract waveform peaks and build all zoom tiers in a single ffmpeg call.
|
||||
/// The `on_progress` callback receives values from 0.0 to 1.0.
|
||||
pub fn extract_waveform_tiers(
|
||||
file_path: &str,
|
||||
duration: f64,
|
||||
on_progress: impl Fn(f64) + Send + 'static,
|
||||
) -> Result<WaveformTiers, String> {
|
||||
// ~100 peaks/sec, capped at 200K, floored at 50K
|
||||
let raw_count = (duration * 100.0).round() as usize;
|
||||
let raw_count = raw_count.clamp(50_000, 200_000);
|
||||
|
||||
// Compute the actual output sample rate in Hz.
|
||||
// aresample=N sets rate to N Hz (NOT total count), so we must divide by duration.
|
||||
let sample_rate = ((raw_count as f64) / duration).ceil() as usize;
|
||||
let sample_rate = sample_rate.max(8);
|
||||
|
||||
eprintln!(
|
||||
"[waveform_generator] extracting ~{} peaks ({}Hz x {:.0}s) for '{}'",
|
||||
raw_count, sample_rate, duration, file_path
|
||||
);
|
||||
|
||||
let mut child = Command::new(ffmpeg_bin())
|
||||
.args([
|
||||
"-i",
|
||||
file_path,
|
||||
"-ac",
|
||||
"1",
|
||||
"-filter:a",
|
||||
&format!("aresample={sample_count}"),
|
||||
"-f",
|
||||
"f32le",
|
||||
"-i", file_path,
|
||||
"-ac", "1",
|
||||
"-ar", &sample_rate.to_string(),
|
||||
"-f", "f32le",
|
||||
"-vn",
|
||||
"-progress", "pipe:2",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to run ffmpeg: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("ffmpeg waveform extraction failed: {stderr}"));
|
||||
let stderr = child.stderr.take().ok_or("No stderr")?;
|
||||
let mut stdout = child.stdout.take().ok_or("No stdout")?;
|
||||
|
||||
// Parse progress from stderr in a background thread
|
||||
let duration_us = duration * 1_000_000.0;
|
||||
let progress_thread = std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if let Some(us_str) = line.strip_prefix("out_time_us=") {
|
||||
if let Ok(us) = us_str.parse::<f64>() {
|
||||
if duration_us > 0.0 {
|
||||
on_progress((us / duration_us).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Read all waveform data from stdout
|
||||
let mut waveform_data = Vec::new();
|
||||
stdout
|
||||
.read_to_end(&mut waveform_data)
|
||||
.map_err(|e| format!("Failed to read ffmpeg stdout: {e}"))?;
|
||||
|
||||
progress_thread.join().ok();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("ffmpeg wait failed: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("ffmpeg waveform extraction failed".to_string());
|
||||
}
|
||||
|
||||
let samples = parse_f32_samples(&output.stdout);
|
||||
let peaks = compute_peaks(&samples, sample_count);
|
||||
Ok(peaks)
|
||||
let samples = parse_f32_samples(&waveform_data);
|
||||
let tier2 = compute_peaks(&samples, raw_count);
|
||||
let tier1 = downsample(&tier2, TIER1_SIZE);
|
||||
let tier0 = downsample(&tier2, TIER0_SIZE);
|
||||
|
||||
eprintln!(
|
||||
"[waveform_generator] tiers: {} / {} / {} peaks",
|
||||
tier0.len(),
|
||||
tier1.len(),
|
||||
tier2.len()
|
||||
);
|
||||
|
||||
Ok(WaveformTiers { tier0, tier1, tier2 })
|
||||
}
|
||||
|
||||
/// Downsample a peaks array to a smaller size by taking the max of each chunk.
|
||||
fn downsample(peaks: &[f64], target_count: usize) -> Vec<f64> {
|
||||
if peaks.is_empty() || target_count == 0 {
|
||||
return vec![0.0; target_count];
|
||||
}
|
||||
if target_count >= peaks.len() {
|
||||
return peaks.to_vec();
|
||||
}
|
||||
|
||||
let chunk_size = (peaks.len() as f64 / target_count as f64).ceil() as usize;
|
||||
let chunk_size = chunk_size.max(1);
|
||||
|
||||
peaks
|
||||
.chunks(chunk_size)
|
||||
.map(|chunk| chunk.iter().cloned().fold(0.0_f64, f64::max))
|
||||
.take(target_count)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_f32_samples(data: &[u8]) -> Vec<f32> {
|
||||
@@ -53,7 +133,8 @@ fn compute_peaks(samples: &[f32], target_count: usize) -> Vec<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
peaks.resize(target_count, 0.0);
|
||||
// Do NOT resize/pad — the actual peak count from chunking is correct.
|
||||
// Padding with zeros causes the waveform to appear truncated.
|
||||
peaks
|
||||
}
|
||||
|
||||
@@ -87,4 +168,28 @@ mod tests {
|
||||
assert_eq!(samples.len(), 1);
|
||||
assert!((samples[0] - 0.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample() {
|
||||
let peaks = vec![0.1, 0.5, 0.3, 0.8, 0.2, 0.9];
|
||||
let down = downsample(&peaks, 3);
|
||||
assert_eq!(down.len(), 3);
|
||||
assert!((down[0] - 0.5).abs() < 0.001);
|
||||
assert!((down[1] - 0.8).abs() < 0.001);
|
||||
assert!((down[2] - 0.9).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_identity() {
|
||||
let peaks = vec![0.1, 0.5, 0.3];
|
||||
let down = downsample(&peaks, 5);
|
||||
assert_eq!(down, peaks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_empty() {
|
||||
let down = downsample(&[], 3);
|
||||
assert_eq!(down.len(), 3);
|
||||
assert!(down.iter().all(|p| *p == 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,14 @@
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$TEMP/**", "$TMP/**", "/tmp/**"]
|
||||
"scope": [
|
||||
"$TEMP/**",
|
||||
"$TMP/**",
|
||||
"/tmp/**",
|
||||
"/private/tmp/**",
|
||||
"/private/var/**",
|
||||
"/var/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user