diff --git a/CLAUDE.md b/CLAUDE.md index d9066a7..38513ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,30 @@ so the feature is **only** activated through these forwards — which is what ma software fallback shared with the preview path; the GUI defaults export `hwaccel` to `auto` too, and `render_with_progress` retries a failed hardware-decode export once in software so the default can never lose a render. + **How much of the machine any of this may take** is `engine/cpu.rs`. FFmpeg is + written to finish as fast as it can — every run grabs every core and nothing + coordinates one run with the next — so an agent analyzing eight sources over + MCP used to spawn eight all-cores, whole-file decodes at once (each buffering + its PCM, so gigabytes too) and leave the desktop unusable for no wall-clock + gain. Two moving parts: **one heavy job at a time** (`cpu::lease`, a reentrant + gate — an export's second pass and a stitch inside an import must not queue + behind themselves) and **a share of the cores for that job** (`cpu_percent`, + seeded from `KERF_CPU_PERCENT`, set at runtime by the app's settings). Gated = + anything that reads a *whole file*: silence / scene / loudness detection, the + PCM decode behind rhythm and in-process whisper, transcription, proxy, stitch, + export. **Ungated** = anything that reads a *moment*: a scrubbed frame, the + composited still, a clip's audio, the preview stream, a waveform, a contact + sheet — the UI (and an agent *looking* at footage) must not wait out a render. + The share becomes `-threads` / `-filter_threads` / `-filter_complex_threads`, + written in at **spawn** time (`cpu::limit_args` / `limit_cmd`) rather than in + the pure argument builders, so those keep describing exactly what ffmpeg is + handed; `-threads` goes in twice because ffmpeg assigns it to whichever *file + group* it sits in — at the front for the decoder, immediately before the last + argument (the output sink) for the encoder. Plus below-normal scheduling + priority (`cpu::background`, a creation flag on Windows / `nice` on unix), + which is the half that actually keeps the desktop responsive. At **100%** none + of the second half applies: no flags, no priority change, byte-identical + invocations to the ones Kerf always issued. Export is a **positional, multi-track** `filter_complex` (`build_export_args` / `build_filter_complex`, both pure + unit-tested): a black canvas with every video clip `overlay`'d at its `timeline_start` (later tracks on @@ -142,7 +166,9 @@ so the feature is **only** activated through these forwards — which is what ma `fov` maps to `d_fov` (aspect-correct on its own; `h_fov` would stretch). The resulting graph outgrows argv — Linux caps one argument at 128 KiB, Windows the whole command line at 32767 — so `externalize_filter_complex` spills anything - over `GRAPH_ARG_MAX` to a temp file passed via `-filter_complex_script`. The + over `GRAPH_ARG_MAX` to a temp file, passed via `-filter_complex_script` or, + where FFmpeg 8 removed that option, the `-/filter_complex ` form that + replaced it (`graph_script_flag` probes `-h full` once per process). The still path samples `Clip::reframe_at` to a constant `v360` instead, and `export_format` ignores a reframed clip's source dimensions so a 5760x2880 capture does not become the deliverable size. @@ -593,7 +619,13 @@ from one round-trip; `get_staged_timeline` for previewing it; `apply_staged_edit `discard_staged_edit`) and `revision_diff`, `export_timeline` (emits `export-progress` events) / `cancel_export`, `cancel_analysis` (the same shape, for the analysis pass — importing ten clips must not be an unbreakable -commitment to ten transcriptions) and `agent_status` (the MCP endpoint plus how +commitment to ten transcriptions), app preferences (`get_settings` / +`set_settings` → a `SettingsView`: the *effective* CPU budget read back out of +the engine, the cores it works out to, and the machine it is a share of — +`settings.rs` persists them as JSON in the platform config dir, since how much +of *this* computer Kerf may use is not something that should travel inside a +`.kerf` file; `KERF_CPU_PERCENT` wins at launch, a moved slider wins after) +and `agent_status` (the MCP endpoint plus how many seconds ago an agent last spoke to it, or `null` if none ever has — `mcp::LAST_AGENT_ACTIVITY`, stamped in `lock_agent` and in `get_info`, since `initialize` is the one moment an agent is known to be there; a @@ -872,6 +904,17 @@ notice was about. It is also why the failure paths that used to reject into noth (`fetchSpeechModel`, `analyzeQueue`'s per-asset catch, the media bin's `runAnalysis` calls) now report: a notice that is never raised cannot be recovered from a log. +**Settings** are their own runes singleton (`src/lib/settings.svelte.ts`) behind +the title bar's gear (⌘,): `SettingsDialog.svelte` is a section rail plus a +panel, so the next preference is a row in a list rather than new chrome. Its one +section is **Performance** — the CPU limit as three named budgets (Background / +Balanced / Full speed) over a slider, reading back "9 of 12 cores for Kerf · 3 +left for everything else", because the complaint this answers arrives in those +terms and not in percentages. The percentage is clamped by the engine, so the +view that comes *back* from `set_settings` is what renders, not the value asked +for; in the browser harness `api.ts` answers from localStorage over +`navigator.hardwareConcurrency` so the dialog is drivable under `bun run dev`. + The **update flow** is its own runes singleton (`src/lib/updater.svelte.ts`, alongside `editor`/`ui`/`agent`): it runs a *silent* check at startup and every 6 h through `api.ts`'s `checkUpdate` / `installUpdate` / `relaunchApp`, and drives diff --git a/Cargo.lock b/Cargo.lock index 915cfa8..6fec402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2224,6 +2224,7 @@ dependencies = [ "dirs", "ffmpeg-next", "fontdb", + "libc", "rusqlite", "schemars 1.2.2", "serde", diff --git a/crates/kerf-app/src/lib.rs b/crates/kerf-app/src/lib.rs index 2af1b70..66269d8 100644 --- a/crates/kerf-app/src/lib.rs +++ b/crates/kerf-app/src/lib.rs @@ -14,6 +14,7 @@ //! and releasing it before the slow part. mod mcp; +mod settings; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -259,11 +260,10 @@ pub(crate) fn spawn_proxy(app: &AppHandle, asset: &Asset) { /// How many proxy encodes may run at once. Importing many large sources (or /// reopening a project full of them) would otherwise spawn one full-file -/// re-encode per file *at once* — and each ffmpeg grabs every core — so the CPU -/// saturates and both the GUI and the agent freeze. The default of 1 keeps at -/// most one encode running; raise it with `KERF_PROXY_WORKERS` on a machine with -/// cores to spare (pair with `KERF_PROXY_THREADS` so workers × threads stays -/// under your core count, or you're back to oversubscribing). +/// re-encode per file *at once*. The engine's CPU budget now gates every heavy +/// job anyway (`kerf_core::engine::cpu`), so raising `KERF_PROXY_WORKERS` above +/// the default of 1 buys queued encodes rather than concurrent ones — the knob +/// that decides how much of the machine they get is the CPU limit in Settings. fn proxy_workers() -> usize { std::env::var("KERF_PROXY_WORKERS") .ok() @@ -1505,6 +1505,29 @@ fn agent_status() -> AgentStatus { } } +// ---- app settings ---------------------------------------------------------- + +/// The current preferences, resolved against the engine (see +/// [`settings::SettingsView`]). +#[tauri::command(async)] +fn get_settings() -> settings::SettingsView { + settings::SettingsView::current() +} + +/// Write the preferences and put them into force. Returns the resolved view, so +/// the dialog can show the clamped percentage and the cores it works out to +/// without a second round-trip. +#[tauri::command(async)] +fn set_settings(app: AppHandle, settings: settings::Settings) -> CmdResult { + // Clamp through the engine first, then persist what was actually applied — + // storing an out-of-range value would keep re-clamping on every launch. + let stored = settings::Settings { + cpu_percent: kerf_core::set_cpu_percent(settings.cpu_percent), + }; + settings::save(&app, &stored)?; + Ok(settings::SettingsView::current()) +} + // ---- diagnostics (logs) ---------------------------------------------------- #[tauri::command(async)] @@ -1627,6 +1650,8 @@ pub fn run() { init_logging(app.handle()); install_panic_hook(); use_bundled_ffmpeg(); + // Before anything can spawn ffmpeg: how much of the machine it may take. + settings::apply(&settings::load(app.handle())); tracing::info!( version = env!("CARGO_PKG_VERSION"), os = std::env::consts::OS, @@ -1742,6 +1767,8 @@ pub fn run() { reveal_path, mcp_endpoint, agent_status, + get_settings, + set_settings, log_dir, reveal_logs ]) diff --git a/crates/kerf-app/src/settings.rs b/crates/kerf-app/src/settings.rs new file mode 100644 index 0000000..283394b --- /dev/null +++ b/crates/kerf-app/src/settings.rs @@ -0,0 +1,108 @@ +//! App preferences — the settings that belong to this machine rather than to a +//! project. +//! +//! A `.kerf` file describes a cut; how much of *your* computer Kerf may take +//! while it renders one is not part of that, and must not travel with the +//! project to another machine. So these live in the platform config directory as +//! plain JSON, are read once at launch and written on every change. +//! +//! Everything here is best-effort: an unreadable or malformed file falls back to +//! the defaults rather than refusing to start, because a preference is never +//! worth failing a launch over. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +/// Set deliberately in the environment, this wins over the stored preference at +/// launch (see [`apply`]). +const CPU_ENV: &str = "KERF_CPU_PERCENT"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + /// Share of the machine one heavy job (analysis, transcription, proxy, + /// stitch, export) may take, in percent. See `kerf_core::engine::cpu`. + pub cpu_percent: u8, +} + +impl Default for Settings { + fn default() -> Self { + Self { + cpu_percent: kerf_core::DEFAULT_CPU_PERCENT, + } + } +} + +/// What the settings surface actually shows: the stored preference resolved +/// against the engine, plus the machine it is a share *of*. The UI cannot work +/// out "9 of 12 cores" on its own — a webview's `hardwareConcurrency` is not +/// what ffmpeg sees. +#[derive(Debug, Clone, Serialize)] +pub struct SettingsView { + pub cpu_percent: u8, + pub cpu_cores: usize, + pub cpu_threads: usize, + pub cpu_min_percent: u8, +} + +impl SettingsView { + /// Read straight from the engine rather than from the stored file, so what + /// the dialog shows is what is actually in force — including an environment + /// override the user set outside the app. + pub fn current() -> Self { + Self { + cpu_percent: kerf_core::cpu_percent(), + cpu_cores: kerf_core::cpu_cores(), + cpu_threads: kerf_core::cpu_threads(), + cpu_min_percent: kerf_core::MIN_CPU_PERCENT, + } + } +} + +fn path(app: &AppHandle) -> Option { + app.path().app_config_dir().ok().map(|dir| dir.join("settings.json")) +} + +/// Read the stored preferences, falling back to the defaults for anything +/// missing, unreadable or malformed. +pub fn load(app: &AppHandle) -> Settings { + let Some(file) = path(app) else { + return Settings::default(); + }; + match std::fs::read_to_string(&file) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!(error = %e, path = %file.display(), "unreadable settings; using defaults"); + Settings::default() + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Settings::default(), + Err(e) => { + tracing::warn!(error = %e, path = %file.display(), "could not read settings; using defaults"); + Settings::default() + } + } +} + +pub fn save(app: &AppHandle, settings: &Settings) -> Result<(), String> { + let file = path(app).ok_or("no config directory available for settings")?; + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("could not create the settings directory: {e}"))?; + } + let raw = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; + std::fs::write(&file, raw).map_err(|e| format!("could not write settings: {e}")) +} + +/// Push the preferences into the engine. +/// +/// `KERF_CPU_PERCENT` deliberately wins at launch: someone who set it in the +/// environment meant it for this run. Moving the slider afterwards still takes +/// effect — a runtime choice is the newer instruction of the two. +pub fn apply(settings: &Settings) { + if std::env::var_os(CPU_ENV).is_some() { + tracing::info!(percent = kerf_core::cpu_percent(), "CPU budget set from {CPU_ENV}"); + return; + } + let applied = kerf_core::set_cpu_percent(settings.cpu_percent); + tracing::info!(percent = applied, cores = kerf_core::cpu_cores(), "CPU budget applied"); +} diff --git a/crates/kerf-core/Cargo.toml b/crates/kerf-core/Cargo.toml index fd2afad..1fb816d 100644 --- a/crates/kerf-core/Cargo.toml +++ b/crates/kerf-core/Cargo.toml @@ -41,3 +41,8 @@ fontdb.workspace = true ureq = "3.4" ffmpeg-next = { workspace = true, optional = true } whisper-rs = { version = "0.16.0", optional = true } + +# Scheduling priority for background ffmpeg runs (see engine::cpu). Unix only — +# Windows sets its priority class through a process creation flag instead. +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/kerf-core/src/analysis.rs b/crates/kerf-core/src/analysis.rs index 292f886..46640cb 100644 --- a/crates/kerf-core/src/analysis.rs +++ b/crates/kerf-core/src/analysis.rs @@ -235,12 +235,18 @@ impl Transcriber for WhisperTranscriber { } let samples = crate::engine::decode_audio_16k_mono(std::path::Path::new(&asset.path))?; let language = self.language.clone(); + // In-process inference is as CPU-hungry as the ffmpeg filter backend, so + // it takes the same heavy-job slot and the same share of the cores. The + // lease is held here while the worker below does the work, which is what + // the join makes safe. + let cpu = crate::engine::cpu::lease(); + let threads = cpu.threads(); // whisper-rs wants a `'static` progress callback, and `full()` blocks // for the whole inference — so run it on a worker and pump percentages // back over a channel instead of holding a borrow across the call. let (tx, rx) = std::sync::mpsc::channel::(); - let worker = std::thread::spawn(move || run_whisper_rs(samples, model, language, tx)); + let worker = std::thread::spawn(move || run_whisper_rs(samples, model, language, threads, tx)); for pct in rx { progress(AnalysisProgress::with_fraction("transcribe", pct as f64 / 100.0)); } @@ -259,6 +265,7 @@ fn run_whisper_rs( samples: Vec, model: std::path::PathBuf, language: Option, + threads: usize, progress: std::sync::mpsc::Sender, ) -> Result> { use crate::error::Error; @@ -269,6 +276,9 @@ fn run_whisper_rs( let mut state = ctx.create_state().map_err(|e| Error::Engine(format!("whisper: {e}")))?; let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); + // whisper.cpp otherwise sizes its thread pool from the machine, not from + // what Kerf was told it may use. + params.set_n_threads(threads.clamp(1, i32::MAX as usize) as i32); if let Some(lang) = &language { params.set_language(Some(lang)); } diff --git a/crates/kerf-core/src/engine/audio.rs b/crates/kerf-core/src/engine/audio.rs index 900f936..6ddd99b 100644 --- a/crates/kerf-core/src/engine/audio.rs +++ b/crates/kerf-core/src/engine/audio.rs @@ -7,7 +7,8 @@ use std::path::Path; use std::process::Stdio; -use super::cli::{command, decode_audio_mono_f32, ffmpeg_bin, launch_err}; +use super::cli::{bg_command, decode_audio_mono_f32, ffmpeg_bin, launch_err}; +use super::cpu; use crate::error::{Error, Result}; use crate::model::{AudioClass, AudioClassification, Loudness, Rhythm, Tempo}; @@ -18,7 +19,11 @@ use crate::model::{AudioClass, AudioClassification, Loudness, Rhythm, Tempo}; /// with the measured `input_i` / `input_lra` / `input_tp` / `input_thresh`. pub fn measure_loudness(path: &Path) -> Result { let bin = ffmpeg_bin(); - let output = command(&bin) + // Another whole-file decode; it takes the heavy-job slot like the rest. + let cpu = cpu::lease(); + let mut cmd = bg_command(&bin); + cpu::limit_cmd(&mut cmd, cpu.threads()); + let output = cmd .args(["-hide_banner", "-nostats"]) .arg("-i") .arg(path) diff --git a/crates/kerf-core/src/engine/cli.rs b/crates/kerf-core/src/engine/cli.rs index e035516..ffa1b34 100644 --- a/crates/kerf-core/src/engine/cli.rs +++ b/crates/kerf-core/src/engine/cli.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; +use super::cpu; use super::ProbeResult; use crate::error::{Error, Result}; use crate::model::{ @@ -221,6 +222,17 @@ pub(super) fn command(bin: &str) -> Command { cmd } +/// A `Command` for a **background** ffmpeg run: everything [`command`] does, +/// plus below-normal scheduling priority so a render or an analysis pass never +/// out-prioritizes the window the user is actually looking at. Interactive +/// decodes (a scrubbed frame, the preview stream) keep normal priority — they +/// are short, and the whole point of them is to land now. +pub(super) fn bg_command(bin: &str) -> Command { + let mut cmd = command(bin); + cpu::background(&mut cmd); + cmd +} + // ---- probe ----------------------------------------------------------------- #[derive(serde::Deserialize)] @@ -456,7 +468,11 @@ fn parse_rational(s: &str) -> Option { pub fn detect_silence(path: &Path, noise_db: f64, min_silence: f64) -> Result> { let bin = ffmpeg_bin(); let filter = format!("silencedetect=noise={noise_db}dB:d={min_silence}"); - let output = command(&bin) + // A whole-file decode: takes the heavy-job slot and the budget's threads. + let cpu = cpu::lease(); + let mut cmd = bg_command(&bin); + cpu::limit_cmd(&mut cmd, cpu.threads()); + let output = cmd .args(["-hide_banner", "-nostats"]) .arg("-i") .arg(path) @@ -501,8 +517,11 @@ pub fn detect_scenes(path: &Path, threshold: f64) -> Result> { let bin = ffmpeg_bin(); let filter = format!("scale='min({SCENE_DETECT_WIDTH},iw)':-2:flags=bilinear,select='gt(scene,{threshold})',showinfo"); + // The whole file is decoded, so this queues behind any other heavy job. + let cpu = cpu::lease(); let run = |hw: Option<&str>| { - let mut cmd = command(&bin); + let mut cmd = bg_command(&bin); + cpu::limit_cmd(&mut cmd, cpu.threads()); cmd.args(["-hide_banner", "-nostats"]); if let Some(hw) = hw { cmd.args(["-hwaccel", hw]); @@ -586,9 +605,11 @@ pub fn salience_map(path: &Path, start: f64, end: f64) -> Result { use std::sync::atomic::Ordering; let bin = ffmpeg_bin(); - let args = build_salience_args(path, start, end); + let cpu = cpu::lease(); + let mut args = build_salience_args(path, start, end); + cpu::limit_args(&mut args, cpu.threads()); let run = |hw: Option<&str>| { - let mut cmd = command(&bin); + let mut cmd = bg_command(&bin); if let Some(hw) = hw { cmd.args(["-hwaccel", hw]); } @@ -779,6 +800,9 @@ fn run_frame_decode( hw: Option<&str>, ) -> Result> { let mut cmd = command(bin); + // Interactive: never gated and never de-prioritized — a scrubbed frame is + // wanted now — but still held to the budget's threads. + cpu::limit_cmd(&mut cmd, cpu::budget_threads()); cmd.args(["-hide_banner", "-loglevel", "error"]); if let Some(hw) = hw { cmd.args(["-hwaccel", hw]); @@ -823,9 +847,13 @@ pub fn contact_sheet( let path = path .to_str() .ok_or_else(|| Error::Engine("asset path is not valid UTF-8".to_string()))?; - let (args, times) = build_contact_sheet_args(path, start, end, columns, rows, cell_width, quality); + let (mut args, times) = build_contact_sheet_args(path, start, end, columns, rows, cell_width, quality); let bin = ffmpeg_bin(); - let output = command(&bin) + // Deliberately ungated: this is how an agent *looks* at the footage, and + // making it wait out a ten-minute render would read as a hung server. It + // still runs thread-capped and at background priority. + cpu::limit_args(&mut args, cpu::budget_threads()); + let output = bg_command(&bin) .args(&args) .stderr(Stdio::piped()) .output() @@ -899,7 +927,11 @@ pub fn waveform(path: &Path, buckets: usize, sample_rate: u32) -> Result Result> { pub(super) fn decode_audio_mono_f32(path: &Path, sample_rate: u32) -> Result> { let bin = ffmpeg_bin(); - let output = command(&bin) + // Decodes the whole stream *into memory* — eight of these at once is the + // several gigabytes an unsupervised agent used to cost — so it is gated. + let cpu = cpu::lease(); + let mut cmd = bg_command(&bin); + cpu::limit_cmd(&mut cmd, cpu.threads()); + let output = cmd .args(["-hide_banner", "-loglevel", "error"]) .arg("-i") .arg(path) @@ -1076,6 +1113,8 @@ pub(super) fn decode_audio_mono_f32(path: &Path, sample_rate: u32) -> Result) -> Result> { let bin = ffmpeg_bin(); let mut cmd = command(&bin); + // Interactive, like the frame decode: the preview's playback is waiting on it. + cpu::limit_cmd(&mut cmd, cpu::budget_threads()); cmd.args(["-hide_banner", "-loglevel", "error"]) .args(["-ss", &start.max(0.0).to_string()]) .arg("-i") @@ -1180,17 +1219,16 @@ pub fn ready_proxy(src: &Path, width: u32) -> Option { proxy_path(src, width).filter(|p| p.is_file()) } -/// How many CPU threads a single preview-proxy encode may use. Capped to leave -/// at least one core free so the GUI and a working agent stay responsive while a -/// proxy transcodes in the background (an uncapped `libx264` grabs every core). -/// Override with `KERF_PROXY_THREADS` (clamped to >= 1). -fn proxy_threads() -> usize { +/// How many CPU threads a single preview-proxy encode may use. Follows the +/// engine's CPU budget (see [`cpu`]), except that it never takes the whole +/// machine even at 100% — an uncapped `libx264` grabs every core, and a proxy +/// is background work the user did not ask to wait for. `KERF_PROXY_THREADS` +/// still overrides it outright (clamped to >= 1). +fn proxy_threads(budget: usize) -> usize { if let Some(n) = std::env::var("KERF_PROXY_THREADS").ok().and_then(|v| v.parse::().ok()) { return n.max(1); } - std::thread::available_parallelism() - .map(|n| n.get().saturating_sub(1).max(1)) - .unwrap_or(1) + budget.min(cpu::cores().saturating_sub(1).max(1)).max(1) } /// Constant-quality flags for encoder `vc` at software-CRF-scale `crf` — the @@ -1308,9 +1346,14 @@ pub fn generate_proxy(src: &Path, width: u32) -> Result { .to_str() .ok_or_else(|| Error::Engine("proxy temp path is not valid UTF-8".to_string()))?; let bin = ffmpeg_bin(); + // A full-file re-encode. Importing a folder queues them one behind the next + // rather than starting one per file at once. + let cpu = cpu::lease(); + let threads = proxy_threads(cpu.threads()); let run = |encoder: &str, hw_decode: Option<&str>| -> Result { - let args = build_proxy_args(src_str, tmp_str, proxy_threads(), width, encoder, hw_decode); - command(&bin) + let mut args = build_proxy_args(src_str, tmp_str, threads, width, encoder, hw_decode); + cpu::limit_args(&mut args, threads); + bg_command(&bin) .args(&args) .stderr(Stdio::piped()) .output() @@ -2798,6 +2841,9 @@ pub fn stream_preview( let mut args = build_preview_args(timeline, assets, start, fps, PREVIEW_STREAM_WIDTH, PREVIEW_STREAM_QUALITY)?; // The composited graph outgrows argv just as the export's does. let _script = externalize_filter_complex(&mut args, "preview")?; + // Playback is paced to the wall clock, so it never races ahead; the cap is + // there so the budget means the same thing while the cut is playing. + cpu::limit_args(&mut args, cpu::budget_threads()); let bin = ffmpeg_bin(); tracing::debug!(start, fps, "starting preview stream"); @@ -3075,22 +3121,54 @@ impl Drop for GraphScript { } } +/// The argv spelling that points *this* ffmpeg at a filtergraph file, probed +/// once per process: FFmpeg 8 removed `-filter_complex_script` (deprecated in +/// 7.0 as equivalent to the generic `-/filter_complex ` form), so on a +/// bundled FFmpeg 8 the old spelling aborts every spilled render with +/// `Unrecognized option` — while a pre-7.0 binary knows only the old one. +/// `-h full` still lists the option wherever it exists; a probe that cannot +/// run at all answers with the modern form, since any render on that binary is +/// about to fail the same way regardless. +fn graph_script_flag() -> &'static str { + static FLAG: OnceLock<&'static str> = OnceLock::new(); + FLAG.get_or_init(|| { + let legacy = command(&ffmpeg_bin()) + .args(["-hide_banner", "-loglevel", "quiet", "-h", "full"]) + .stdin(Stdio::null()) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains("-filter_complex_script")) + .unwrap_or(false); + tracing::debug!(legacy, "probed ffmpeg for -filter_complex_script"); + if legacy { + "-filter_complex_script" + } else { + "-/filter_complex" + } + }) +} + /// Move an oversized filtergraph out of argv into a script file, pointing ffmpeg -/// at it with `-filter_complex_script`. Leaves ordinary exports untouched, so -/// their argv stays byte-identical (and every pure arg-builder test with it). -/// -/// `-filter_complex_script` takes the path as its own argv token, which is why -/// it beats the obvious alternative of `sendcmd=f=`: that would bury the path -/// *inside* a filtergraph value, where `\` escapes and `:` separates options, so -/// a Windows path would have to be mangled first. +/// at it with [`graph_script_flag`]'s spelling. Leaves ordinary exports +/// untouched, so their argv stays byte-identical (and every pure arg-builder +/// test with it). fn externalize_filter_complex(args: &mut [String], tag: &str) -> Result { + spill_graph(args, tag, graph_script_flag()) +} + +/// The pure half of [`externalize_filter_complex`], with the flag decided. +/// +/// Either flag takes the path as its own argv token, which is why this beats +/// the obvious alternative of `sendcmd=f=`: that would bury the path *inside* a +/// filtergraph value, where `\` escapes and `:` separates options, so a Windows +/// path would have to be mangled first. +fn spill_graph(args: &mut [String], tag: &str, flag: &str) -> Result { let Some(i) = oversized_graph_index(args) else { return Ok(GraphScript(None)); }; let path = std::env::temp_dir().join(format!("kerf-graph-{}-{tag}.txt", std::process::id())); std::fs::write(&path, &args[i]).map_err(|e| Error::Engine(format!("could not write the filtergraph script: {e}")))?; args[i] = path.to_string_lossy().into_owned(); - args[i - 1] = "-filter_complex_script".to_string(); + args[i - 1] = flag.to_string(); Ok(GraphScript(Some(path))) } @@ -3122,12 +3200,18 @@ fn run_ffmpeg_progress( use std::process::Stdio; let bin = ffmpeg_bin(); + // The heaviest thing the engine does. The lease is reentrant, so an export's + // second pass and a stitch inside an import do not queue behind themselves. + let cpu = cpu::lease(); + let mut args = args.to_vec(); + cpu::limit_args(&mut args, cpu.threads()); + let args = &args[..]; tracing::info!(output = %output.display(), "exporting timeline"); tracing::debug!(command = %format!("{bin} {}", args.join(" ")), "ffmpeg export command"); // `-progress pipe:1` writes machine-readable key=value blocks to stdout; // `-stats_period` bounds how often, and thus the cancel-poll latency. - let mut child = command(&bin) + let mut child = bg_command(&bin) .arg("-progress") .arg("pipe:1") .arg("-stats_period") @@ -4383,7 +4467,8 @@ fn run_still( ) -> Result> { let piping = matches!(out, StillOutput::JpegPipe { .. }); let run = |o: &ExportOptions| -> Result> { - let args = build_still_args(timeline, assets, o, t, max_width, out)?; + let mut args = build_still_args(timeline, assets, o, t, max_width, out)?; + cpu::limit_args(&mut args, cpu::budget_threads()); let bin = ffmpeg_bin(); let output = command(&bin) .args(&args) @@ -6415,9 +6500,12 @@ mod tests { let i = oversized_graph_index(&args).expect("a long pan must spill out of argv"); assert!(args[i].len() > GRAPH_ARG_MAX); + // spill_graph rather than externalize_filter_complex: the wrapper + // probes the real binary for which flag spelling it takes, and the + // default test run must stay binary-free. let mut spilled = args.clone(); - let guard = externalize_filter_complex(&mut spilled, "test").unwrap(); - assert_eq!(spilled[i - 1], "-filter_complex_script"); + let guard = spill_graph(&mut spilled, "test", "-/filter_complex").unwrap(); + assert_eq!(spilled[i - 1], "-/filter_complex"); let path = std::path::PathBuf::from(&spilled[i]); assert_eq!( std::fs::read_to_string(&path).unwrap(), diff --git a/crates/kerf-core/src/engine/cpu.rs b/crates/kerf-core/src/engine/cpu.rs new file mode 100644 index 0000000..eb7e446 --- /dev/null +++ b/crates/kerf-core/src/engine/cpu.rs @@ -0,0 +1,353 @@ +//! How much of the machine the media engine is allowed to take. +//! +//! FFmpeg is written to finish as fast as possible: every run grabs every core, +//! and nothing coordinates one run with the next. That is right for a single +//! export and wrong for everything else — an agent that analyzes eight sources +//! over MCP spawns eight full-file decodes *at once*, each with as many threads +//! as there are cores, and the desktop stops responding while they fight each +//! other. The wall-clock is barely better than running them one at a time; only +//! the machine is worse. +//! +//! So the engine keeps a budget, and it has exactly two moving parts: +//! +//! * **One heavy job at a time.** Every pass that reads a whole file (analysis, +//! transcription, proxy, stitch, export) takes [`lease`] first and waits its +//! turn. Interactive work — a scrubbed frame, a preview stream, a clip's audio +//! — never queues, so the UI stays live behind a running render. +//! * **A share of the cores for that job**, from [`cpu_percent`]: the thread caps +//! [`limit_args`] writes into the ffmpeg command line, plus below-normal +//! scheduling priority ([`background`]) so the rest of the desktop always +//! preempts it. +//! +//! At **100%** the second half is off entirely: no thread flags are added and +//! priority is untouched, so a full-speed render produces byte-identical ffmpeg +//! invocations to the ones Kerf has always issued. The percentage is seeded from +//! `KERF_CPU_PERCENT` and set at runtime by the app's settings. + +use std::cell::Cell; +use std::process::Command; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Condvar, Mutex, OnceLock}; + +/// The narrowest slice of the machine that can be asked for. Below this a big +/// export stops being worth starting. +pub const MIN_CPU_PERCENT: u8 = 10; + +/// What a fresh install allows. Not 100%: leaving roughly a quarter of the +/// machine alone costs a render very little and is the difference between +/// "Kerf is busy" and "the computer is unusable". +pub const DEFAULT_CPU_PERCENT: u8 = 75; + +/// Logical cores this machine reports. +pub fn cores() -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) +} + +/// 0 means "not resolved yet" — the first read seeds it from the environment. +static PERCENT: AtomicU8 = AtomicU8::new(0); + +pub fn clamp_percent(percent: u8) -> u8 { + percent.clamp(MIN_CPU_PERCENT, 100) +} + +/// The share of the machine one heavy job may use, in percent. +pub fn cpu_percent() -> u8 { + match PERCENT.load(Ordering::Relaxed) { + 0 => { + let seed = std::env::var("KERF_CPU_PERCENT") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(clamp_percent) + .unwrap_or(DEFAULT_CPU_PERCENT); + PERCENT.store(seed, Ordering::Relaxed); + seed + } + percent => percent, + } +} + +/// Set the share of the machine heavy jobs may use, returning the clamped +/// value. Takes effect on the next job to start; a render already running keeps +/// the threads it was launched with (ffmpeg has no way to be told otherwise). +pub fn set_cpu_percent(percent: u8) -> u8 { + let percent = clamp_percent(percent); + PERCENT.store(percent, Ordering::Relaxed); + // Wake anything queued so a raised budget is picked up promptly. + let gate = gate(); + let _held = gate.busy.lock(); + gate.free.notify_all(); + percent +} + +/// How many threads a single heavy job may use at the current budget. +pub fn budget_threads() -> usize { + threads_for(cores(), cpu_percent()) +} + +/// Cores to threads at `percent` — pure, so the rounding is unit-tested. Always +/// at least one thread and never more than the machine has, so 10% of a 4-core +/// laptop is 1 rather than 0. +pub fn threads_for(cores: usize, percent: u8) -> usize { + let cores = cores.max(1) as f64; + let want = (cores * f64::from(clamp_percent(percent)) / 100.0).round(); + (want.max(1.0).min(cores)) as usize +} + +/// Whether the budget is capping anything at all. At 100% every ffmpeg +/// invocation and its scheduling priority are exactly what they were before the +/// budget existed. +fn limited() -> bool { + cpu_percent() < 100 +} + +// ---- the one-heavy-job-at-a-time gate -------------------------------------- + +struct Gate { + busy: Mutex, + free: Condvar, +} + +fn gate() -> &'static Gate { + static GATE: OnceLock = OnceLock::new(); + GATE.get_or_init(|| Gate { + busy: Mutex::new(false), + free: Condvar::new(), + }) +} + +thread_local! { + /// Nesting depth on this thread. A leased job that calls another leased + /// helper (an export's second pass, a stitch inside an import) must not + /// queue behind itself. + static HELD: Cell = const { Cell::new(0) }; +} + +/// The heavy-job slot, held until dropped. +pub struct Lease { + threads: usize, + /// A nested lease owns no slot; only the outermost releases it. + nested: bool, +} + +impl Lease { + /// How many CPU threads this job may use. + pub fn threads(&self) -> usize { + self.threads + } +} + +impl Drop for Lease { + fn drop(&mut self) { + HELD.with(|h| h.set(h.get().saturating_sub(1))); + if self.nested { + return; + } + let gate = gate(); + if let Ok(mut busy) = gate.busy.lock() { + *busy = false; + gate.free.notify_one(); + } + } +} + +/// Wait for the heavy-job slot and take it. +/// +/// Every pass that reads a whole file goes through here, which is what keeps +/// eight concurrent agent analyses from becoming eight concurrent full-file +/// decodes. Callers must not hold the project lock across this — the wait is +/// unbounded by design (a queued job waits out the render ahead of it). +pub fn lease() -> Lease { + let depth = HELD.with(|h| h.get()); + HELD.with(|h| h.set(depth + 1)); + if depth > 0 { + return Lease { + threads: budget_threads(), + nested: true, + }; + } + let gate = gate(); + match gate.busy.lock() { + Ok(mut busy) => { + while *busy { + busy = match gate.free.wait(busy) { + Ok(g) => g, + // A panicking job poisoned the gate; take the slot rather + // than wedging every later job for the rest of the session. + Err(e) => e.into_inner(), + }; + } + *busy = true; + } + // Same: a poisoned mutex must not stop the engine working. + Err(e) => *e.into_inner() = true, + } + Lease { + threads: budget_threads(), + nested: false, + } +} + +// ---- thread caps on the command line --------------------------------------- + +/// The thread-cap flags for `threads`, or nothing when the budget is off. +/// +/// `-filter_threads` / `-filter_complex_threads` are true global options; +/// `-threads` is a per-file codec option, so where it sits decides what it +/// means. These are the *front* flags, which land in the first input's option +/// group and so cap the decoder — the expensive half of every analysis pass. +fn head_flags(threads: usize) -> Vec { + if !limited() || threads == 0 || threads >= cores() { + return Vec::new(); + } + let n = threads.to_string(); + vec![ + "-threads".to_string(), + n.clone(), + "-filter_threads".to_string(), + n.clone(), + "-filter_complex_threads".to_string(), + n, + ] +} + +/// Cap a built ffmpeg argument list to `threads`. +/// +/// Two insertions, because ffmpeg assigns `-threads` to whichever file group it +/// appears in: the [`head_flags`] cap the decode, and a second `-threads` goes +/// immediately before the last argument — which for every command the engine +/// builds is the output sink — so the *encoder* is capped too. A no-op at 100%, +/// which is what keeps the pure argument builders' tests describing exactly what +/// ffmpeg is handed. +pub fn limit_args(args: &mut Vec, threads: usize) { + let head = head_flags(threads); + if head.is_empty() { + return; + } + if let Some(sink) = args.len().checked_sub(1) { + args.splice(sink..sink, ["-threads".to_string(), threads.to_string()]); + } + args.splice(0..0, head); +} + +/// Cap a `Command` that is being built up fluently, before any of its own +/// arguments are pushed. Only the decode side — a command assembled this way +/// has no output sink to insert before yet. +pub fn limit_cmd(cmd: &mut Command, threads: usize) { + let head = head_flags(threads); + if !head.is_empty() { + cmd.args(head); + } +} + +/// Drop a child to below-normal scheduling priority. +/// +/// The thread cap decides how much of the machine ffmpeg *asks* for; this +/// decides who wins when it asks for too much. It is the half that keeps the +/// desktop usable, because the scheduler will hand the foreground window a core +/// the instant it wants one. Left alone at 100%. +pub fn background(cmd: &mut Command) { + if !limited() { + return; + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // `creation_flags` replaces the whole set, so the no-console flag + // `cli::command` set has to be repeated here or a terminal flashes over + // the GUI on every background ffmpeg. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x0000_4000; + cmd.creation_flags(CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // SAFETY: runs in the forked child before exec; `nice` is + // async-signal-safe and touches nothing this process owns. + unsafe { + cmd.pre_exec(|| { + libc::nice(10); + Ok(()) + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The budget is process-global, so the tests that move it cannot run + /// beside each other (cargo runs them on threads of one process). + fn exclusive() -> std::sync::MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + #[test] + fn threads_scale_with_the_budget() { + assert_eq!(threads_for(16, 100), 16); + assert_eq!(threads_for(16, 75), 12); + assert_eq!(threads_for(16, 50), 8); + assert_eq!(threads_for(12, 75), 9); + } + + #[test] + fn threads_never_reach_zero_or_exceed_the_machine() { + // 10% of a 4-core laptop rounds to nothing; a job still needs a thread. + assert_eq!(threads_for(4, 10), 1); + assert_eq!(threads_for(1, 10), 1); + // Out-of-range percentages clamp rather than overcommit. + assert_eq!(threads_for(8, 200), 8); + assert_eq!(threads_for(0, 100), 1); + } + + #[test] + fn limit_args_caps_both_the_decoder_and_the_encoder() { + let _serial = exclusive(); + // Pin the budget below the machine so the flags are actually written. + let restore = cpu_percent(); + set_cpu_percent(MIN_CPU_PERCENT); + let mut args: Vec = ["-hide_banner", "-i", "in.mp4", "-c:v", "libx264", "out.mp4"] + .iter() + .map(|s| s.to_string()) + .collect(); + limit_args(&mut args, 1); + // Decode caps lead, before the first `-i`. + assert_eq!( + &args[..6], + &["-threads", "1", "-filter_threads", "1", "-filter_complex_threads", "1"] + ); + // The encoder cap sits in the output group: after the last input, + // immediately before the sink. + assert_eq!(&args[args.len() - 3..], &["-threads", "1", "out.mp4"]); + set_cpu_percent(restore); + } + + #[test] + fn a_full_budget_writes_no_flags() { + let _serial = exclusive(); + let restore = cpu_percent(); + set_cpu_percent(100); + let original: Vec = ["-i", "in.mp4", "out.mp4"].iter().map(|s| s.to_string()).collect(); + let mut args = original.clone(); + limit_args(&mut args, 1); + assert_eq!(args, original, "a 100% budget must leave every command line untouched"); + set_cpu_percent(restore); + } + + #[test] + fn a_nested_lease_does_not_wait_for_itself() { + // The budget must hold still: `threads()` is sampled per lease. + let _serial = exclusive(); + let outer = lease(); + // Would deadlock against a non-reentrant gate: an export's second pass + // and a stitch inside an import both lease under an outer lease. + let inner = lease(); + assert_eq!(inner.threads(), outer.threads()); + drop(inner); + drop(outer); + // The slot is free again once the outermost lease is dropped. + let _next = lease(); + } +} diff --git a/crates/kerf-core/src/engine/mod.rs b/crates/kerf-core/src/engine/mod.rs index 09888c8..cf77298 100644 --- a/crates/kerf-core/src/engine/mod.rs +++ b/crates/kerf-core/src/engine/mod.rs @@ -23,6 +23,10 @@ pub struct ProbeResult { mod cli; +// How much of the machine the engine may take: the one-heavy-job-at-a-time gate +// and the thread / priority caps every ffmpeg run is launched under. +pub mod cpu; + // Audio analysis (loudness, energy, onsets, tempo, classification): CLI/PCM // based, available in every build like the rest of `cli`. mod audio; diff --git a/crates/kerf-core/src/engine/whisper.rs b/crates/kerf-core/src/engine/whisper.rs index 5a9eb39..f6b39b2 100644 --- a/crates/kerf-core/src/engine/whisper.rs +++ b/crates/kerf-core/src/engine/whisper.rs @@ -24,7 +24,8 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Mutex, OnceLock}; -use super::cli::{command, ffmpeg_bin, launch_err}; +use super::cli::{bg_command, command, ffmpeg_bin, launch_err}; +use super::cpu; use crate::error::{Error, Result}; use crate::model::TranscriptSegment; @@ -500,11 +501,15 @@ pub fn transcribe( .ok_or_else(|| Error::Engine("asset path is not valid UTF-8".to_string()))? .to_string(); - let args = build_transcribe_args(&input, model_file, &out_name, language); + let mut args = build_transcribe_args(&input, model_file, &out_name, language); let bin = ffmpeg_bin(); + // Minutes of inference over the whole file — the single heaviest thing an + // analysis pass does, and the one worth keeping off the user's other cores. + let cpu = cpu::lease(); + cpu::limit_args(&mut args, cpu.threads()); tracing::info!(path = %path.display(), model = %model.display(), "transcribing with the ffmpeg whisper filter"); - let mut child = command(&bin) + let mut child = bg_command(&bin) .current_dir(&model_dir) .arg("-progress") .arg("pipe:1") diff --git a/crates/kerf-core/src/lib.rs b/crates/kerf-core/src/lib.rs index c9a06f1..d8bc708 100644 --- a/crates/kerf-core/src/lib.rs +++ b/crates/kerf-core/src/lib.rs @@ -22,6 +22,9 @@ pub use analysis::{ FfmpegSceneDetector, FfmpegSilenceDetector, NullAnalyzer, ProgressFn, RhythmAnalyzer, SceneDetector, SilenceDetector, Transcriber, TranscriptionStatus, WhisperFilterTranscriber, }; +pub use engine::cpu::{ + budget_threads as cpu_threads, cores as cpu_cores, cpu_percent, set_cpu_percent, DEFAULT_CPU_PERCENT, MIN_CPU_PERCENT, +}; pub use engine::{ download_speech_model, export_still, generate_proxy, hw_encoders, insta360_pair, proxy_path, proxy_width, render_with, render_with_progress, set_speech_model, speech_model_names, stitch_insta360, stitched_path, stream_preview, validate_export, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a7699b5..fac4321 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -25,6 +25,8 @@ import type { Reframe, ReframeKeyframe, Revision, + AppSettings, + SettingsView, StagedEdit, StreamKind, Task, @@ -1780,6 +1782,56 @@ export async function agentStatus(): Promise<{ endpoint: string; last_seen_secs: return invoke<{ endpoint: string; last_seen_secs: number | null }>('agent_status'); } +// ---- app settings ---------------------------------------------------------- + +/** + * Read the preferences in force, resolved against the engine. In the browser + * harness there is no engine, so it answers from `localStorage` over the + * webview's own core count — enough to drive the dialog under `bun run dev`. + */ +export async function getSettings(): Promise { + if (!inTauri()) return browserSettings(readBrowserCpuPercent()); + return invoke('get_settings'); +} + +/** Persist the preferences and put them into force; returns the resolved view. */ +export async function setSettings(settings: AppSettings): Promise { + if (!inTauri()) { + const percent = Math.round(Math.min(100, Math.max(MIN_CPU_PERCENT, settings.cpu_percent))); + try { + localStorage.setItem(CPU_KEY, String(percent)); + } catch { + // A private window with storage blocked still gets a working dialog. + } + return browserSettings(percent); + } + return invoke('set_settings', { settings }); +} + +const CPU_KEY = 'kerf.settings.cpuPercent'; +const MIN_CPU_PERCENT = 10; +const DEFAULT_CPU_PERCENT = 75; + +function readBrowserCpuPercent(): number { + try { + const stored = Number(localStorage.getItem(CPU_KEY)); + if (Number.isFinite(stored) && stored > 0) return stored; + } catch { + // Ignore — fall through to the default. + } + return DEFAULT_CPU_PERCENT; +} + +function browserSettings(percent: number): SettingsView { + const cores = Math.max(1, navigator.hardwareConcurrency || 4); + return { + cpu_percent: percent, + cpu_cores: cores, + cpu_threads: Math.min(cores, Math.max(1, Math.round((cores * percent) / 100))), + cpu_min_percent: MIN_CPU_PERCENT + }; +} + // ---- diagnostics (logs) ---------------------------------------------------- /** The platform log directory Kerf writes its logfile to, or `null` in the browser. */ diff --git a/frontend/src/lib/components/editor/SettingsDialog.svelte b/frontend/src/lib/components/editor/SettingsDialog.svelte new file mode 100644 index 0000000..1c4b2d6 --- /dev/null +++ b/frontend/src/lib/components/editor/SettingsDialog.svelte @@ -0,0 +1,154 @@ + + + + +
{ + if (e.key === 'Escape') onClose(); + e.stopPropagation(); + }} + style="position:fixed;inset:0;z-index:50;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:24px" +> +
e.stopPropagation()} + style="width:620px;max-width:100%;max-height:100%;display:flex;flex-direction:column;background:var(--surface-panel);border:1px solid var(--border-default);border-radius:var(--radius-md);box-shadow:var(--shadow-lg,0 24px 60px rgba(0,0,0,.5));overflow:hidden" + > +
+ + Settings + +
+ +
+ +
+ {#each SECTIONS as s (s.id)} + + {/each} +
+ +
+ {#if section === 'performance'} +
+ CPU limit +
+

+ Kerf runs one heavy job at a time — an analysis pass, a transcription, a proxy, an export — and + this is how much of the machine that job may take. Left alone, FFmpeg takes all of it. +

+ +
+ {#each CPU_PRESETS as p (p.id)} + + {/each} +
+ +
+ (settings.cpuPercent = Number(e.currentTarget.value))} + onchange={(e) => settings.setCpuPercent(Number(e.currentTarget.value))} + style="flex:1;accent-color:var(--kerf-500);cursor:pointer" + /> + {percent}% +
+ +
+ {threads} of {cores} + {cores === 1 ? 'core' : 'cores'} for Kerf · {spare === 0 + ? 'nothing held back' + : `${spare} left for everything else`} +
+ +

+ {#if preset} + {preset.hint} + {:else} + Renders scale roughly with the share you allow; everything else on the machine gets the rest. + {/if} +

+ + {#if percent < 100} +

+ Below 100%, background work also runs at lower scheduling priority, so the window you are + looking at always wins a core when it wants one. +

+ {/if} + +

+ A job already running keeps the cores it started with — FFmpeg cannot be told otherwise + mid-render. The next one picks this up. +

+ {/if} +
+
+
+
diff --git a/frontend/src/lib/components/editor/TitleBar.svelte b/frontend/src/lib/components/editor/TitleBar.svelte index e0e0ae4..66fabfe 100644 --- a/frontend/src/lib/components/editor/TitleBar.svelte +++ b/frontend/src/lib/components/editor/TitleBar.svelte @@ -5,6 +5,7 @@ import { editor } from '$lib/state.svelte'; import { updater } from '$lib/updater.svelte'; import { notifications } from '$lib/notifications.svelte'; + import { settings } from '$lib/settings.svelte'; // An available update stays offered here after the dialog is dismissed; // otherwise the version label doubles as a manual "check for updates". @@ -23,6 +24,18 @@ Unsaved {/if} +