API
Publish comparisons and screenshot galleries from scripts, encoders and bots. Everything the site does, the API does. Base URL https://both.pics; the machine-readable description is /v1/openapi.json (OpenAPI 3.1).
1. Tokens
Create a token under Settings → API tokens and pick its scopes: upload (create, upload, edit), read (list and fetch your collections), delete. The token is shown once. Send it on every request:
Authorization: Bearer bp_…
- Keep it secret: it acts as you. Never put it in a web page, a public repository or a URL. Revoke it in Settings if it leaks.
- Your account needs a confirmed email and approval before tokens can upload.
- Send a descriptive
User-Agent(for examplemy-encoder/1.2 (+contact)). Requests without one may be blocked by the network edge. - New collections are public and appear in Recent uploads unless you send
"visibility": "unlisted". Unlisted uploads are still accessible to anyone with the link.
Without an account: guest tokens
When posting without an account is open, a tool can get a guest token instead. It works for a day, can upload and read its own uploads, and follows the website's guest rules:
curl -X POST https://both.pics/v1/guest -H 'Content-Type: application/json' -d '{"acceptPolicy": true}'
acceptPolicy: truemeans the person accepts the content policy.- Guest uploads are public, are not linked to title pages, and cannot be deleted later.
- A network gets a limited number of guest uploads an hour (website and API together). Over the limit the API answers
429withRetry-Afterand a message pointing to a free account: show it to the person. - While guest tokens are switched off,
/v1/guestand guest uploads answer403with a message for the person: show it too.
2. Quick start: a comparison in one request
POST /v1/collections/upload takes a multipart/form-data body: the fields, then the PNGs in frame-major order (frame 1 of every version, then frame 2, and so on). The answer is 202 with the page url and a statusUrl to poll until status is published.
curl -sS -H "Authorization: Bearer $BOTHPICS_TOKEN" -A "my-script/1.0" \
-F title="Movie (2024) BD Remux vs x264" \
-F versions="Source,x264" \
-F frames="00:05:52,00:18:37" \
-F visibility=public \
-F f=@f1_source.png -F f=@f1_x264.png \
-F f=@f2_source.png -F f=@f2_x264.png \
https://both.pics/v1/collections/upload
{"id": "4f0c…", "key": "a1b2c3d4", "status": "processing", "statusUrl": "https://both.pics/v1/collections/4f0c…", "url": "https://both.pics/c/a1b2c3d4"}
# Poll until published (or failed); needs the read scope.
curl -sS -H "Authorization: Bearer $BOTHPICS_TOKEN" https://both.pics/v1/collections/4f0c… | jq .status
Optional fields: description, frameCount (instead of frames). For tags, a title link or show-on-title, use the steps below or PATCH afterwards.
3. Comparison, step by step
Three calls give full control: create, upload each image, finalize. Creating returns one image slot per frame × version.
Create
curl -sS -H "Authorization: Bearer $BOTHPICS_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: movie-2024-x264-v1" \
-d '{
"title": "Movie (2024) BD Remux vs x264",
"versions": ["BD Remux", "x264 CRF 18"],
"frames": ["00:05:52", "00:18:37"],
"visibility": "public",
"tags": ["BD", "x264"],
"tmdb": "movie:603",
"showOnTitle": true
}' https://both.pics/v1/collections
The response holds collection.id and collection.images: each slot has an id, a frameId and a versionId, matching collection.frames and collection.versions. For a TV episode send "tmdb": "tv:1399", "season": 1, "episode": 3. The Idempotency-Key makes retries safe: the same key returns the same collection.
Upload each image
The simple way sends each PNG through the API:
curl -sS -X PUT -H "Authorization: Bearer $BOTHPICS_TOKEN" -H "Content-Type: image/png" \
--data-binary @f1_source.png https://both.pics/v1/collections/{id}/images/{imageId}
For big batches, presigned uploads go straight to storage. Declare each file's size and SHA-256, then PUT the bytes to each returned url with exactly the returned headers (no token):
curl -sS -H "Authorization: Bearer $BOTHPICS_TOKEN" -H "Content-Type: application/json" \
-d '{"declared": {"<imageId>": {"bytes": 5123456, "sha256": "9f86d0…", "contentType": "image/png"}}}' \
https://both.pics/v1/collections/{id}/uploads
Finalize and poll
curl -sS -X POST -H "Authorization: Bearer $BOTHPICS_TOKEN" https://both.pics/v1/collections/{id}/finalize
Every image is checked (a real, complete PNG within the limits), then the comparison goes live. Poll GET /v1/collections/{id}: progress counts the images checked; status ends as published, or failed with an error on each bad image. Fix those (upload again into the same slot) and finalize again.
The whole thing in Python
import requests, time
API = "https://both.pics"
s = requests.Session()
s.headers.update({"Authorization": "Bearer bp_…", "User-Agent": "my-script/1.0"})
versions = ["Source", "x264"]
frames = {"00:05:52": ["f1_source.png", "f1_x264.png"], "00:18:37": ["f2_source.png", "f2_x264.png"]}
r = s.post(f"{API}/v1/collections", json={
"title": "Movie (2024) BD Remux vs x264", "versions": versions,
"frames": list(frames), "visibility": "public", "tmdb": "movie:603",
})
r.raise_for_status() # a 4xx body says what was wrong: r.json()["message"]
col = r.json()["collection"]
slot = {(i["frameId"], i["versionId"]): i["id"] for i in col["images"]}
for f, files in zip(col["frames"], frames.values()):
for v, path in zip(col["versions"], files):
with open(path, "rb") as fh:
s.put(f"{API}/v1/collections/{col['id']}/images/{slot[f['id'], v['id']]}",
data=fh, headers={"Content-Type": "image/png"}).raise_for_status()
s.post(f"{API}/v1/collections/{col['id']}/finalize").raise_for_status()
while (c := s.get(f"{API}/v1/collections/{col['id']}").json())["status"] == "processing":
time.sleep(1)
print(c["status"], c["url"])
The same in JavaScript (Node 18+)
import { readFile } from 'node:fs/promises';
const API = 'https://both.pics';
const headers = { Authorization: 'Bearer bp_…', 'User-Agent': 'my-script/1.0' };
const call = async (method, path, body, type = 'application/json') => {
const r = await fetch(API + path, { method, headers: { ...headers, 'Content-Type': type }, body });
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
return r.status === 204 ? null : r.json();
};
const files = [['f1_source.png', 'f1_x264.png'], ['f2_source.png', 'f2_x264.png']];
const { collection: col } = await call('POST', '/v1/collections', JSON.stringify({
title: 'Movie (2024) BD Remux vs x264', versions: ['Source', 'x264'], frames: ['00:05:52', '00:18:37'],
}));
for (const [fi, f] of col.frames.entries())
for (const [vi, v] of col.versions.entries()) {
const img = col.images.find((i) => i.frameId === f.id && i.versionId === v.id);
await call('PUT', `/v1/collections/${col.id}/images/${img.id}`, await readFile(files[fi][vi]), 'image/png');
}
await call('POST', `/v1/collections/${col.id}/finalize`);
let c;
do { await new Promise((r) => setTimeout(r, 1000)); c = await call('GET', `/v1/collections/${col.id}`); } while (c.status === 'processing');
console.log(c.status, c.url);
4. Screenshot galleries
A gallery is one image per frame, no versions. PNG, JPEG and WebP are accepted; location and camera metadata (EXIF, XMP, comments) are removed, pixels are kept. The page is /s/{key} and each image has its own page /s/{key}/{n}.
curl -sS -H "Authorization: Bearer $BOTHPICS_TOKEN" -A "my-script/1.0" \
-F kind=screenshots -F title="Show S01E01 screenshots" -F visibility=public \
-F frameLabels="Opening,Night scene,Credits" \
-F [email protected] -F [email protected] -F [email protected] \
https://both.pics/v1/collections/upload
Or create it as JSON with "kind": "screenshots" and "frameLabels": [...] (up to 500), then upload with Content-Type image/png, image/jpeg or image/webp and finalize as above. Once published, each image in GET /v1/collections/{id} has url (the original); thumbUrl (480 px) and thumb240Url appear a few seconds later, so poll until thumb240Url is set before building BBCode. Forum BBCode for image n:
[url=https://both.pics/s/{key}/{n}][img]{thumb240Url}[/img][/url]
The Python client's shots --json prints these links and the BBCode for you.
5. Video comparisons
A video comparison plays up to 6 versions of the same stretch (at most 15 s) in sync, frame by frame. Create it with "kind": "video" and 2 to 6 versions (no frames), then upload one clip per version: MP4, MKV, WebM or MOV, with H.264, HEVC, AV1 or VP9 video, all at the same constant frame rate. The video data is stored unchanged (the container becomes MP4); the source's sound is kept, the encodes' is not. A clip is at most 15 seconds from its start and 250.0 MiB; a cut made with stream copy may begin up to 15 s earlier, at the keyframe before the start.
Cut clips without re-encoding: start at a keyframe at or before the frame you want and keep the original timestamps, then send that frame's time as the clip's start. The server starts the window at the first frame at or after it, in every version. Without a start, the window starts at each clip's first frame.
# The clip from 51.4 s for 10 s. K is the keyframe at or before 51.4 s (ffprobe -show_packets).
ffmpeg -ss K -t 12 -i source.mkv -map 0:v:0 -map 0:a:0 -c copy -copyts -f matroska src.mkv
ffmpeg -ss K2 -t 12 -i encode.mkv -map 0:v:0 -c copy -copyts -f matroska enc.mkv
curl -sS -H "Authorization: Bearer $BOTHPICS_TOKEN" -H "Content-Type: application/json" \
-d '{"kind": "video", "title": "BD vs x265", "versions": ["BD", "x265"], "clipSeconds": 10}' \
https://both.pics/v1/collections
# Each clip (image ids from the answer) with its start; then finalize and poll as above
curl -sS -X PUT -H "Authorization: Bearer $BOTHPICS_TOKEN" -H "Content-Type: video/x-matroska" \
-H "X-Clip-Start: 51.4" --data-binary @src.mkv https://both.pics/v1/collections/{id}/images/{imageId}
Large clips are better sent to the presigned URLs. Declare each clip in POST /v1/collections/{id}/uploads as {"bytes", "sha256", "contentType": "video/x-matroska", "startSeconds": 51.4}. The Python client does all of this: bothpics.py video --start 0:51.4 --seconds 10 source.mkv encode.mkv.
Clips play on the site and cannot be downloaded: a video comparison has no url per version and no ZIP. Once published, each version has video (codec, frame rate, colour), clipStart, alignOffset and frameUrl, which gives window frame {n} as a PNG decoded on the server. If a version starts a frame off, move it with PATCH {"alignOffsets": {"{imageId}": 1}}.
6. After publishing
PATCH /v1/collections/{id}changestitle,description,visibility,tags,tmdb/season/episode,showOnTitle, and the labels and order ofversionsandframes(send every one, in the new order, as{"id", "label"}).POST /v1/collections/{id}/frameswith{"frames": ["01:02:03"]}adds frames. They stay hidden until their images are uploaded andfinalizepasses; poll until no frame has"state": "processing". Then"live"means shown, and"pending"withfailureset means rejected (see the per-imageerror).DELETE /v1/collections/{id}/frames/{frameId}removes a frame (scopedelete; a published collection keeps at least one).DELETE /v1/collections/{id}deletes the collection (scopedelete).GET /v1/collectionslists yours;GET /v1/meshows the account and storage used.GET /v1/collections/{key}without a token returns the public view of a published collection.
curl -sS -X PATCH -H "Authorization: Bearer $BOTHPICS_TOKEN" -H "Content-Type: application/json" \
-d '{"visibility": "public", "tags": ["BD", "x264", "anime"]}' https://both.pics/v1/collections/{id}
7. Python client
Download bothpics.py One file, standard library only, Python 3. It works as a command and as a library.
Uploads are public by default. Add --unlisted to keep an upload out of public listings; anyone with its link can still view it. --public remains supported.
curl -O https://both.pics/clients/bothpics.py
export BOTHPICS_TOKEN=bp_…
# Comparison: files frame by frame (every version of frame 1, then frame 2 …)
python3 bothpics.py upload --title "x264 vs x265" --versions x264,x265 --public \
--tmdb movie:603 --tags x264,x265 f1_a.png f1_b.png f2_a.png f2_b.png
# Screenshot gallery, printing direct, thumbnail and BBCode links as JSON
python3 bothpics.py shots --title "Show S01E01" --public --json 01.png 02.png 03.png
# Extract the same frames from local videos with your FFmpeg, then publish
python3 bothpics.py easy --title "Show S01E01" source.mkv encode.mkv --count 8
# Video comparison: the same 10 s copied out of each video (no re-encode); :N shifts a video by N frames
python3 bothpics.py video --title "BD vs x265" --start 0:51.4 --seconds 10 source.mkv encode.mkv:-2
python3 bothpics.py status <collection-id>
python3 bothpics.py delete <collection-id>
from bothpics import Client
c = Client("https://both.pics", "bp_…")
url = c.upload("x264 vs x265", ["x264", "x265"],
[["f1_a.png", "f1_b.png"], ["f2_a.png", "f2_b.png"]],
visibility="public", tmdb="movie:603", tags=["x264"])
print(url)
8. Limits and errors
| Limit | Value |
|---|---|
| Image size | 64.0 MiB, 67 megapixels (e.g. 8192 × 8192) |
| Versions per comparison | 2 to 16 |
| Frames per comparison | 100 |
| Images per gallery | 500 |
| Video clip | 250.0 MiB, 15 s from its start; 2 to 6 versions |
| One-shot request body | 256.0 MiB |
| Title / description / tags | 120 / 2000 chars / 12 × 40 |
| Requests | 120 per minute |
| Presigned URL lifetime | 1 hour |
Comparisons take PNG only (the bytes decide, not the name); animated images are refused. Your storage quota and daily upload limit are in GET /v1/me.
Errors are JSON with a stable code and a readable message:
{"error": "insufficient_scope", "message": "This token lacks the \"upload\" scope."}
| Status | Meaning |
|---|---|
| 400 | Invalid input (the message says which field) |
| 401 | Missing, invalid, expired or revoked token |
| 403 | Missing scope, unconfirmed or unapproved account, suspended account or blocked network |
| 404 | Not found, or not yours |
| 409 | Busy (being checked), held or blocked |
| 413 / 415 | Too large / image type not accepted |
| 429 | Too many requests: wait for Retry-After seconds, then retry |
| 5xx | Our fault: retry with backoff (creation is safe to retry with an Idempotency-Key) |