API reference · v1
Fingerprint-first copy detection over HTTP. Every endpoint that takes media prefers a fingerprint computed where the media lives, so uploading is always optional. The story of how the system works, and a two-minute walkthrough, live on the landing page; this page is the contract. Every response on it is captured server output.
http://127.0.0.1:5002, endpoints under /v1. Swap in your own host for a deployed instance. Opening /v1 in a browser lands on this page..cdfp bytes (Content-Type: application/x-cdfp), a multipart file field, or JSON {"url": …} for the server to fetch.GET /v1/descriptor names this instance's descriptor space, e.g. {"model": "sscd/mixup", "dim": 512, "input_px": 224, "regions": 3}. A fingerprint from another space is refused with a 409, never scored — cosine gives no warning when it is wrong.Authorization: Bearer cdnn_… (a personal token minted on your account page, shown once, revocable there) acts as you: your own library, your quota, your hourly limits. A request with no credentials lands in the shared guest sandbox with its pooled budgets — the same one the webpage offers visitors, where the oldest uploads are evicted when its disk cap fills. A presented token that is wrong is a 401, never a silent downgrade to the pool. GET /v1/me reports who you are and what you may spend; a spent hourly budget is a 429 with Retry-After, a full quota a 507. An instance started with --no-auth is open.curl works everywhere below, and the copydnn CLI speaks this API with the same verbs: copydnn --api http://host add|query|compare|list|rm.Everything below assumes a running instance. One machine runs the engine
and owns the library; clients talk to it with curl
or the copydnn CLI, which ships with the
engine and speaks this API when pointed at a host. From nothing to a first
verdict is about two minutes plus the model download.
python3 -m venv .venv .venv/bin/pip install -e ".[web]" .venv/bin/python scripts/fetch_models.py # SSCD weights, ~99 MB, once .venv/bin/copydnn-web # UI + this API on http://127.0.0.1:5002 # optional, for libraries past a few hundred thousand rows: # python3 -m venv .venv-faiss && .venv-faiss/bin/pip install faiss-cpu numpy
export COPYDNN_API=http://127.0.0.1:5002 copydnn fingerprint film.mp4 # a .cdfp beside it — kilobytes, media never moves copydnn insert film.mp4.cdfp # video:1 film.mp4 ready copydnn query suspect.mp4 STRONG film.mp4 best 0.921 query 11.2-38.8 -> reference 31.3-54.7 rate 1.000x
That is the whole loop: fingerprint where the media lives, insert the kilobytes, query anything suspicious. Everything the CLI just did is plain HTTP, documented endpoint by endpoint below.
/v1/references
References are the things you want to protect, your library. Insert them once and every future query is checked against them. There are three ways to hand us a reference, and the endpoint is the same for all of them.
Your own tags can ride along: a metadata
field holding a JSON object of string keys (rights holder, case id, ground
truth). They are stored inside the fingerprint, come back on the reference
detail, and are editable later with
PATCH /v1/references/<id>/metadata.
Body is the .cdfp file. Private, kilobytes to megabytes,
and indexed before the request returns. Its manifest names the source;
add ?name= to override.
Body is {"url": …}, typically a pre-signed storage link.
We fetch it, fingerprint it, and keep the media alongside for evidence views.
Plain multipart upload, a file field, for when the other
two are not an option.
curl -X POST http://127.0.0.1:5002/v1/references \ -H "Content-Type: application/x-cdfp" \ --data-binary @film.mp4.cdfp # or by link: -H "Content-Type: application/json" \ # -d '{"url": "https://storage…/film.mp4?sig=…"}' # or upload: -F "file=@film.mp4"
{
"id": 1,
"kind": "video",
"name": "bunny.mp4",
"duration": 596.46, // seconds
"frames": 1012, // distinct moments we kept
"fingerprint_mb": 3.11,
"status": "ready" // searchable right now
}
Every insert is checked against the library first. If what you hand us
is content it already holds (the same file, a re-encode, a mirrored copy
under a new name), the insert is refused with 409 and the
response names the reference it duplicates. Pass ?dedupe=false
to insert it anyway.
That's the whole ceremony. A ten-minute film becomes a thousand stored moments,
searchable immediately. Remove one with
DELETE/v1/references/1,
list them all with
GET/v1/references,
inspect one with
GET/v1/references/1.
/v1/queries
Hand us a suspect clip, or its fingerprint, and ask the only question this system exists to answer. Is this a copy of something in my library, and of which seconds, exactly?
The answer is not a yes or a no. It is a mapping between two timelines, with the evidence attached. Below, a 14-second mirrored excerpt is queried against the library that holds its source.
curl -X POST http://127.0.0.1:5002/v1/queries \ -H "Content-Type: application/x-cdfp" \ --data-binary @clip.mp4.cdfp # media works too, with a sampling knob: # -F "file=@clip.mp4" -F "fps=2"
{
"qid": "42f9616d76c6",
"url": "/queries/42f9616d76c6",
"verdict": "strong",
"frames": 20,
"duration": 13.67,
"timings": { "stage": "fingerprint", "total": 0.0826 },
"matches": [{
"reference": 1,
"name": "bunny.mp4",
"verdict": "strong",
"score": 0.9448,
"runs": [{
"query": [0.0, 13.67],
"reference": [59.0, 73.67],
"rate": 1.0367,
"offset": 59.66,
"frames": 20,
"mean_score": 0.9156
}],
"repetition": 0.0
}]
}
Read the run aloud and it is a plain sentence. Seconds 0 to 13.7 of your clip
are seconds 59 to 73.7 of bunny.mp4, supported by
twenty matched frames. The clip was flipped left-to-right and the query did not
blink.
.cdfp query, whose descriptors already exist. timings.views is the exact descriptors-per-frame this query paid for.GET /v1/queries/42f9616d76c6 re-opens it any time. Add ?evidence=full for the raw similarity surfaces and per-frame pairs behind the verdict.?evidence=full when you are drawing your own views instead.The timelines move together. Many frames agree on the same line through time. This is a copy.
Some frames look right but the timeline evidence is thin. Worth a human glance, not a conclusion.
Nothing in your library explains this clip. Lookalikes score here too, which is the point.
/v1/streams
Everything above answers a question about a file that has finished. A broadcast has not finished, and the question people actually ask of one is is my content on air right now — which has to be answered before it ends.
A stream is not a second kind of search. It is this one, run on a window
that keeps moving. Frames arrive, are embedded, and are appended to an
accumulator that is a fingerprint at every moment. That is
the whole design, and everything else follows from it: the live search is
a .cdfp query over the last thirty
seconds, closing the stream needs no recomputation because the descriptors
the search was reading are already the artifact, and a stream you closed as
a query can be promoted into the library later without decoding a frame
twice.
You decode where the stream lands and send sampled frames as MJPEG.
One ffmpeg pipe, no protocol for us to guess, and the
traffic is the frames we look at rather than the whole broadcast.
You embed as well, and send .cdfp batches. The same
promise the fingerprint format makes for files, kept for a live source:
the pixels never cross the wire.
Give us a source URL at open and this instance decodes it
itself — HLS, RTMP, RTSP, UDP, plain HTTP. The only shape that needs
the engine to reach your stream.
# 1. open a session curl -X POST http://127.0.0.1:5002/v1/streams \ -H "Content-Type: application/json" \ -d '{"mode":"query","name":"channel-4","fps":1,"record":true}' # -> {"id": "2e3ab1072bd9", "state": "live", ...} # 2. feed it, forever if you like ffmpeg -i "https://…/live.m3u8" -vf fps=1 \ -f image2pipe -c:v mjpeg - \ | curl -X POST --data-binary @- \ -H "Content-Type: image/jpeg" \ http://127.0.0.1:5002/v1/streams/2e3ab1072bd9/frames # 3. end it. this is where it becomes permanent curl -X POST http://127.0.0.1:5002/v1/streams/2e3ab1072bd9/close # or all three at once, which is what the CLI is: copydnn --api http://127.0.0.1:5002 stream "https://…/live.m3u8"
{
"id": "2e3ab1072bd9",
"state": "live",
"duration": 13.0, // seconds of stream so far
"frames": 14,
"searches": 1,
"airings": 1, // matches on air right now
"events": [{
"seq": 2,
"type": "hit",
"t": 13.0,
"hit": {
"n": 1,
"reference": 1,
"name": "demo_original.mp4",
"verdict": "strong",
"score": 0.9927,
"started": 0.0,
"ended": 13.0,
"reference_span": [0.0, 13.0],
"rate": 0.9648,
"windows": 1,
"open": true // still on air
}
}]
}
One row per appearance, not one per search. A
thirty-second window searched every five seconds sees the same match six
times over. What a monitor wants is a row that grows, so consecutive
windows landing on the same stretch of the same reference are folded into
one airing and extended in place. It stays
open while the content is on, and closing
it is the event worth alerting on.
Airings are keyed on where the frames landed in the reference rather
than on the fitted rate or
offset, because a line fitted over one
window is fitted over few frames and wobbles. The same thirteen-second clip
produced consecutive windows at rates 1.033 and 0.681; keyed on rate that
arrives as two airings of one continuous match. The rate an airing reports
is taken from its best-supported window, which is why it converges as the
match runs.
curl -N http://127.0.0.1:5002/v1/streams/2e3ab1072bd9/events id: 2 event: hit data: {"seq": 2, "type": "hit", "t": 13.0, "hit": {…}} id: 3 event: update data: {"seq": 3, "type": "update", "t": 18.0, "hit": {…}} id: 9 event: end data: {"seq": 9, "type": "end", "t": 96.0, "hit": {…}} # reconnect with Last-Event-ID (or ?since=) and the # backlog is replayed rather than lost.
curl -X POST http://127.0.0.1:5002/v1/streams/2e3ab1072bd9/close { "state": "closed", "duration": 13.0, "frames": 14, "closed_as": { "kind": "query", "query": "86152e75afa4", "result": "5c7c9dfc6b26", "media": "media.mp4", "verdict": "strong", "url": "/queries/86152e75afa4" }, "hits": [{ "n": 1, "open": false, … }] }
GET /v1/streams/{id}/fingerprint gives a searchable .cdfp for the first hour of a broadcast that has four to go, and a process that dies leaves what it had.Both modes embed at the reference region layout, three
concentric crops, which is what makes the accumulator ingestible. It is the
same trade a stored .cdfp query already
makes: less sensitive to a heavy crop or a picture-in-picture than a
dropped clip searched with the region capabilities on, which can pay for
fourteen windows and their mirrors per frame. A stream monitors
continuously, so it buys the cheap version continuously. Watch the whole
broadcast this way, then re-query the interesting minutes as a file with
crops and insets enabled.
/v1/compare
Sometimes there is no library, just two files and one question. Compare takes two fingerprints (or media files), answers in the same verdict-and-runs shape, and stores nothing.
curl -X POST http://127.0.0.1:5002/v1/compare \ -F "a=@clip.mp4.cdfp" -F "b=@film.mp4.cdfp" { "verdict": "strong", "score": 0.9446, "runs": [ … ] }
When something goes wrong
Similarity math fails quietly. Two incompatible fingerprints would still produce a number, just a meaningless one. So this API refuses loudly instead, and every refusal says what happened and what to do about it. All three bodies below are verbatim server output.
{
"error": "cannot ingest clip.mp4.cdfp: descriptor width
differs: fingerprint has 256, library uses 512",
"hint": "re-run the fingerprinter with a client
matching GET /v1/descriptor"
}
{
"error": "request body is not a .cdfp fingerprint
(bad container)"
}
{
"error": "duplicate of video:1 bunny.mp4 (covers 100% of it)",
"duplicate": { "ref": 1, "name": "bunny.mp4", "kind": "video",
"score": 0.998, "coverage": 1.0 },
"hint": "POST again with ?dedupe=false to insert anyway"
}
{
"error": "missing or unknown API token",
"hint": "create one on your account page and send
'Authorization: Bearer <token>'"
}
# also sends Retry-After: 840 { "error": "hourly query limit reached: 500000 of 500000 embeddings used", "used": 500000, "limit": 500000, "resets_in": 840, "hint": "retry after the reset, or ask an admin to raise the limit" }
# scope is "user" (your quota) or "instance" (the whole # data folder). Hourly budgets reset; storage does not -- # something has to be deleted, or the quota raised. { "error": "your storage quota is full: 9.9 GB of 10.0 GB used", "scope": "user", "used": 9902341120, "limit": 10000000000, "hint": "delete references, queries or streams you no longer need, or ask an admin to raise your quota" }
The whole surface
Everything above, in one glance. If an endpoint is not on this list, it does not exist yet.
| Method | Path | What it does | |
|---|---|---|---|
| GET | /v1/me | Who this token is: identity, hourly limits, and this hour's usage. The self-report a script wants before a long run. | |
| GET | /v1/descriptor | The server's fingerprint dialect, so clients can check compatibility first. | |
Response
curl http://127.0.0.1:5002/v1/descriptor { "service": "copyd-nn", "api": "v1", "descriptor": { "model": "sscd/mixup", "input_px": 224, "dim": 512, "regions": 3 } } | |||
| POST | /v1/references | Insert into your library. Accepts a fingerprint body, a link, or a file. | |
Request
Response · 201
Errors
curl -X POST -H "Content-Type: application/x-cdfp" \ --data-binary @film.mp4.cdfp \ http://127.0.0.1:5002/v1/references // 201 { "id": 1, "kind": "video", "name": "bunny.mp4", "duration": 596.46, "frames": 1012, "fingerprint_mb": 3.11, "status": "ready" } // 409, a foreign fingerprint { "error": "cannot ingest clip.mp4.cdfp: descriptor width differs: fingerprint has 256, library uses 512", "hint": "re-run the fingerprinter with a client matching GET /v1/descriptor" } | |||
| GET | /v1/references | List your library. | |
Response
curl http://127.0.0.1:5002/v1/references { "count": 2, "references": [ { "id": 1, "kind": "video", "name": "bunny.mp4", "duration": 596.46, "frames": 1012, "fingerprint_mb": 3.11, "status": "ready" }, { "id": 2, "kind": "video", "name": "neg_bars.mp4", "duration": 14.0, "frames": 7, "fingerprint_mb": 0.02, "status": "ready" } ] } | |||
| GET | /v1/references/{id} | One reference in detail. | |
Response
curl http://127.0.0.1:5002/v1/references/3 { "id": 3, "kind": "image", "name": "bunny_215_crop50.jpg", "duration": 0.0, "frames": 1, "fingerprint_mb": 0.0, "status": "ready", "width": 320, "height": 180, "frames_seen": 1, "added": 1785061016.5, "ingest_seconds": 0.14 } | |||
| GET | /v1/references/{id}/fingerprint | Download the reference's portable .cdfp — everything needed to search for it elsewhere, without the media. | |
| PATCH | /v1/references/{id}/metadata | Merge-patch user metadata; a null value deletes a key. Tags live in the fingerprint and travel with it. | |
| DEL | /v1/references/{id} | Remove a reference and its stored moments. | |
Response
curl -X DELETE http://127.0.0.1:5002/v1/references/3 { "removed": true, "id": 3 } | |||
| POST | /v1/queries | Query the library. The core of the API. | |
Request
Response
curl -X POST -H "Content-Type: application/x-cdfp" \ --data-binary @clip.mp4.cdfp \ http://127.0.0.1:5002/v1/queries { "qid": "42f9616d76c6", "url": "/queries/42f9616d76c6", "query": "query.cdfp", "kind": "video", "frames": 20, "duration": 13.67, "verdict": "strong", "timings": { "stage": "fingerprint", "total": 0.0826 }, "matches": [{ "reference": 1, "name": "bunny.mp4", "kind": "video", "verdict": "strong", "score": 0.9448, "runs": [{ "query": [0.0, 13.67], "reference": [59.0, 73.67], "rate": 1.0367, "offset": 59.66, "frames": 20, "mean_score": 0.9156 }], "repeats": [ … ], "repetition": 0.0, "transform": [] }] } | |||
| GET | /v1/queries/{qid} | Re-open a saved result at its permanent address. | |
Request
Response
curl http://127.0.0.1:5002/v1/queries/42f9616d76c6 { "qid": "42f9616d76c6", "url": "/queries/42f9616d76c6", "verdict": "strong", … the POST response above, verbatim … } // unknown qid { "error": "no such query" } // 404 | |||
| POST | /v1/streams | Open a live session. Nothing is searched until frames arrive. | |
Request · JSON body
Response · 201
curl -X POST -H "Content-Type: application/json" \ -d '{"mode":"query","name":"channel-4","fps":1}' \ http://127.0.0.1:5002/v1/streams // 201 { "id": "2e3ab1072bd9", "mode": "query", "name": "channel-4", "state": "live", "fps": 1.0, "window": 30.0, "interval": 5.0, "record": false, "source": null, "duration": 0.0, "frames": 0, "searches": 0, "airings": 0, "url": "/streams/2e3ab1072bd9" } | |||
| POST | /v1/streams/{id}/frames | Feed a session. MJPEG bytes, multipart images, or a .cdfp descriptor batch. | |
Request
Response
Errors
ffmpeg -i "https://…/live.m3u8" -vf fps=1 \ -f image2pipe -c:v mjpeg - \ | curl -X POST --data-binary @- \ -H "Content-Type: image/jpeg" \ http://…/v1/streams/2e3ab1072bd9/frames { "state": "live", "duration": 13.0, "frames": 14, "searches": 1, "airings": 1, "events": [{ "seq": 2, "type": "hit", "t": 13.0, "hit": { "n": 1, "reference": 1, "name": "demo_original.mp4", "verdict": "strong", "score": 0.9927, "started": 0.0, "ended": 13.0, "reference_span": [0.0, 13.0], "rate": 0.9648, "windows": 1, "open": true } }] } | |||
| GET | /v1/streams/{id}/events | Server-sent events. Every airing the moment the engine forms it. | |
Request
Stream
curl -N http://127.0.0.1:5002/v1/streams/2e3ab1072bd9/events id: 2 event: hit data: {"seq": 2, "type": "hit", "t": 13.0, "hit": {"reference": 1, "verdict": "strong", …}} id: 3 event: update data: {"seq": 3, "type": "update", "t": 18.0, "hit": {…}} id: 4 event: ping data: {"seq": 3, "type": "ping", "t": 33.0} | |||
| POST | /v1/streams/{id}/close | End the stream and materialise it. ?keep=false ends it without keeping anything. | |
Response
What it costs
curl -X POST http://…/v1/streams/2e3ab1072bd9/close { "state": "closed", "duration": 13.0, "frames": 14, "searches": 1, "closed_as": { "kind": "query", "query": "86152e75afa4", "result": "5c7c9dfc6b26", "media": "media.mp4", "verdict": "strong", "url": "/queries/86152e75afa4" } } | |||
| GET | /v1/streams | Every session this instance knows about, newest first, with how many are live. | |
| GET | /v1/streams/{id} | One session and its airings, live or long finished. | |
| GET | /v1/streams/{id}/poster | The most recent frame sampled, as a jpeg. A recording only plays once the stream ends, so this is what a live view shows. | |
| GET | /v1/streams/{id}/fingerprint | The descriptors so far as a portable .cdfp. Valid mid-flight, because the accumulator is a fingerprint at every moment. | |
| DEL | /v1/streams/{id} | Stop a session and remove everything it produced. | |
| POST | /v1/compare | Two files, one verdict, nothing stored. | |
Request
Response
curl -X POST -F "a=@clip.mp4.cdfp" -F "b=@film.mp4.cdfp" \ http://127.0.0.1:5002/v1/compare { "verdict": "strong", "score": 0.9446, "runs": [{ "q_start": 0.0, "q_end": 13.67, "r_start": 59.0, "r_end": 73.67, "rate": 1.0367, "frames": 20, "mean_score": …, "residual": …, "pairs": [ … ] }] } | |||
The web UI talks to a private surface under /api/*
on this same server. It answers in the shapes the page was built around and is free
to change with the page — integrate against /v1
above. The complete surface, grouped by what it serves:
| Method | Path | What it does |
|---|---|---|
| library | ||
| GET | /api/stats · /api/items · /api/item/<id> | Library totals, the reference listing (with metadata), and one reference in full detail. |
| GET | /api/thumb/<id> · /api/media/<id> | A poster frame at ?t=, and the seekable media itself. |
| POST | /api/add | Insert an upload, with dedup, progress, and an optional ingest profile. |
| DELETE | /api/items/<id> | Remove a reference. |
| POST | /api/items/<id>/reingest | Re-fingerprint one reference, optionally under an ingest profile; provenance recorded. |
| PATCH | /api/items/<id>/metadata · /api/items/metadata | Merge-patch one reference's metadata, or many in one call. |
| fingerprints | ||
| GET | /api/items/<id>/fingerprint | The fingerprint view: dimensions, flat flags, novelty curve, compatibility. |
| GET | /api/items/<id>/fingerprint/download | The .cdfp itself, named after the reference. |
| POST | /api/fingerprint/inspect | Inspect an uploaded .cdfp without ingesting it. |
| search | ||
| POST | /api/query · /api/query_item/<id> · /api/query_example | Search with an upload, a stored reference's own vectors, or a packaged demo clip. |
| GET | /api/queries/<qid>/results · /api/queries/<qid>/results/<rid> | Every reading of one query, and one of them in full. A result lives inside the query it came from, so nothing outlives its subject. |
| GET | /api/items/<id>/results · /api/items/<id>/results/<rid> · /api/items/<id>/result | The same, for a reference searched against the library — a reference is its own query. |
| POST | /api/queries/<qid>/run | Run a stored query again, optionally under a search profile. |
| DELETE | /api/queries/<qid>/results · /api/queries/<qid>/results/<rid> | Forget every reading of a query, or one of them. |
| GET | /api/progress/<token> · /api/hero · /api/examples · /api/counts | Live progress for a running request, the landing page's surfaces, and how many references and queries are held. |
| queries | ||
| GET | /api/queries · /api/queries/<qid> | The paginated grid (filters, counts, key:value search) and one query with its reading. |
| POST | /api/queries | Add a file to the query store — and answer it. Every item in the store has a result; a query the engine has not run is a file, not a query. |
| GET | /api/queries/<qid>/thumb · /api/queries/<qid>/media | Poster and seekable media for a query. |
| POST | /api/queries/<qid>/promote | Promote a query into the library; metadata carries over, and the query is left as a tombstone. |
| PATCH | /api/queries/<qid>/metadata · /api/queries/metadata | Merge-patch one query's metadata, or many in one call. |
| DELETE | /api/queries/<qid> | Delete a query, and with it every result about it. |
| playback | ||
| GET | /api/media/<id>/codec · /api/queries/<qid>/codec | What a stored file actually is, and which of three tiers will play it: the browser's own decoder, the WebAssembly one in the page, or a transcode. |
| clusters | ||
| GET | /api/clusters | The map of how the library duplicates itself: nodes, labelled edges, and the groups they form. Served whole, because the page re-clusters as thresholds move. |
| POST | /api/clusters/build | Rebuild the map — one cached search per reference. Runs in the background; one at a time. |
| GET | /api/clusters/status | Progress of a running build. |
| DELETE | /api/clusters | Clear the map. Only the map — the searches behind it belong to their references. |
| GET | /api/refs/<id>/related | One reference's edges, for its detail page. |
| configuration & admin | ||
| GET | /api/settings · /api/profiles | The settings registry with current values, and both profile sets. |
| PUT | /api/settings · /api/profiles | Apply setting overrides, or save one kind's profile set — both validated against the registry. |
| DELETE | /api/settings | Reset every override to the shipped defaults. |
| POST | /api/reingest · /api/admin/clear | Re-fingerprint the whole library under current settings, or empty exactly one store (queries, references, or results). |