both.pics
Sign inCreate account

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_…

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}'

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

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

LimitValue
Image size64.0 MiB, 67 megapixels (e.g. 8192 × 8192)
Versions per comparison2 to 16
Frames per comparison100
Images per gallery500
Video clip250.0 MiB, 15 s from its start; 2 to 6 versions
One-shot request body256.0 MiB
Title / description / tags120 / 2000 chars / 12 × 40
Requests120 per minute
Presigned URL lifetime1 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."}
StatusMeaning
400Invalid input (the message says which field)
401Missing, invalid, expired or revoked token
403Missing scope, unconfirmed or unapproved account, suspended account or blocked network
404Not found, or not yours
409Busy (being checked), held or blocked
413 / 415Too large / image type not accepted
429Too many requests: wait for Retry-After seconds, then retry
5xxOur fault: retry with backoff (creation is safe to retry with an Idempotency-Key)