copyd-nnSSCD

API reference · v1

The copyd-nn API

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.

base url
This instance: 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.
sending media
Three interchangeable shapes everywhere: raw .cdfp bytes (Content-Type: application/x-cdfp), a multipart file field, or JSON {"url": …} for the server to fetch.
compatibility
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.
auth
A request with 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.
clients
curl works everywhere below, and the copydnn CLI speaks this API with the same verbs: copydnn --api http://host add|query|compare|list|rm.

00

Install & first search

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.

run an instance
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
first search
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.


01

Insert references

POST/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.

Recommended

Send a fingerprint

Body is the .cdfp file. Private, kilobytes to megabytes, and indexed before the request returns. Its manifest names the source; add ?name= to override.

Good middle ground

Send a link

Body is {"url": …}, typically a pre-signed storage link. We fetch it, fingerprint it, and keep the media alongside for evidence views.

Always works

Upload the file

Plain multipart upload, a file field, for when the other two are not an option.

you send
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"
you get back · 201
{
  "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.


02

Query

POST/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.

you send
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"
you get back
{
  "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.

verdict
The headline. One of strong, possible, or none, explained below.
score
Visual similarity of the best-matching moment, from 0 to 1. Above ~0.9 means near-identical frames.
runs
The evidence. Each run is one continuous stretch where your clip and the reference move together in time.
rate
The playback speed of the copy relative to the original. 1.0 is untouched, 1.5 sped up, 0.75 slowed down.
timings.stage
How the query was described. frames is the baseline, one whole-frame descriptor per sampled frame; frames+regions means region windows were searched too, which is what deep crops and insets need. stored and stored+regions are the same distinction for a .cdfp query, whose descriptors already exist. timings.views is the exact descriptors-per-frame this query paid for.
qid
A permanent address for this result. The same clip queried twice lands on the same qid, so GET /v1/queries/42f9616d76c6 re-opens it any time. Add ?evidence=full for the raw similarity surfaces and per-frame pairs behind the verdict.
url
The same result as a page. Open it on the instance and the web UI renders the synced players, the dual timelines and the correlation heatmap for this exact query — the fastest way to see a match rather than read it. Prefer ?evidence=full when you are drawing your own views instead.
strong

The timelines move together. Many frames agree on the same line through time. This is a copy.

possible

Some frames look right but the timeline evidence is thin. Worth a human glance, not a conclusion.

none

Nothing in your library explains this clip. Lookalikes score here too, which is the point.


03

Live streams

POST/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.

Recommended

Push frames

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.

Nothing leaves

Push descriptors

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.

Least to run

Let us pull

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.

open, feed, close
# 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"
a push answers with what it caused
{
  "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.

watch it happen · server-sent events
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.
closing · where it goes
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,  }]
}
mode
What closing produces, and nothing else — both modes are searched live. query stores the stream in the query store with a full result against it, addressable and re-openable like any other. reference inserts it into the library, dedup check included.
record
Keep the sampled frames as a playable MP4. Off by default: descriptors alone detect everything, and this is the largest thing a stream writes. On, and the query or reference has media the UI can play, with proxy seconds equal to stream seconds so a hit at 04:12 is at 04:12 in the file.
fps · window · interval
One frame a second, thirty seconds of context per search, a search every five. The window is what a temporal fit has to fit through, so shortening it costs confidence; the interval is how late a match can be reported.
source
A URL for this instance to open itself. Present means we pull; absent means you push. Everything downstream is identical.
dedupe
Drop a frame that says nothing new against the last kept one. On for a reference stream, off for a query one, because every query frame is an independent vote for an alignment and throwing them away weakens the evidence.
a session survives its stream
The descriptors are checkpointed as they accumulate, so 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.


04

Compare two files

POST/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.

one call, in and out
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

Errors are sentences

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.

409 · wrong descriptor space
{
  "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"
}
400 · not a fingerprint at all
{
  "error": "request body is not a .cdfp fingerprint
           (bad container)"
}
409 · already in the library
{
  "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"
}
401 · no or unknown token
{
  "error": "missing or unknown API token",
  "hint": "create one on your account page and send
           'Authorization: Bearer <token>'"
}
429 · this hour's budget is spent
# 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"
}
507 · a storage quota is full
# 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

Endpoint index

Everything above, in one glance. If an endpoint is not on this list, it does not exist yet.

MethodPathWhat it does
GET/v1/meWho this token is: identity, hourly limits, and this hour's usage. The self-report a script wants before a long run.
GET/v1/descriptorThe server's fingerprint dialect, so clients can check compatibility first.
POST/v1/referencesInsert into your library. Accepts a fingerprint body, a link, or a file.
GET/v1/referencesList your library.
GET/v1/references/{id}One reference in detail.
GET/v1/references/{id}/fingerprintDownload the reference's portable .cdfp — everything needed to search for it elsewhere, without the media.
PATCH/v1/references/{id}/metadataMerge-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.
POST/v1/queriesQuery the library. The core of the API.
GET/v1/queries/{qid}Re-open a saved result at its permanent address.
POST/v1/streamsOpen a live session. Nothing is searched until frames arrive.
POST/v1/streams/{id}/framesFeed a session. MJPEG bytes, multipart images, or a .cdfp descriptor batch.
GET/v1/streams/{id}/eventsServer-sent events. Every airing the moment the engine forms it.
POST/v1/streams/{id}/closeEnd the stream and materialise it. ?keep=false ends it without keeping anything.
GET/v1/streamsEvery 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}/posterThe 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}/fingerprintThe 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/compareTwo files, one verdict, nothing stored.

The UI's own backend, for completeness

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:

MethodPathWhat 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/addInsert an upload, with dedup, progress, and an optional ingest profile.
DELETE/api/items/<id>Remove a reference.
POST/api/items/<id>/reingestRe-fingerprint one reference, optionally under an ingest profile; provenance recorded.
PATCH/api/items/<id>/metadata · /api/items/metadataMerge-patch one reference's metadata, or many in one call.
fingerprints
GET/api/items/<id>/fingerprintThe fingerprint view: dimensions, flat flags, novelty curve, compatibility.
GET/api/items/<id>/fingerprint/downloadThe .cdfp itself, named after the reference.
POST/api/fingerprint/inspectInspect an uploaded .cdfp without ingesting it.
search
POST/api/query · /api/query_item/<id> · /api/query_exampleSearch 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>/resultThe same, for a reference searched against the library — a reference is its own query.
POST/api/queries/<qid>/runRun 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/countsLive 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/queriesAdd 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>/mediaPoster and seekable media for a query.
POST/api/queries/<qid>/promotePromote a query into the library; metadata carries over, and the query is left as a tombstone.
PATCH/api/queries/<qid>/metadata · /api/queries/metadataMerge-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>/codecWhat 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/clustersThe 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/buildRebuild the map — one cached search per reference. Runs in the background; one at a time.
GET/api/clusters/statusProgress of a running build.
DELETE/api/clustersClear the map. Only the map — the searches behind it belong to their references.
GET/api/refs/<id>/relatedOne reference's edges, for its detail page.
configuration & admin
GET/api/settings · /api/profilesThe settings registry with current values, and both profile sets.
PUT/api/settings · /api/profilesApply setting overrides, or save one kind's profile set — both validated against the registry.
DELETE/api/settingsReset every override to the shipped defaults.
POST/api/reingest · /api/admin/clearRe-fingerprint the whole library under current settings, or empty exactly one store (queries, references, or results).