Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <file>` 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 32 additions & 5 deletions crates/kerf-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<settings::SettingsView> {
// 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)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1742,6 +1767,8 @@ pub fn run() {
reveal_path,
mcp_endpoint,
agent_status,
get_settings,
set_settings,
log_dir,
reveal_logs
])
Expand Down
108 changes: 108 additions & 0 deletions crates/kerf-app/src/settings.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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");
}
5 changes: 5 additions & 0 deletions crates/kerf-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 11 additions & 1 deletion crates/kerf-core/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i32>();
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));
}
Expand All @@ -259,6 +265,7 @@ fn run_whisper_rs(
samples: Vec<f32>,
model: std::path::PathBuf,
language: Option<String>,
threads: usize,
progress: std::sync::mpsc::Sender<i32>,
) -> Result<Vec<TranscriptSegment>> {
use crate::error::Error;
Expand All @@ -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));
}
Expand Down
9 changes: 7 additions & 2 deletions crates/kerf-core/src/engine/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<Loudness> {
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)
Expand Down
Loading
Loading