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
93 changes: 85 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,21 @@ so the feature is **only** activated through these forwards — which is what ma
(override with `KERF_FFMPEG` / `KERF_FFPROBE`). Probe, `silencedetect`, scene
detection, preview frames (`frame_at`; `frame_jpeg` for a low-res JPEG), the
per-asset **contact sheet** (`contact_sheet` — a `tile`d grid of frames sampled
across a range, for skimming footage) and the **composited timeline still**
(`timeline_frame` / `build_timeline_frame_args`, pure + unit-tested — overlays
across a range, for skimming footage), the **salience map** behind smart crop
(`salience_map` / `build_salience_args` / `score_salience`, the last two pure +
unit-tested — one pass decodes ~48 tiny gray frames of a source window and scores
each cell by edge energy plus frame-to-frame motion, so a locked-off talking head
scores on detail and a follow shot on both; deliberately *not* face detection —
no model to ship, and the answer only has to beat a centre crop) and the
**composited timeline still**
(`timeline_frame` / `build_still_args`, pure + unit-tested — overlays
every clip visible at a timeline time onto a black canvas, mirroring the export
geometry, so an agent can *see the cut*), waveforms, and export all live here, so
geometry, so an agent can *see the cut*), the **cover frame** (`export_still` —
the same graph and the same builder, but a `StillOutput::File` sink and no
preview width cap, so the thumbnail is a real frame of the finished video at
the delivery shape rather than a screenshot to crop back into agreement; the
preview keeps its MJPEG pipe, which is why every pre-existing still test is
byte-identical), waveforms, and export all live here, so
they work in the `--no-default-features` build — only the binaries are needed,
never the dev libraries. Preview decodes go through a **cached all-intra proxy**
(`generate_proxy` in the background, `ready_proxy` never blocks, resolved by
Expand Down Expand Up @@ -260,6 +271,12 @@ no editing logic in the adapter.
carries a `duck` flag (sidechain-ducked under the rest of the mix on export).
`Fit` and `Delivery` live here (the domain owns the delivery shape; `engine::cli`
re-exports `Fit`), and `Timeline.format` is the frame the project is cut for.
**Smart crop** is here too and pure + unit-tested: `SalienceMap::crop_for` slides a
window of the delivery aspect across the sampled map and returns the `CropFrame`
(per-edge fractions, plus how far off centre it landed) that keeps the content —
with a `CENTER_BIAS` so a flat map resolves to the plain centre crop rather than to
whichever edge won by rounding, and `needs_crop` short-circuiting footage that is
already the delivery shape.
Inherent helpers (`Timeline::locate`, `Track::end`/`reflow`, `Clip::duration`,
`Timeline::slice` — the shifted sub-timeline copy behind range export) back the
operations. **Beat alignment** lives here too and is pure + unit-tested:
Expand All @@ -279,6 +296,24 @@ no editing logic in the adapter.
every clip having been replaced, and a removed track is one entry instead of one
per orphaned clip. `StagedEdit` is a pending proposal (base seq, the edit
labels, `stale`, and its diff).
- `platform.rs` — **where the cut is going.** A static `TARGETS` table (Reels /
Shorts / TikTok / Instagram feed / YouTube: delivery frame, accepted aspects,
length limits) plus a pure, unit-tested `check` over a `CutSummary`. It keeps
two limits apart that are usually conflated: a **hard** limit is what a
platform rejects, a **reach** limit is what it accepts and then stops
distributing — a four-minute Reel uploads fine and is shown only to existing
followers, the worse outcome because nothing tells you. Findings carry a
`Severity` (error / warning / tip) *and* an `IssueKind` (empty / length / shape
/ resolution / captions), because a landscape cut earns a near-identical shape
complaint from every vertical feed and the UI has to collapse those into one
line naming four platforms. Messages are phrased with the real numbers
("0:20 over", "cutting 1:00 would keep it in the feed"); aspect is compared as
a **ratio**, so 720x1280 reads as the right shape and merely soft. The numbers
are other companies' product decisions, verified 2026-08-25 and **advisory** —
nothing here ever blocks an export. `Project::platform_check(frame)` resolves
the summary from `working_timeline` (so an agent is judged on its own
proposal) with an optional frame override, which the export dialog passes when
a render resizes away from the project frame.
- `project.rs` — `Project` wraps a `rusqlite::Connection`. **Persistence shape:**
`assets` and `analysis` are real tables (streams/analysis stored as JSON columns);
the **entire timeline is a single JSON blob** in a one-row `timeline` table. All
Expand All @@ -292,7 +327,18 @@ no editing logic in the adapter.
every asset's cached `Tempo`, builds the grid and aligns one track (or every
unlocked video track) to it, defaulting the tolerance to half a beat so each cut
moves to the beat it is already nearest; it errors when nothing rhythmic has been
analyzed rather than silently doing nothing. The **agent task queue** is a real `tasks` table (one row per `Task`,
analyzed rather than silently doing nothing.
`smart_crop(clip_id)` is "frame it for where it's going": reshaping a cut throws
away most of one axis and both fits pick that axis blindly — `Cover` takes the
middle, `Contain` letterboxes — so it samples where each shot's content actually
sits and writes the crop that keeps it, **per clip**, as one `Smart crop` revision.
Split three ways for the lock-free pattern (`smart_crop_inputs` under the lock →
the static `sample_smart_crops` with it released → `apply_smart_crops` under it
again); the result is an ordinary `Transform` crop, which the graph already applies
*before* the fit scale, so the preview, the still and the export all follow and the
inspector's sliders still have the last word. Clips already the delivery shape and
360-reframed clips are left out (that camera *is* the framing decision), and a pass
that changes nothing writes no revision. The **agent task queue** is a real `tasks` table (one row per `Task`,
columns not JSON): `add_task` / `list_tasks` / `claim_next_task` / `complete_task`
/ `fail_task` / `resolve_task` / `remove_task` drive the `queued → working →
ready → done` (or `failed`) lifecycle in `model.rs`.
Expand Down Expand Up @@ -367,6 +413,13 @@ proposal appears for review, not that the cut changes: the read tools
(`get_timeline_state`, `timeline_summary`, `preview_timeline`, `export`) go through
`working_timeline`, so the agent sees the cut it is building, and
`timeline_summary` carries `staged_changes` so it cannot mistake one for the other.
`smart_crop` frames each shot for the delivery frame (the server `instructions`
pair it with `set_delivery_format`, since reshaping to 9:16 otherwise keeps
whatever was in the middle).
`platform_check` tells it whether the cut is publishable where it is going
(and the server `instructions` tell it to run that before reporting a cut
finished — an agent that assembles a four-minute Reel has done the work and lost
the audience), and `export_cover` writes the thumbnail.
`stage_edits` / `staged_diff` (the entries plus a rendered text summary) /
`apply_staged_edits` / `discard_staged_edits` drive it explicitly, and
`revision_diff` explains a past revision. The server `instructions` spell the flow
Expand All @@ -393,14 +446,20 @@ width/height to clear it), `remove_clip`, `set_volume`, `set_fade`,
`set_asset_projection` (asset-level 360 mark; returns the `Asset`),
`add_overlay` / `update_overlay` / `remove_overlay` / `set_overlay_keyframes`,
`captions_from_transcript`, `export_srt`, `remove_silence`, `snap_to_beats`,
`smart_crop` (frame each shot for the delivery frame),
`extract_audio`, `concatenate` — each returns the
refreshed `Timeline`), media (`get_frame` → base64 PNG data URL, `get_waveform`,
`start_playback` / `stop_playback` — streamed composited frames over a
`tauri::ipc::Channel`, cancelled **by caller-supplied id** rather than a generation
counter, because start and stop are separate async calls that can arrive out of
order and a late stop must not kill the stream that replaced it —
`get_audio` → a clip window as **raw mono s16le PCM via `tauri::ipc::Response`**, the
only non-JSON command — the preview's Web Audio playback decodes it), the
only non-JSON command — the preview's Web Audio playback decodes it),
delivery (`export_cover` → a cover image at the full delivery frame,
`platform_targets` / `platform_check` → the readiness verdict, `reveal_path` →
show a rendered file in the OS file manager, opening its *containing folder*
rather than the file, since "show me where it went" is not a request to launch a
player), the
agent task queue (`list_tasks`, `add_task` → the new `Task`; `resolve_task` /
`remove_task` → the refreshed `Task[]`), the agent's staged proposal
(`get_staged_edit` → the `StagedEdit` *with its diff*, so the review card renders
Expand Down Expand Up @@ -500,7 +559,10 @@ editor-grade workspace under `src/lib/components/editor/` — bespoke atoms (`Bt
`routes/+page.svelte`. The `Inspector` (right panel) edits the selected clip —
trim, volume, fades, speed, transform, color, transition, plus **video / audio
effect chains** (add / tune / remove), **keyframe animation** (the Transform panel
auto-keyframes at the playhead and shows the sampled pose), a **360 reframe**
auto-keyframes at the playhead and shows the sampled pose), a **Framing** section
(a `Smart crop` button that frames *this* shot for the delivery frame, plus
`Reset crop`, above the crop sliders it writes — greyed out with a reason when the
shot already matches the frame or is 360), a **360 reframe**
section (yaw / pitch / roll / FOV, auto-keyframing
at the playhead like Transform — note its `lerpAngle` takes the shortest arc, which
plain `lerp` would read as a 340° swing across the seam; for a source Kerf did not
Expand Down Expand Up @@ -557,7 +619,21 @@ frame is height-bound in a wide pane, not squashed), and for a vertical or squar
delivery it draws **safe-area guides** (the platform's top strip / caption rail /
action column, plus a title-safe box; `ui.safeAreas`, toggled from the preview
context menu). The export dialog's "Source" resolution relabels to
**Project frame (WxH)** so the two surfaces cannot silently disagree.
**Project frame (WxH)** so the two surfaces cannot silently disagree. It also
leads with a **readiness panel**: "Ready for Instagram Reels · YouTube Shorts ·
TikTok", then any length errors / reach warnings one line each, then a *single*
collapsed line for shape ("A 16:9 cut is letterboxed on … Pick a delivery frame
in the toolbar") — grouped by `IssueKind`, because otherwise four vertical feeds
each say the same thing. It re-checks against `opts.resolution`, so a 9:16
project exported at 1920×1080 is judged as the landscape file it will be.
`kerf_core::platform` decides all of it; `src/lib/platforms.ts` is a bun-tested
mirror used **only** by the browser harness, so the panel is drivable under
`bun run dev`. `src/lib/smart-crop.ts` is the same arrangement for smart crop: only
the *shape* arithmetic is mirrored (bun-tested), because the harness has no decoder
to sample with and so lands on the centre window — which part of the shot survives
is the half that only exists with media behind it. The **cover frame** is saved from the preview's context menu
(`Save cover frame…` → `export_cover` at the playhead), and both a finished
export and a saved cover offer **Show in folder** in their toast.
`Preview` shows the composited frame under the playhead, and during
**forward 1× playback it switches to the streamed frame source** (`start_playback`)
— per-frame `get_timeline_frame` decodes stay for scrubbing, shuttle and the
Expand Down Expand Up @@ -589,7 +665,8 @@ queue** (status · queue · history · add-task) — Kerf has no in-app chat; a
LLM claims tasks over MCP. The queue is `agent` state (`src/lib/agent.svelte.ts`, a third
runes singleton) backed by the `tasks` table over Tauri/MCP: the add-task box and preset chips
`agent.add(...)` real tasks, and `ready` tasks show Apply/Dismiss (`resolve_task`/`remove_task`).
Three preset chips (`Remove silences` / `Assemble rough cut` / `Cut to the beat` — which
Four preset chips (`Remove silences` / `Assemble rough cut` / `Frame for the delivery`
/ `Cut to the beat` — which
analyzes whatever is on the audio tracks first, then calls `snap_to_beats`, and says
"No cuts were near a beat" instead of claiming an alignment when the grid never reached
them) also run the matching local op and
Expand Down
85 changes: 85 additions & 0 deletions crates/kerf-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,86 @@ async fn export_timeline(
.await
}

/// Write the composited frame at `time_secs` to `output_path` as a **cover
/// image** — full delivery resolution, decoded from the original media rather
/// than a preview proxy. `format` follows the file extension when omitted.
#[tauri::command]
async fn export_cover(
state: State<'_, AppState>,
time_secs: f64,
output_path: String,
format: Option<kerf_core::ImageFormat>,
) -> CmdResult<String> {
let shared = state.project.clone();
blocking(move || {
// Same shape as every heavy command: resolve under the lock, render
// without it. A 4K still is a real decode.
let (timeline, assets) = lock_user(&shared).export_still_inputs().map_err(|e| e.to_string())?;
let path = Project::render_still(&timeline, &assets, time_secs, &output_path, format).map_err(|e| e.to_string())?;
Ok(path.to_string_lossy().into_owned())
})
.await
}

/// Frame every shot for the delivery frame instead of centring it blindly —
/// samples where each clip's content sits and writes the crop that keeps it.
/// One clip when `clip_id` is given, otherwise every clip on an unlocked video
/// track. The result is an ordinary transform crop, so the inspector's sliders
/// still have the last word.
#[tauri::command]
async fn smart_crop(state: State<'_, AppState>, clip_id: Option<String>) -> CmdResult<Timeline> {
let clip = clip_id.as_deref().map(id).transpose()?;
let shared = state.project.clone();
blocking(move || {
// The usual shape for a heavy command: plan under the lock, decode
// without it (one short ffmpeg pass per clip), apply under it again.
let plan = lock_user(&shared).smart_crop_inputs(clip).map_err(|e| e.to_string())?;
let crops = Project::sample_smart_crops(&plan).map_err(|e| e.to_string())?;
let project = lock_user(&shared);
project.apply_smart_crops(&crops).map_err(|e| e.to_string())?;
project.timeline().map_err(|e| e.to_string())
})
.await
}

/// Every publishing target Kerf knows about, with its frame and length limits.
#[tauri::command(async)]
fn platform_targets() -> Vec<kerf_core::PlatformTarget> {
kerf_core::PLATFORM_TARGETS.to_vec()
}

/// How ready the current cut is for each target — what would be rejected, what
/// would be accepted and then under-distributed, and what would just be better.
#[tauri::command(async)]
fn platform_check(
state: State<'_, AppState>,
width: Option<u32>,
height: Option<u32>,
) -> CmdResult<Vec<kerf_core::DeliveryCheck>> {
// The export dialog can resize away from the project frame; when it does it
// passes the frame it is actually about to render, so the verdict is about
// the file that will exist rather than the one the project defaults to.
let frame = width.zip(height);
state.project().platform_check(frame).map_err(|e| e.to_string())
}

/// Show a file in the OS file manager. The last step of an export: the render
/// finished somewhere, and "somewhere" is not much use on its own.
#[tauri::command(async)]
fn reveal_path(app: AppHandle, path: String) -> CmdResult<()> {
use tauri_plugin_opener::OpenerExt;
// Open the containing folder, not the file — opening the file would launch
// a player, which is not what "show me where it went" means.
let target = std::path::Path::new(&path)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from(&path));
app.opener()
.open_path(target.to_string_lossy().into_owned(), None::<&str>)
.map_err(|e| e.to_string())
}

/// Request cancellation of the in-flight export (if any). The running
/// [`export_timeline`] observes the flag on its next progress poll, stops
/// ffmpeg, and returns the `"export cancelled"` error.
Expand Down Expand Up @@ -1528,6 +1608,7 @@ pub fn run() {
export_srt,
remove_silence,
snap_to_beats,
smart_crop,
extract_audio,
concatenate,
get_history,
Expand All @@ -1553,6 +1634,10 @@ pub fn run() {
hw_encoders,
export_timeline,
cancel_export,
export_cover,
platform_targets,
platform_check,
reveal_path,
mcp_endpoint,
log_dir,
reveal_logs
Expand Down
Loading
Loading