# picut — llms.txt > picut is the typed, undoable video-project surface an AI agent drives. There is NO LLM inside > picut: YOU (the calling agent) are the intelligence. picut gives you a safe hand — a validated op > surface, time-indexed facts to read, and a deterministic engine that renders proof on the user's > machine. All decode/render/analysis run locally (WebCodecs via headless Chrome); only auth/license/STT > touch a server. ## Install & connect (MCP) npm i -g picut-cli claude mcp add picut -- picut mcp # runs on stdio in the current project dir claude mcp add picut -- picut mcp --project /path/to/project ## The loop 1. PERCEIVE — read time-indexed facts (get_facts): silence, scene, transcript. RECALL what already happened with history (a person may have edited this project in the web editor while you were away). 2. ACT — emit a batch of typed ops (apply_ops). Invalid ops bounce as a typed error; retry. 3. PROVE — render to a real MP4 on the user's machine (render). Read the live JSON Schema first with the ops_schema tool (or `picut ops --schema`). It is the source of truth; the summary below orients you. ## Ops (op boundary is decimal SECONDS, not microseconds) - addTrack { kind: "video"|"audio"|"caption"|"overlay", name? } - removeTrack { trackId } // removes the track and its clips - moveTrack { trackId, toIndex } // re-layer within its kind; toIndex 0 = bottom, last = drawn on top - addClip { assetId, startSec, durationSec, trackId?, sourceOffsetSec?=0, speed?=1, imageFit?, imageRect?, linkId? } // imageFit "cover"(default for image assets)|"contain"|"free"; video clips too (default: full frame when absent); imageRect {x,y,w,h} 0..1 for "free" // linkId: clips sharing it MOVE together (e.g. a video + its extracted audio); <2 members auto-clears - moveClip { clipId, startSec, trackId? } // trackId re-lanes the clip (SAME kind only); fails on overlap - trimClip { clipId, startSec?, durationSec? } - splitClip { clipId, atSec, newLinkId? } // the right half never INHERITS linkId (a cut must not grow the rigid move-group). Pass the SAME fresh newLinkId to every splitClip in one batch that cuts a linked group at the same point, and the tails stay a pair — that is what cutting a linked video+audio should do - removeClip { clipId } - unlinkClip { clipId } // clears linkId across the whole group (stop moving together) - setClipAsset { clipId, assetId } // repoint a clip at a DIFFERENT asset, keeping position/trim/speed/volume/link. The swap a derived asset needs (denoise). Fails if the clip's trim window reads past the new asset's end, or if the asset can't back that lane (silent asset on an audio lane, audio-only asset on a video lane) - setClipProps { clipId, volume?, opacity?(0..1), speed?, imageFit?(cover|contain|free), imageRect?, flipH?, flipV?, scale?(>0), fadeInSec?(>=0), fadeOutSec?(>=0), linkId?, matte?, cropKeys?, opacityKeys?, speedKeys? } // ≥1 field; imageFit/imageRect/flipH/flipV/scale: image & video clips (free = move/resize in imageRect {x,y,w,h} 0..1; video default when absent = full frame; flipH/flipV = mirror the picture in place, absent = no flip; scale = uniform zoom about the picture's center, all fit modes, absent = 1, >1 overflows the frame, <1 shows background); audio/shape/text → error. fadeInSec/fadeOutSec: ANY clip kind — the picture ramps its opacity and the audio its gain over that many seconds at the clip's head/tail (0 = none; a fade longer than the clip, or head+tail overlapping, is clamped at render time, never an error). linkId: JOIN an existing move-group (addClip mints one at creation; this is how a clip added later joins it). A group of one is pruned — set the same linkId on both members in the SAME batch. matte: { maskAssetId } applies a BAKED background-removal mask to the clip's picture (null removes it). The mask is a `kind:"mask"` asset produced by the removeBackground pass; image/video clips only. cropKeys: [{tSec, rect}] (>=2, sorted) = auto-reframe's MOVING crop window — rect is normalized to the SOURCE (a window ON the footage, unlike imageRect which is a box on the OUTPUT), tSec is the clip's own MEDIA time, and it overrides imageFit/imageRect while set. Linear between keys, holds outside them; a CUT is two keys at the SAME tSec. null clears it. opacityKeys: [{tSec, opacity 0..1}] and speedKeys: [{tSec, speed>0}] animate opacity and playback SPEED over the clip's OWN time (>=2 keys, sorted; null clears; present overrides the scalar opacity/speed). ⚠️ TIME BASE DIFFERS: cropKeys tSec is SOURCE (media) seconds, but opacityKeys/speedKeys tSec is CLIP-START seconds. A speed ramp is an INTEGRAL — the source time it shows is ∫speed dt, and audio follows as varispeed (pitch changes with speed; no pitch-preserving stretch). A ramp that would read past the asset's end is rejected. - reorderClip { clipId, toIndex } // reorder to an index (local window rotation; only clips between old/new index move; gaps preserved) - addCaption { assetId, startSec, durationSec, text, trackId? } - setCaptionText { clipId, text } - addWaveform { assetId, startSec, durationSec, sourceOffsetSec?, style?(bars|mirror|circular), color?, rect?, barCount?, barWidth?, background?, sensitivity?, trackId? } // audio-reactive FREQUENCY-SPECTRUM visualizer clip on an overlay track; assetId = the audio it visualizes; bars map low→high freq across the width (circular wraps it around a ring); silent (volume 0) - setWaveformProps { clipId, style?, color?, x?, y?, w?, h?, barCount?, barWidth?, background?(null clears), sensitivity? } // ≥1 field - addShape { shape(rect|ellipse), startSec, durationSec, fill?(#rrggbb), rect?{x,y,w,h 0..1}, trackId? } // solid overlay primitive on an overlay track (no asset — the payload IS the content) - setShapeProps { clipId, fill?, kind?, x?, y?, w?, h? } // ≥1 field; geometry flattened to x/y/w/h; non-shape clip → error - addText { content?, startSec, durationSec, x?, y?, size?(0..1 of height), color?(#rrggbb), font?, align?(left|center|right), strokeEnabled?, strokeColor?, strokeOpacity?, strokeWidth?, trackId? } // free-placed title; lands on the caption lane by default. font = a canvas family; the web editor self-hosts curated Google Fonts (Inter, Roboto, Open Sans, Montserrat, Lato, Poppins, Raleway, Oswald, Playfair Display, Merriweather, Bebas Neue, Anton, Pacifico, Dancing Script, Caveat, Noto Sans KR, Black Han Sans, Do Hyeon) — an unknown name (and the CLI render) falls back to system sans-serif // stroke = the 테두리 outline: strokeEnabled(default true), strokeColor(default #000000), strokeOpacity(0..1, default 0.55), strokeWidth(fraction of font, default 0.14). Defaults reproduce the legacy baked look - setTextProps { clipId, content?, x?, y?, size?, color?, font?, align?, strokeEnabled?, strokeColor?, strokeOpacity?, strokeWidth? } // ≥1 field; touching any stroke* field materializes the outline with legacy defaults then patches - setProjectSettings { width?, height?, fps?, background?(#rrggbb canvas backdrop; absent = black) } // ≥1 field; output canvas size (orientation = swap w/h) + frame rate + backdrop color A batch is atomic: if any op is invalid, nothing is written. Entity ids (tracks/clips) are assigned once at apply time and stay stable across later calls — read them back with get_project. Project format follows the media. An imported video asset carries its probed source format (width/height — DISPLAY dimensions, so a portrait phone clip reads 1080x1920 — and fps). Placing the FIRST video onto an empty timeline adopts that format into the project's settings (one setProjectSettings op, batched with the placement, so ONE undo reverts both). Later placements never reshape it, and an explicit setProjectSettings you issue wins from then on. This is not cosmetic: the export can only STREAM-COPY a source's encoded packets — instead of re-drawing and re-encoding every frame — when the output geometry matches the source exactly. A project left on a shape its media does not have turns a plain cut of a 2h recording into a 2h re-encode. Example apply_ops batch (remove a silent gap, close the timeline): [{ "op": "splitClip", "clipId": "c1", "atSec": 12.4 }, { "op": "removeClip", "clipId": "c1b" }, { "op": "moveClip", "clipId": "c2", "startSec": 12.4 }] ## Facts (get_facts → sidecar per asset; { type, start, end, ... } in seconds) - { "type": "silence", "start": 8.20, "end": 9.05 } - { "type": "scene", "start": 0.00, "end": 4.12 } - { "type": "beat", "start": 8.42, "end": 8.42 } // an INSTANT — start == end - { "type": "transcript", "start": 1.10, "end": 3.40, "text": "...", "words": [{ "text": "hello", "start": 1.10, "end": 1.46 }] } Produce facts with `picut analyze --silence --scenes` / `analyze --transcribe`, or import your own with `picut facts import --asset `. Beats come from `analyze --beats` (pure spectral-flux DSP — no model, deterministic). `words` (per-word timings) rides on transcripts that came from Whisper (analyze --transcribe, or a whisper.json import). SRT/VTT carry none — a transcript without `words` is editable at cue granularity only, and text_cut says so rather than guessing. ## Talking back to us (send_feedback) Hit a wall — an edit picut cannot express, a contract that misled you, a defect? Call `send_feedback` ({message, kind: suggestion|question|bug, email?}). No login, no subscription: an agent that cannot report the wall it hit is a wall we never learn about. Pass `email` only if the user gave you one to be answered at. (`feedback_list` / `feedback_reply` also exist, but they are OURS — they need an admin token you do not have.) ## Recipes (deterministic facts → ops; no LLM inside — you invoke them) Three of these — cut_silences, denoise, remove_background — are SMART EDIT: picut Pro only. They run on the user's machine, so the gate is not about server cost; the automation itself is what the subscription buys. Without an active subscription they fail with a 'picut Pro feature' error telling the user to run `picut login` (see https://picut.app/pricing). Everything else — every op, render, undo, export — is free, forever, unwatermarked. - Cut silences [Pro]: analyze --silence → cut_silences (or `picut cut-silences [--pad-sec 0.15]`). Splits/removes silent ranges and closes the gaps as one undoable batch. - Auto-captions: analyze --transcribe (or facts import) → captions_build (`picut captions build [--max-chars 42]`). Segments a transcript into a caption track; render burns it in. - Beat-synced cuts: analyze --beats → beat_snap (or `picut beat-snap [--max-shift-sec 0.2]`). Nudges the cuts you ALREADY made onto the nearest onset (trimClip + moveClip, one undo step; no new op). It MOVES cuts, it does not make them — to cut ON every beat, read the beats with get_facts and split at them yourself. A cut further than maxShiftSec from any beat is left alone. - Auto-reframe: reframe (or `picut reframe [--dry-run]`). Tracks the subject and writes a moving crop window (one setClipProps { cropKeys } = one undo step). The window is sized from the PROJECT's output shape — set that first (setProjectSettings 1080x1920 for 9:16); reframe never reshapes your timeline for you. Camera work is hold/pan/cut. REFUSES a shot with no subject to follow — for a FIXED crop, set imageRect yourself. Reframed clips re-encode (every pixel changes); other clips still stream-copy. - Background removal [Pro]: remove_background (or `picut remove-background [--dry-run]`). MediaPipe segments every frame on the user's machine and bakes a per-frame alpha mask asset; one setClipProps { matte } applies it. It segments the salient SUBJECT (a speaker, a pet, an object) — not strictly a person. REFUSES, with no override, a shot with no clear subject, or one whose edges the model cannot resolve. A `kind:"mask"` asset is DERIVED: addClip and setClipAsset both reject it — it is referenced, never placed. Note: a matted clip can never be stream-copied (every pixel changes), so it re-encodes; other clips still copy. - Voice cleanup [Pro]: denoise (or `picut denoise [--dry-run]`). RNNoise strips wind/hum/room tone on the user's machine, writes a cleaned audio asset, and repoints the clips that play it (one setClipAsset each = one undo step); the video file is never re-muxed. It REFUSES, with no override, a track that is not speech (it is a speech model — it would damage music) or one that is already clean (a second pass dulls the voice). - Text-based editing: analyze --transcribe → list_words → text_cut (or `picut text-cut --list`, then `--words 3,4,5` / `--fillers` / `--pauses-over 1.5`). Deleting words in the transcript cuts the video under them: same split/remove/ripple batch as cut_silences, one undo step. Read list_words FIRST — the indices are its output. - Export overrides: render (or `picut render`) defaults to the PROJECT's geometry, but you can override the output WITHOUT mutating the timeline (export is not an op — nothing to undo). preset: 4k · 1440p · 1080p · 720p · vertical(9:16) · square(1:1) · portrait(4:5); or explicit width/height (even integers) / fps / quality (high|balanced|small) / bitrate (bits/s; wins over quality). Pass SEVERAL presets to batch — one file per preset, the name suffixed into the stem (`out.vertical.mp4`). Overriding fps or bitrate (or quality "small") forces a re-encode; a resolution/aspect change re-encodes anyway (geometry mismatch). A DIFFERENT aspect just re-fits (letterbox over the background — never a silent crop); for a content-aware crop, REFRAME first: reframe → apply the cropKeys ops → render --preset vertical → undo (the reframe is the hand's move, not the export's). Upscaling (e.g. 1080p→4k) is allowed and never watermarked, but reported as "no new detail". Every render writes a provenance sidecar next to it (`.picut.json`): engine version, output geometry, the sources, and an editHash (sha256 of the op log) — proof of what was rendered, from what, deterministically, with no mark. - Templates / brand kit (no picut feature — it's YOUR job): a reusable title look IS a saved op batch. Keep a `lower-third.json` of addText/addShape ops with your brand's font/color/stroke/position, and re-apply it with apply_ops (`picut apply lower-third.json`), retiming startSec and swapping the text per use. picut stores no "style" or "brand" object — every style axis already lives on the addText/addShape payload, and the brand is state YOU hold and stamp per op. (Caption-track styling is not yet exposed — captions burn in one geometry-derived style; that is a future op, not a preset.) - Already possible — DON'T ask for a feature, COMPOSE it from ops you have: - Cross-dissolve between two clips: overlap them on stacked video tracks, then ramp the TOP clip's opacity from 0→1 across the overlap with opacityKeys (setClipProps { opacityKeys: [{tSec:0,opacity:0},{tSec:D,opacity:1}] }, tSec in CLIP-start seconds). The lower clip shows through as the upper fades in — that IS a dissolve. - Ken Burns (slow pan/zoom over a photo): an image clip's cropKeys ARE the move — two keys with a slightly different rect (a tighter/offset window at the end) pans and zooms across the still over the clip's span. - Fade to/from black: ONE clip's opacityKeys against the project background (setProjectSettings background), or fadeInSec / fadeOutSec on the clip for a symmetric head/tail fade of both picture and audio. - Multitrack audio mix: already automatic — every audio track sums in the mix window. To balance them you need per-clip volume (setClipProps { volume }); track-level gain is a future op, the mixer is not missing. - A freeze-frame is NOT expressible today (speed 0 is rejected; there is no still-export op yet) — do not try speedKeys to 0. Say so plainly instead of emitting an op that will bounce. ## MCP tools ops_schema · init_project · add_asset · get_project · apply_ops · undo · redo · history · get_facts · cut_silences · captions_build · transcribe · denoise · remove_background · reframe · beat_snap · list_words · text_cut · render · send_feedback · feedback_list · feedback_reply ## Requirements & guarantees - Requires Google Chrome; macOS or Linux first-class (Windows best-effort); Node.js >= 22. - Nothing leaves the machine except the audio slice explicitly sent to hosted captions (transcribe). - undo = deterministic replay of the op log = an audit trail of exactly what you did. Web: https://picut-web.dancinlife.workers.dev · Docs source: AGENTS.md in the repo.