{"openapi":"3.1.0","info":{"title":"both.pics API","version":"1.0.0","description":"Create image comparisons and screenshot galleries from scripts. Flow: create a collection (returns presigned upload URLs) → PUT each image → finalize → poll until published. A collection has a kind: \"comparison\" (the default; 2+ versions × frames, PNG only; page /c/{key}) or \"screenshots\" (one version, \"Screenshots\", × one frame per image; PNG, JPEG or WebP; embedded metadata (EXIF including GPS, XMP, text comments) is removed losslessly: pixels, colour profile and EXIF orientation are kept; page /s/{key}) or \"video\" (2 to 6 versions, one clip each: MP4, MKV, WebM or MOV with H.264, HEVC, AV1 or VP9 at one constant frame rate; the compared window is at most 15 s; the video data is stored unchanged in MP4 and the sound of the source is kept; clips play on the site and cannot be downloaded (no url); page /c/{key}). Comparison images are stored byte for byte. Tokens are created in Settings → API tokens. Guide with examples: https://both.pics/docs/api"},"servers":[{"url":"https://both.pics"}],"security":[{"bearer":[]}],"components":{"securitySchemes":{"bearer":{"type":"http","scheme":"bearer","description":"Account API token (bp_…) with scopes upload/read/delete."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error"],"example":{"error":"insufficient_scope","message":"This token lacks the \"upload\" scope."}},"Upload":{"type":"object","properties":{"imageId":{"type":"string","format":"uuid"},"frameId":{"type":"string","format":"uuid"},"versionId":{"type":"string","format":"uuid"},"method":{"const":"PUT"},"url":{"type":"string","description":"Presigned URL to private staging storage (expires in 1 hour)."},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Send exactly these headers with the PUT: Content-Type is the declared contentType (image/png for comparisons) and is part of the signature, as is Content-Length."},"expiresAt":{"type":"string","format":"date-time"}}},"Image":{"type":"object","properties":{"id":{"type":"string"},"frameId":{"type":"string"},"versionId":{"type":"string"},"state":{"enum":["pending","uploaded","valid","invalid"],"description":"Owner view only."},"error":{"type":["string","null"]},"sha256":{"type":["string","null"]},"bytes":{"type":["integer","null"]},"width":{"type":["integer","null"]},"height":{"type":["integer","null"],"description":"Stored (unrotated) pixel size; JPEG/WebP EXIF orientation is not applied."},"mime":{"enum":["image/png","image/jpeg","image/webp","video/mp4"],"description":"Type of the stored original (validated images only; video clips are stored as MP4)."},"url":{"type":"string","description":"Original (published images only): comparisons byte for byte as uploaded; screenshots with embedded metadata removed (pixels unchanged). Served with its own Content-Type; the download name ends in .png, .jpg or .webp."},"thumbUrl":{"type":"string","description":"480 px WebP thumbnail (EXIF orientation applied), once made (screenshots: at publish); published images only. Add &w=240 or &w=960 for other widths."},"thumb240Url":{"type":"string","description":"Versioned 240 px WebP thumbnail, once made (shortly after publish)."},"video":{"type":"object","description":"Video: the stored clip (codec, profile, bitDepth, pixFmt, fps \"num/den\", frames, colour tags, bitrate)."},"clipStart":{"type":"integer","description":"Video: the clip frame (presentation order) where the compared window starts."},"alignOffset":{"type":"integer","description":"Video: frames this version is moved by (PATCH alignOffsets); window frame F is clip frame clipStart + alignOffset + F."},"frameUrl":{"type":"string","description":"Video: window frame {n} (0-based) as a PNG decoded on the server (exact RGB, BT.601/709 as tagged)."}}},"Collection":{"type":"object","properties":{"id":{"type":"string","description":"Owner view only."},"kind":{"enum":["comparison","screenshots","video"]},"clip":{"type":"object","description":"Video: the compared window, the same for every version.","properties":{"fps":{"type":"string","example":"24000/1001"},"frames":{"type":"integer"},"seconds":{"type":"number"}}},"key":{"type":"string","description":"Share key; the page is /c/{key} (comparison) or /s/{key} (screenshots); the other path redirects there."},"url":{"type":"string","description":"Public page: {origin}/c/{key} for comparisons, {origin}/s/{key} for screenshots."},"title":{"type":"string"},"description":{"type":"string"},"visibility":{"enum":["unlisted","public"]},"status":{"enum":["draft","processing","published","failed","deleted"]},"moderation":{"enum":["ok","blocked","removed"]},"failure":{"type":["string","null"]},"progress":{"type":"object","description":"Owner view only. Publish progress: while status is processing (or new frames are being checked), checked counts the images validated so far out of total. Reset to 0 by each finalize; total is null before the first finalize.","properties":{"checked":{"type":"integer","minimum":0},"total":{"type":["integer","null"],"minimum":0}},"required":["checked","total"]},"tags":{"type":"array","items":{"type":"string"}},"titleRef":{"type":["string","null"],"description":"Linked TMDB title, \"movie:<id>\" or \"tv:<id>\"; its page is /t/movie/<id> or /t/tv/<id>."},"season":{"type":["integer","null"]},"episode":{"type":["integer","null"]},"showOnTitle":{"type":"boolean","description":"Owner view only. Listed on the title page (public comparisons only)."},"versions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"}}}},"frames":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"state":{"enum":["live","pending","processing"],"description":"Owner view only. Frames added to a published collection are pending (hidden) until their images pass finalize."}}}},"images":{"type":"array","items":{"$ref":"#/components/schemas/Image"}}}},"Declared":{"type":"object","description":"Per-image declarations, keyed by imageId; an upload URL is issued for each slot with a declared size. Declared sizes and content types are signed into the upload URL; declared hashes are checked at finalize, and the file content must be of the declared type (magic bytes decide, never the name).","additionalProperties":{"type":"object","properties":{"bytes":{"type":"integer"},"sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"},"contentType":{"enum":["image/png","image/jpeg","image/webp","video/mp4","video/x-matroska","video/webm","video/quicktime"],"description":"Type of this file. Required for screenshots slots (PNG, JPEG or WebP); comparisons accept image/png only and default to it; video clips default to video/mp4."},"startSeconds":{"type":"number","minimum":0,"description":"Video only: where the compared window starts, in seconds of this clip's own timeline (its first frame at or after it, half a frame's tolerance). Default: the clip's first frame."}}}}}},"paths":{"/v1/me":{"get":{"summary":"Token owner and storage usage (scope: read)","responses":{"200":{"description":"OK"},"401":{"description":"No/invalid token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections":{"get":{"summary":"List your collections (scope: read)","responses":{"200":{"description":"OK"}}},"post":{"summary":"Create a collection (scope: upload). Upload URLs are issued for slots declared here or via /uploads.","parameters":[{"name":"Idempotency-Key","in":"header","schema":{"type":"string"},"description":"Repeat-safe creation: the same key returns the same collection."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title"],"properties":{"kind":{"enum":["comparison","screenshots","video"],"default":"comparison","description":"screenshots: send frameLabels (or frameCount) and no versions. video: 2 to 6 versions and no frames (one clip per version)."},"clipSeconds":{"type":"number","minimum":0.1,"maximum":15,"description":"Video only: the compared length. Default: the shortest clip from its start, at most 15 s."},"title":{"type":"string","maxLength":120},"description":{"type":"string","maxLength":2000},"visibility":{"enum":["unlisted","public"],"default":"public"},"versions":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":16,"description":"Comparisons only (required there)."},"frames":{"type":"array","items":{"type":"string"},"maxItems":100},"frameLabels":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":500,"description":"Screenshots only: one label per image, kept as typed (control characters removed, at most 60 characters; \"Ep 1/2 before\" stays as it is). Editable later like frame labels. An empty label falls back to frameFiles, then \"Image N\"."},"frameFiles":{"type":"array","items":{"type":"string"},"maxItems":500,"description":"Screenshots only: the original file names, one per image; used as the label where frameLabels gives none, without a directory part and an image extension (.png, .jpg, .jpeg, .webp)."},"frameCount":{"type":"integer","minimum":1,"maximum":500,"description":"Number of frames (comparisons: at most 100) or images (screenshots: at most 500) when no labels are given."},"tags":{"type":"array","items":{"type":"string","maxLength":40},"maxItems":12},"tmdb":{"type":["string","null"],"description":"Link a TMDB title: \"movie:<id>\" or \"tv:<id>\" (null unlinks). Find ids with the site search or on themoviedb.org. titleRef (as returned) is accepted as an alias."},"season":{"type":["integer","null"],"minimum":0,"maximum":9999,"description":"TV only. On PATCH without tmdb, changes the current TV link; re-sending the same tmdb keeps the stored value unless given."},"episode":{"type":["integer","null"],"minimum":0,"maximum":99999,"description":"TV only (same rules as season)."},"showOnTitle":{"type":"boolean","description":"List on the title page (public comparisons only); default: on when public."},"declared":{"$ref":"#/components/schemas/Declared"}},"example":{"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}}}}},"responses":{"201":{"description":"Created. collection.url is the page (/s/{key} for screenshots); uploads lists presigned PUTs for the slots declared here.","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"$ref":"#/components/schemas/Collection"},"uploads":{"type":"array","items":{"$ref":"#/components/schemas/Upload"}}}}}}},"400":{"description":"Invalid input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Unverified email, suspended account or missing scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/upload":{"post":{"summary":"One-shot multipart upload: create, store and finalize (scope: upload)","description":"multipart/form-data with fields title, versions (comma separated), optional frames (comma separated) or frameCount, description, visibility; then PNG files in frame-major order (frame 1 versions 1..n, frame 2 …). For screenshots: kind=screenshots, frameLabels or frameFiles (each as one field per label, repeated in order; or one field holding a JSON array of strings; or, for labels without commas, one comma-separated field) or frameCount, then one PNG, JPEG or WebP file per image (a part's Content-Type image/png, image/jpeg or image/webp must match its content; other types leave it to the content). Bounded by MAX_MULTIPART_BYTES. The response url is the page (/c/ or /s/).","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object"}}}},"responses":{"202":{"description":"Accepted; poll statusUrl","content":{"application/json":{"schema":{"type":"object","example":{"id":"4f0c9a52-…","key":"a1b2c3d4","status":"processing","statusUrl":"https://both.pics/v1/collections/4f0c9a52-…","url":"https://both.pics/c/a1b2c3d4"}}}}},"413":{"description":"Too large","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}/uploads":{"post":{"summary":"Presigned upload URLs for the declared slots (scope: upload). Each declared size is signed into its URL; slots without a declared size get none.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"declared":{"$ref":"#/components/schemas/Declared"}}}}}},"responses":{"200":{"description":"OK"}}}},"/v1/collections/{id}/images/{imageId}":{"put":{"summary":"Upload one image through the API server instead of the presigned URL (scope: upload). Content-Type image/png, image/jpeg or image/webp declares the type (screenshots take all three, comparisons PNG only; a type the kind does not take, or GIF, BMP, TIFF, SVG, AVIF, HEIC and the like, gets 415); application/octet-stream, other image/* aliases (image/x-png, image/jpg, …) or none leave it to the content.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"imageId","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Clip-Start","in":"header","schema":{"type":"number"},"description":"Video only: startSeconds for this clip (see Declared)."}],"requestBody":{"required":true,"content":{"video/mp4":{"schema":{"type":"string","format":"binary"}},"video/x-matroska":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}},"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/webp":{"schema":{"type":"string","format":"binary"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"Stored in staging"},"413":{"description":"Too large","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"Image type not accepted for this kind","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}/frames":{"post":{"summary":"Add frames (screenshots: images; scope: upload). In a published collection they stay hidden until their images are uploaded and finalize passes.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"frames":{"type":"array","items":{"type":"string"}},"count":{"type":"integer","minimum":1,"maximum":100}}}}}},"responses":{"201":{"description":"Created; returns the collection with the new slots","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Collection"}}}},"409":{"description":"Blocked, or frames are being checked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}/frames/{frameId}":{"delete":{"summary":"Delete a frame and its images (scope: delete). A published collection keeps at least one visible frame.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"frameId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted; returns the collection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Collection"}}}},"409":{"description":"Last frame, held, blocked or being checked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}/finalize":{"post":{"summary":"Validate every image and publish (idempotent; scope: upload). On a published collection it validates and shows the frames added since.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"202":{"description":"Processing; poll GET /v1/collections/{id}"}}}},"/v1/collections/{ref}":{"get":{"summary":"Collection by id (owner view; scope: read) or by share key (public view, no token)","security":[{"bearer":[]},{}],"parameters":[{"name":"ref","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Collection"}}}},"404":{"description":"Not found"},"410":{"description":"Deleted"},"451":{"description":"Removed or blocked after a report"}}},"patch":{"summary":"Edit title, description, visibility, tags, and the labels and order of versions and frames (scope: upload)","parameters":[{"name":"ref","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","maxLength":120},"description":{"type":"string","maxLength":2000},"visibility":{"enum":["unlisted","public"]},"tags":{"oneOf":[{"type":"array","items":{"type":"string","maxLength":40},"maxItems":12},{"type":"string","description":"Comma separated"}]},"tmdb":{"type":["string","null"],"description":"Link a TMDB title: \"movie:<id>\" or \"tv:<id>\" (null unlinks). Find ids with the site search or on themoviedb.org. titleRef (as returned) is accepted as an alias."},"season":{"type":["integer","null"],"minimum":0,"maximum":9999,"description":"TV only. On PATCH without tmdb, changes the current TV link; re-sending the same tmdb keeps the stored value unless given."},"episode":{"type":["integer","null"],"minimum":0,"maximum":99999,"description":"TV only (same rules as season)."},"showOnTitle":{"type":"boolean","description":"List on the title page (public comparisons only); default: on when public."},"versions":{"type":"array","description":"Every version exactly once, in the new order","items":{"type":"object","required":["id","label"],"properties":{"id":{"type":"string"},"label":{"type":"string","maxLength":60}}}},"frames":{"type":"array","description":"Every frame exactly once, in the new order","items":{"type":"object","required":["id","label"],"properties":{"id":{"type":"string"},"label":{"type":"string","maxLength":60}}}},"alignOffsets":{"type":"object","additionalProperties":{"type":"integer"},"description":"Video only: {imageId: frames} moves a version against the others by whole frames (+1 shows its next frame); the clip must have the frames."}}}}}},"responses":{"200":{"description":"Saved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Collection"}}}},"400":{"description":"Invalid input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Blocked or being checked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Delete a collection (scope: delete)","parameters":[{"name":"ref","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted"}}}},"/v1/guest":{"post":{"summary":"Get a guest token (no account): upload and read for a day, when posting without an account is open","security":[{}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["acceptPolicy"],"properties":{"acceptPolicy":{"const":true,"description":"The person accepts the content policy (/policy)"}}}}}},"responses":{"201":{"description":"token (Bearer), expiresAt, scopes, uploadsPerHour; uploads are public"},"403":{"description":"Posting without an account is closed, or the address is blocked"}}}},"/v1/reports":{"post":{"summary":"Report a comparison (no token needed)","security":[{}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["category","target"],"properties":{"category":{"enum":["copyright","child_safety","abuse","other"]},"target":{"type":"string","description":"Page URL (/c/{key} or /s/{key}), image URL or share key"},"explanation":{"type":"string"},"contactName":{"type":"string"},"contactEmail":{"type":"string"},"dmca":{"type":"object","description":"Copyright notice elements (signature, work, material, contact_*, good_faith=\"yes\", accuracy=\"yes\")."}}}}}},"responses":{"201":{"description":"Received; returns an opaque receipt"},"429":{"description":"Rate limited"}}}}}}