Cropper is a local web app for:
- video crop + trim (manual drag box + presets, timeline trim)
- image crop (manual drag box + presets, heuristic auto-crop)
- audio trim (single range or multi-segment cuts)
- auto-crop detection for video/image
- black bars (static or adaptive)
- subject/activity region (static or adaptive for video)
The app runs fully local with a FastAPI backend and ffmpeg processing.
cropper.git/
├── app/
│ ├── main.py
│ ├── requirements.txt
│ ├── static/
│ │ ├── index.html
│ │ ├── app.css
│ │ └── app.js
│ └── tmp/
├── install.js
├── start.js
├── update.js
├── reset.js
├── pinokio.js
├── pinokio.json
└── README.md
- Click
Install. - Click
Start. - Open
Open Web UI. - Upload media (video, image, or audio).
- Set crop/trim/segments/auto-crop as applicable.
- Click
Process Mediaand download the result.
- Input: Any video/audio format your local ffmpeg can decode; common image formats (png/jpg/webp). GIF is not supported.
- Output defaults: MP4 (
H.264/AAC) for video, PNG for image, MP3 for audio. You can change formats per media. - Storage: Uploads and outputs are temporary and auto-cleaned.
cd app
uv venv env
source env/bin/activate
uv pip install -r requirements.txt
uvicorn main:app --host 127.0.0.1 --port 8000POST /api/upload (multipart form field: file)
Response includes:
media_idmedia_type(video,image,audio)preview_urlmetadata- video:
width,height,duration,frame_rate,has_audio,codec - image:
width,height - audio:
duration,sample_rate,channels,codec
- video:
POST /api/media/{media_id}/autocrop
Body:
{
"mode": "black_static",
"window_seconds": 8,
"aspect_ratio": "free"
}Modes:
black_staticblack_adaptivesubject_staticsubject_adaptive
Returns either:
kind: "static"withcropkind: "adaptive"withcropandadaptive_planNotes:- Adaptive plans are only generated for video; images always return a static crop.
- Backwards-compatible alias:
POST /api/videos/{video_id}/autocrop.
Start job: POST /api/process/start
Body:
{
"media_id": "<id>",
"output_format": "mp4",
"trim_start": 0,
"trim_end": 22.5,
"segments": [
{ "start": 0, "end": 5.2 },
{ "start": 8.0, "end": 14.5 }
],
"crop": { "x": 20, "y": 10, "width": 1280, "height": 720 },
"adaptive_plan": null
}Notes:
- If
segmentsis non-empty, it overrides single trim range. - If
adaptive_planis provided, backend applies adaptive crop first, then trim/cut (video only). - Audio ignores
crop/adaptive_plan; images ignore trim/segments.
Poll job:
GET /api/process/jobs/{job_id}
Returns status, progress (0-100), stage, and result on completion.
POST /api/process
Same payload as above. This endpoint blocks until finished.
- Preview:
GET /api/outputs/{output_id}/stream - Download:
GET /api/outputs/{output_id}/download - Upload preview:
GET /api/media/{media_id}/stream(alias:/api/videos/{video_id}/stream) - Audio waveform:
GET /api/media/{media_id}/waveform
const upload = new FormData();
upload.append("file", fileInput.files[0]);
const uploaded = await fetch("/api/upload", { method: "POST", body: upload }).then(r => r.json());
const auto = await fetch(`/api/media/${uploaded.media_id}/autocrop`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "black_static", window_seconds: 8, aspect_ratio: "16:9" })
}).then(r => r.json());
const started = await fetch("/api/process/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
media_id: uploaded.media_id,
output_format: "mp4",
trim_start: 0,
trim_end: 20,
segments: [],
crop: auto.crop,
adaptive_plan: auto.kind === "adaptive" ? auto.adaptive_plan : null
})
}).then(r => r.json());
let final;
while (!final) {
const job = await fetch(`/api/process/jobs/${started.job_id}`).then(r => r.json());
if (job.status === "error") throw new Error(job.error || "process failed");
if (job.status === "completed") final = job.result;
await new Promise(r => setTimeout(r, 500));
}
console.log(final.download_url);import requests
with open("input.mp4", "rb") as f:
uploaded = requests.post("http://127.0.0.1:8000/api/upload", files={"file": f}).json()
auto = requests.post(
f"http://127.0.0.1:8000/api/media/{uploaded['media_id']}/autocrop",
json={"mode": "black_static", "window_seconds": 8, "aspect_ratio": "16:9"},
).json()
payload = {
"media_id": uploaded["media_id"],
"output_format": "mp4",
"trim_start": 0,
"trim_end": 20,
"segments": [],
"crop": auto["crop"],
"adaptive_plan": auto.get("adaptive_plan") if auto.get("kind") == "adaptive" else None,
}
started = requests.post("http://127.0.0.1:8000/api/process/start", json=payload).json()
job_id = started["job_id"]
while True:
job = requests.get(f"http://127.0.0.1:8000/api/process/jobs/{job_id}").json()
if job["status"] == "error":
raise RuntimeError(job.get("error") or "process failed")
if job["status"] == "completed":
result = job["result"]
break
print("Download:", f"http://127.0.0.1:8000{result['download_url']}")# 1) Upload
curl -s -X POST -F "[email protected]" http://127.0.0.1:8000/api/upload
# 2) Auto-crop (replace MEDIA_ID)
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"mode":"black_static","window_seconds":8,"aspect_ratio":"16:9"}' \
http://127.0.0.1:8000/api/media/MEDIA_ID/autocrop
# 3) Start process job (replace MEDIA_ID and crop values)
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{
"media_id":"MEDIA_ID",
"output_format":"mp4",
"trim_start":0,
"trim_end":20,
"segments":[],
"crop":{"x":0,"y":60,"width":1920,"height":960},
"adaptive_plan":null
}' \
http://127.0.0.1:8000/api/process/start
# 4) Poll job (replace JOB_ID)
curl -s http://127.0.0.1:8000/api/process/jobs/JOB_ID