Video

Read this if you mark generated or stored video and want the mark readable later. You will install the video extra, mark a clip in code, through the sidecar's job path or from a bucket, run it on a GPU, and read the mark back. Skip if you only mark images (Quickstart) or audio (Audio). Time: 15 minutes for a first clip on CPU; the GPU sections take longer. Needs: Python 3.12, pip install "laidline[video,audio]", a laidline.yaml from laidline init, and for the job path the server extra.

In short

  • Laidline marks video with the VideoSeal watermark, re-encodes it to H.264 mp4, marks the soundtrack with AudioSeal, signs Content Credentials and writes an evidence entry.
  • Video marking is off by default (watermark.video.enabled); the sidecar marks it as an async job (202, then poll), never in the request.
  • VideoSeal wants a GPU; on CPU it works but is slow. Keyframe TrustMark is a CPU-only stopgap that can mis-decode.
  • VideoSeal recovers the exact payload after H.264 crf 23 in 93.0 percent of 43 clips, after crf 28 in 53.5 percent, and never returned a wrong payload.
  • On 4,854 unmarked 5 s clips no read showed a mark: presence 0 of 4,854 for VideoSeal, tag accepts 0 of 4,854 for keyed VideoSeal and 0 of 4,854 for the keyframe reader (Wilson 95 percent upper bound 0.079 percent at each; 2026-10 null campaign, Benchmarks).
  • The runs behind these figures (an RTX 5080 and an AWS L4) are listed at the end of this page.

Laidline marks video with VideoSeal 1.0 (Meta, MIT code and weights). The frames are re-encoded to H.264, the audio track is AudioSeal-marked, the final mp4 is signed with Content Credentials, and the mark writes an evidence entry with modality: video. VideoSeal wants a GPU; the async job path of laidline serve runs it, on a CUDA host or on CPU. A stopgap scheme, keyframe TrustMark, runs on CPU only.

Video watermarking is off by default. What the mark survives is in Benchmarks and Robustness: VideoSeal recovers the exact payload after H.264 crf 23 in 93.0 percent of 43 test clips but after crf 28 in 53.5 percent, and never returned a wrong payload.

False positives are measured on a separate, larger set: in the 2026-10 null campaign 4,854 unmarked clips went through unkeyed VideoSeal, keyed VideoSeal and the keyframe reader with the shipped defaults, and none produced a presence, a tag accept or a payload (upper bound 0.079 percent at each read; Benchmarks, Grading).

What you need

You need Python 3.12 or newer, the video extra (and audio for the soundtrack), and the VideoSeal and AudioSeal weights. For the job path you also need the server extra and a laidline.yaml from laidline init.

# PyAV and torch; [audio] adds AudioSeal for the audio track
pip install "laidline[video,audio]"
# optional: VideoSeal (228 MB) and AudioSeal weights, SHA-256 pinned
laidline models fetch
watermark:
  # issuer_id: the trial number laidline init wrote (register leaves it as is); never hand-write it
  # auto | cpu | cuda | cuda:N (VideoSeal, and AudioSeal when not auto)
  device: auto
  video:
    enabled: true
    scheme: videoseal           # or trustmark_keyframes (stopgap, below)
    crf: 18                     # quality of the re-encode (libx264's crf)
    preset: medium
    encoder: libx264            # the default; other encoders: Encoding without x264, below
    max_duration_s: 600         # longer video is refused (mark and detect)
    max_pixels: 8294400         # 3840x2160; larger frames are refused (mark and detect)
jobs:
  path: /var/lib/laidline/jobs  # local disk, one replica per directory (below)
  webhook_hosts: [hooks.example.com]

Every key is in Configuration (watermark.video, jobs).

Set it up

Pick the route that matches where your video comes from: the library for files in your own code, the sidecar's job path for hosts that must not hold keys, the storage hook for objects in a bucket. Run any of them on a GPU for speed (GPU).

Library

# a path or the bytes
marked = marker.mark_video("clip.mov", ctx=MarkContext(request_id="job-1"))
open("clip.marked.mp4", "wb").write(marked.data)

mark_video(path_or_bytes, *, ctx, fmt="mp4") probes the file, refuses frames larger than max_pixels (video_too_large, by the stream header before any frame is decoded, and by every decoded frame while marking, so a mid-stream size change is caught too) and video beyond max_duration_s (video_too_long), watermarks the frames 32 at a time (frames_per_call) while encoding them (H.264 yuv420p at crf with encoder, libx264 at preset by default), marks the audio track with AudioSeal (mark_audio_track; a track it cannot mark is copied with audio_track_unmarked:<reason>), muxes the mp4, signs the final bytes and appends the evidence entry. Input is any container PyAV reads; output is always mp4. A failure raises and logs a failed entry under both on_mark_failure policies: re-encoding the video unmarked would cost as much as marking it, so fail_open has nothing cheaper to return.

  • Duration. The header's duration is checked first, and the frames are also counted as they are decoded: past max_duration_s x fps plus one second of frames the mark stops with video_too_long, so a file whose header understates its length cannot hold the worker.
  • Rotation. A phone video recorded in portrait is usually coded landscape with a display rotation. Laidline reads every frame upright (the rotation applied), marks it, and writes the output upright with the displayed size and no rotation tag; the entry records the source's rotation as watermark.rotation (0, 90, 180 or 270 degrees counterclockwise). A display matrix that also mirrors the picture is read as its rotation only.
  • Colour. Frames are converted to RGB with the source's colour matrix and range, and the re-encode is converted back with the same ones and tagged with them (matrix, range, primaries, transfer). An untagged source is read as BT.709 when it is 720 lines or taller and BT.601 (SMPTE 170M) below, what players assume, and the output carries that tag explicitly. HDR and 10-bit sources are re-encoded as 8-bit yuv420p with their tags copied, not tone mapped.
  • Upstream manifests. An mp4 or mov input that carries a C2PA manifest becomes the parentOf ingredient titled original and the new manifest's intent is edit, as for images and audio; the entry's c2pa.ingredient_manifest summarises it. Only the box headers are read to find out, so an input without a manifest is not read into memory for this. Manifests in other containers (AVI) are not kept.

The entry's timings_ms for video are probe, watermark (the embed and the H.264 encode, which run interleaved chunk by chunk), mux (extracting and marking the audio track, writing the mp4), sign and total. The entry's fingerprint is the pHash of the marked frame at 1 s (at half the video when it is shorter).

# writes clip.marked.mp4 (needs watermark.video.enabled)
laidline mark clip.mp4
# without the flag detect skips the video
# watermark and grades on C2PA alone
LAIDLINE_WATERMARK__VIDEO__ENABLED=true laidline detect --json clip.marked.mp4

The async job path

A video mark takes seconds to minutes, so the sidecar does not answer it synchronously. POST /v1/mark/video spools the upload to jobs.path, queues it and answers 202; one worker thread marks the queued jobs in order.

$ curl -s -F file=@clip.mp4 -F webhook_url=https://hooks.example.com/laidline localhost:8080/v1/mark/video
{"id":"job_01M3...","status":"queued","status_url":"/v1/jobs/job_01M3..."}

$ curl -s localhost:8080/v1/jobs/job_01M3...
{"id":"job_01M3...","kind":"video","status":"done","created_at":"...","updated_at":"...","expires_at":"...",
 "webhook_url":"https://hooks.example.com/laidline",
 "result":{"asset_id":"ast_01M3...","manifest_id":"urn:c2pa:...","entry_hash":"...","url":"/v1/assets/ast_01M3....mp4",
           "bytes":1523041,"warnings":[],"watermark":{"scheme":"videoseal","device":"cuda"}},
 "error":null,"status_url":"/v1/jobs/job_01M3..."}

$ curl -s -o clip.marked.mp4 localhost:8080/v1/assets/ast_01M3....mp4
  • status goes queued, running, then done (with result) or failed (with error: {reason, detail}, for example undecodable_video, video_too_long or video_too_large). Jobs and their results expire after jobs.ttl_hours (24); an unknown or expired id is 404 job_not_found. The marked file is served from the asset store for assets.ttl_hours; result.url is relative unless assets.public_base_url is set.
  • The upload takes the evidence headers of the other mark routes (X-Laidline-Request-Id, else the job id; X-Laidline-Tenant, X-Laidline-Route, default http:/v1/mark/video; X-Laidline-Upstream).
  • Refusals: 413 upload_too_large beyond jobs.max_upload_bytes (2 GiB); 422 webhook_url_invalid; 429 jobs_queue_full when jobs.max_queued (16) jobs are waiting; 503 video_disabled.
  • Metrics on /metrics: laidline_jobs_queued (jobs waiting), laidline_jobs_total{status="done"|"failed"}, and laidline_marks_total and laidline_mark_seconds with modality="video".
  • MarkClient.mark_video(path_or_bytes, ctx=...) does the upload, polls every 2 s and downloads the result; MarkClient.mark_bytes sends video there. After timeout (600 s by default) it raises job_timeout, whose detail names the job (job job_... not finished after 600 s). The job keeps running on the sidecar: poll GET /v1/jobs/{id} for it rather than calling mark_video again, which uploads and marks the video a second time.

Webhooks: when the job finishes, the public job JSON above is POSTed once to webhook_url. Delivery is best effort: one attempt, a 10 s total deadline (jobs.webhook_timeout_s), no retry and no signature, so treat it as a hint to poll status_url, not as a trusted message. The URL must be https. An IP literal in a private, loopback, link-local or other non-public range is refused in every spelling a resolver accepts (127.1, 2130706433, 0x7f000001, a trailing dot, IPv4 inside IPv6: ::ffff:0:0/96, ::/96, 64:ff9b::/96, 6to4), but a host name that resolves to an internal address is not: set jobs.webhook_hosts to the hosts you expect. A host that has not answered by the deadline has its connection shut down; at most four such requests may still be running (a stuck DNS lookup) before webhooks are dropped. Behind an egress proxy the webhook goes through HTTPS_PROXY (or ALL_PROXY) unless NO_PROXY exempts its host, as httpx would do (http and https proxies; another scheme fails the webhook with a WARNING); the deadline shuts down the connection to the proxy.

Running the job path in production

  • One replica per jobs.path. The job store is SQLite (WAL) plus the uploaded files, on local disk; never put it on NFS or another network filesystem. A second replica on the same directory fails the first one's jobs at start.
  • Status polling needs sticky routing by job id, or a single replica: a job exists only on the replica that took the upload. The marked file is in that replica's asset store too.
  • Put a body-size limit in front of /v1/mark/video (your reverse proxy or ingress). Starlette spools the whole multipart upload to a temporary file before the handler runs, so jobs.max_upload_bytes bounds the job directory, not what reaches the server's temporary disk.
  • Cloud Run: CPU always allocated (--no-cpu-throttling), or the worker thread stops between requests.
  • Shutdown can take about 41 s: the running job gets up to 30 s to finish, then queued webhooks up to webhook_timeout_s (10 s) more. That is longer than Kubernetes' default 30 s grace period; raise terminationGracePeriodSeconds. Jobs still queued at shutdown fail with shutdown; jobs a crashed process left behind fail with interrupted at the next start.

Storage hook

laidline ingest marks video/mp4 and video/quicktime objects when they are in ingest.mime_allowlist and watermark.video.enabled is set, writing the result back as video/mp4 (AWS S3 with SQS). The worker holds the whole object in memory and marks it synchronously, so ingest.max_bytes (64 MiB by default) is the video size limit; keep ingest.visibility_timeout above the longest mark, and let max_duration_s and max_pixels refuse long or oversized files (video_too_long, video_too_large; neither is retried).

GPU: the CUDA container

VideoSeal runs on an NVIDIA GPU through the CUDA 13 wheels of torch 2.14.0 and torchvision 0.29.0 (the PyTorch cu130 index); the wheels bundle the CUDA runtime, so the host needs only the NVIDIA driver, and for a container the NVIDIA Container Toolkit. On a host install, put those wheels in the environment before the extras (see Install) and set watermark.device: cuda. For a container, write this Dockerfile: a Python 3.12 image with ffmpeg, those two wheels and the extras, tagged laidline-cuda:local, then run it with the GPU attached. The wheels carry the CUDA runtime, so the base image is plain Python.

FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* \
 && useradd --uid 10001 --create-home laidline \
 && mkdir -p /var/lib/laidline/models /etc/laidline/certs && chown -R laidline /var/lib/laidline /etc/laidline
RUN pip install --no-cache-dir torch==2.14.0 torchvision==0.29.0 --index-url https://download.pytorch.org/whl/cu130 \
 && python -c "import torch; assert torch.__version__.endswith('+cu130'), torch.__version__"
RUN pip install --no-cache-dir "laidline[image,audio,video,server]==1.0.1" \
 && python -c "import torch; assert torch.__version__.endswith('+cu130'), torch.__version__"
RUN python -c "from trustmark import TrustMark; [TrustMark(verbose=False, model_type=v, encoding_type=TrustMark.Encoding.BCH_5, loadRemover=False) for v in 'QBC']" \
 && chmod -R a+rX "$(python -c 'import trustmark, os; print(os.path.dirname(trustmark.__file__))')"
ENV LAIDLINE_HOME=/var/lib/laidline LAIDLINE_CONFIG=/etc/laidline/config.yaml
USER laidline
EXPOSE 8080
HEALTHCHECK --start-period=180s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/readyz')"
ENTRYPOINT ["laidline"]
CMD ["serve", "--host", "0.0.0.0"]

An image installed from pip has no ONNX graph for the TrustMark Q keyframe reader, so that reader runs on torch (slower, same result): see Third-party licences.

docker build -t laidline-cuda:local .

The /var/lib/laidline/models directory exists in the image and belongs to the laidline user, so a new named volume mounted there takes over that ownership. The torch wheels have their own layer, so changing the Laidline version (the Dockerfile pins laidline==1.0.1; change it to the release you want) does not download them again. The assert after each install stops the build if pip put a CPU or older CUDA torch in place of the cu130 wheel. Build on a Linux filesystem such as ext4: from a network or Windows-mounted drive the build can fail with "cannot allocate memory" on the large model files. Laidline's own CUDA image came to 10.4 GB on disk, 3.55 GB compressed; the image built from this recipe measured 10.5 GB on disk, 3.57 GB compressed.

docker run -d --gpus all -p 8092:8080 \
  -e LAIDLINE_CONFIG=/etc/laidline/config.yaml \
  -e LAIDLINE_WATERMARK__DEVICE=cuda -e LAIDLINE_WATERMARK__VIDEO__ENABLED=true \
  -v "$PWD/laidline.yaml:/etc/laidline/config.yaml:ro" -v "$PWD/certs:/etc/laidline/certs:ro" \
  -v laidline-models:/var/lib/laidline/models -v laidline-data:/var/lib/laidline \
  laidline-cuda:local

Expected output: the container id. curl -s localhost:8092/readyz answers {"ready":true,"error":null} once the start-up self-test passes.

What just happened: the container runs the sidecar with VideoSeal on cuda and the job path on; evidence, jobs and assets live in the laidline-data volume, and the weights in laidline-models.

Host requirements for the cu130 build: an NVIDIA driver of release 580 or newer (CUDA 13), and a GPU of compute capability 7.5 or newer (Turing, Ampere, Ada, Hopper, Blackwell). The cu130 wheels carry no kernels for older cards (Volta such as the V100 is 7.0, Pascal 6.x): such a card reports itself available but cannot run them, so watermark.device: cuda (or cuda:N) stops the start with a configuration error naming the card's sm_ architecture and the build's list, and device: auto runs VideoSeal on the CPU with a WARNING in the log. With a driver older than R580 torch sees no usable GPU: cuda is a configuration error and auto runs on the CPU.

/readyz covers only the start-up self-test (a PixelSeal mark on CPU and a TSA-stamped C2PA sign, so it fails while no TSA answers and retries every 30 s, backing off to 15 minutes while it keeps failing: Quickstart); it does not load or download the VideoSeal and AudioSeal weights. Run laidline models fetch ahead (in the container: docker run --rm ... laidline-cuda:local models fetch with the same volumes). Without it the first video job downloads the weights into the models volume and loads them, which took 23.1 s of that job on the RTX 5080 against 2.3 s for a warm job (Benchmarks). PixelSeal (images) still runs on the CPU in this setup.

Measured on an RTX 5080 (16 GB), 5 s of 1280x720 at 24 fps, preset: fast, 32 frames per call: a warm job marks the 120 frames in about 2.2 s (about 55 frames per second) and the client sees 3.2 s from upload to download.

Also run on a cloud GPU: an AWS g6.xlarge (NVIDIA L4, 24 GB) in eu-central-1 from the Deep Learning Base OSS Nvidia Driver AMI (Ubuntu 24.04) 20261016, which ships driver 595.91.07 (CUDA 13.2), Docker and the NVIDIA Container Toolkit 1.20.1, so nothing needs installing. A 12 s 1280x720 clip at 30 fps (360 frames) marked in 11.0 s on the server (15.7 s from upload to download), and laidline detect on the host read the VideoSeal payload at bit accuracy 1.0 and the manifest as trusted. GPU memory was 2,958 MiB with VideoSeal loaded, 3,914 MiB with AudioSeal too, and 5,246 MiB after the 360-frame job. The instance ran 15 minutes, about $0.25 on demand. The 360-frame job ran once, so it is not a warm-throughput figure.

What a cloud GPU host needs:

  • an NVIDIA driver of release 580 or newer, Docker and the NVIDIA Container Toolkit (that AMI ships all three);
  • a security group open to your own address only, on 22 and on the sidecar's port;
  • the instance metadata service with a hop limit of 1 (HttpPutResponseHopLimit=1);
  • a cost guard: schedule a power-off a few hours after boot and set the instance to terminate on shutdown (the run above used 150 minutes).

Synthetic test patterns may not carry the mark: a 720p clip generated with ffmpeg testsrc2 (large flat areas) marked at preset: fast on the L4 with a trusted manifest but no payload could be read back (84.8 percent raw bit agreement), and the same clip marked on a CPU gave the same result, so it is the content, not the GPU. Use natural footage for a check.

GPU memory, from nvidia-smi for the whole GPU while the container ran (the 720p job above):

state memory in use
before the first job (the host's own use) 1,338 MiB
VideoSeal loaded 3,347 MiB
AudioSeal loaded and the first embed run 4,419 to 4,472 MiB, held while the container ran

That is about 3.1 GiB for the sidecar at 720p. There is no tiling. On CPU VideoSeal works but is slow (seconds per second of video).

Encoding without x264

Laidline re-encodes every marked video with libx264 unless watermark.video.encoder names libopenh264 or h264_nvenc; other FFmpeg H.264 encoders are not supported yet. Use one of them for an install whose FFmpeg has no x264 (the install without x264 and x265 below) or to move the encode onto the GPU. Keep the default unless you need one of these: every published video figure (robustness, timings, PSNR) was measured with libx264, and no robustness figure exists yet for video marked through any other encoder. Measure on your own footage before relying on one.

watermark.video.crf is libx264's setting; for another encoder it is mapped to that encoder's quality setting as below, and watermark.video.preset is ignored (a WARNING at load when you set it). Every encoder gets yuv420p frames and the colour tags. The forced key frames of the keyframe scheme come out as IDR frames with all three (measured). watermark.video.encoder_options (FFmpeg option names; numbers and booleans are passed as strings, a boolean as 1 or 0) are laid over the mapped options, for example {qmax: 24}. They cannot set forced-idr, pix_fmt or pixel_format, which the keyframe scheme and the yuv420p output depend on (a configuration error names the key). The mappings from crf are approximations: the encoders' quality scales are not the same as x264's.

encoder Opened with (crf 18 shown; an approximation of x264's crf) Verified on
libx264 (default) crf=18, preset from the config; forced-idr=1 on the keyframe scheme the PyPI wheel: every test and figure
libopenh264 profile=high, rc_mode=quality, qmin=crf-2 (16), qmax=crf+8 (26) the conda-forge LGPL build below: forced IDR frames at 0, 5 and 10, PSNR above 33 dB, colour tags kept
h264_nvenc preset=p4, profile=high, rc=vbr, cq=crf (at least 1), b=0; forced-idr=1 on the keyframe scheme the PyPI wheel on an RTX 5080 host (not in the container): forced IDR frames, PSNR

At start-up, when video is enabled and the encoder is not libx264 (or libx264 has encoder_options), Laidline encodes one 256x144 frame in memory with it. A failure (the encoder missing from this FFmpeg build, its device or driver missing, an option refused) is the configuration error video_encoder_unavailable: <encoder>: under fail_closed the server does not start, under fail_open /readyz answers startup_failed and the log names the cause. The default configuration skips the trial. A name the encoder does not take in encoder_options fails the trial too (PyAV itself ignores unknown names).

How the encoders compare, measured with crf 18 on Laidline's 48-frame 320x256 textured test clip (mean PSNR against the source): libx264 35.3 dB at preset: medium, NVENC 34.8 dB, OpenH264 34.0 dB. On a smooth gradient clip OpenH264 levels off near 38 dB whatever its quantiser range, where libx264 reaches 42.8 dB at crf 18 ultrafast: lowering qmin and qmax makes its files larger without raising the quality. Whether a mark survives later compression after an OpenH264 encode has not been measured yet. OpenH264 also logs an [OpenH264] Warning: bEnableFrameSkip = 0 line per encode, which is harmless (no frame is skipped).

NVENC is in the PyPI PyAV wheel and needs only the NVIDIA driver on the host. In a container the NVIDIA Container Toolkit must also expose the driver's video libraries (NVIDIA_DRIVER_CAPABILITIES including video, for example -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,video); this was not run in the container.

An install without x264 and x265. The PyAV wheel from PyPI bundles libx264 and libx265 (Third-party licences). conda-forge publishes FFmpeg in a gpl variant (with x264 and x265) and an lgpl variant; the ffmpeg=*=lgpl* pin below selects the one without GPL parts (--disable-gpl, --enable-version3: LGPL version 3, no x264, no x265), with OpenH264 compiled from source, and conda-forge's PyAV is built against it. That makes the FFmpeg and PyAV free of GPL parts, not the whole environment: the environment's other packages keep their own licences (conda-forge's Python depends on GNU readline, GPL-3.0); each package's licence is the license field of its record in the environment's conda-meta/ directory. With micromamba (or conda):

micromamba create -y -n laidline -c conda-forge --override-channels python=3.12 av=18.1 "ffmpeg=*=lgpl*"
micromamba activate laidline
echo "av==18.1.0" > constraints.txt   # keeps pip from replacing conda's PyAV with the PyPI wheel
pip install "laidline[image,audio,video,server]==1.0.1" -c constraints.txt
python -c "import av; c = av.codecs_available; assert 'libopenh264' in c and 'libx264' not in c and 'libx265' not in c"

Expected output: nothing from the last line (an AssertionError means x264 came back). Install the GPU wheels of torch first, as in Install, if you run VideoSeal on a GPU. Then set watermark.video.encoder: libopenh264. The solve measured on Linux x86_64 on 27 Oct 2026: ffmpeg 9.0.2 (build string starting lgpl_), av 18.1.0, openh264 2.6.0, no x264 or x265 package; about 916 MB for the environment, most of it FFmpeg's optional dependencies. The audio codecs Laidline writes (AAC, MP3, Opus, FLAC, PCM) are all in that build. Licences of this route, and the patent question it leaves open: Third-party licences.

Keyframe TrustMark (stopgap)

scheme: trustmark_keyframes marks one frame per keyframe_interval_s (1 s) with TrustMark, the image watermark (variant Q, then B and C below the PSNR floor), and encodes those frames as forced IDR frames. It runs on CPU and carries the 61-bit image payload (issuer and a 36-bit asset sequence). Detect votes over the key frames and the frames at the marking positions; a tie between two payloads gives none. On the bench it survives compression better than VideoSeal (78.0 percent at H.264 crf 28) but returned a wrong, well-formed payload for 2.4 percent of clips at crf 35 and at 15 fps (0 percent at crf 28). Use it where no GPU is available, and rely on registry confirmation (below), not on the decoded payload. In keyed mode (Payload keys) the keyed layout is embedded with variant Q only (a position under the PSNR floor is left unmarked and recorded) and read by the hardened Q-only reader (vote, then scan) whatever watermark.video.decode says; grading follows the keyed rows of Grading (an unconfirmed own payload is C on the full tier only).

Check it worked

Turn video on, mark a short clip of natural footage and read it back:

LAIDLINE_WATERMARK__VIDEO__ENABLED=true laidline mark clip.mp4

Expected output:

{
  "out": "clip.marked.mp4",
  "asset_id": "ast_01M5...",
  "manifest_id": "urn:c2pa:...",
  "entry_hash": "03bbf0f3...",
  "warnings": []
}

What just happened: Laidline watermarked every frame, re-encoded to H.264, marked the audio track, signed a Content Credentials manifest into the mp4 and appended an evidence entry. On CPU this takes a while; the first run downloads the weights unless you fetched them ahead.

LAIDLINE_WATERMARK__VIDEO__ENABLED=true laidline detect clip.marked.mp4

Expected output:

verdict: marked_by_this_system   grade: A
  c2pa       present=True grade=A status=valid_trusted
  ...

The c2pa line shows your manifest chaining to your own anchor. The watermark line below it shows the VideoSeal mark found; with a trial issuer number it grades as a shared mark until you register. Without the variable detect skips the video watermark and grades on Content Credentials alone. The ids differ on every run.

Detection

/detect and marker.detect read the C2PA manifest and decode the configured scheme:

  • VideoSeal: frames at a stride that is a multiple of 4 (VideoSeal's step), at most max_detect_frames (256), their logits averaged, then the BCH code and the CRC-16. The signal is present only when both pass. The pHash of the frame at 1 s is computed in the same pass.
  • A decoded payload for your issuer is looked up among videoseal entries by (issuer_id, asset_seq) and counts only when confirmed: the query's exact bytes, or its 1 s frame fingerprint within detect.fingerprint_max_distance bits of the recorded one. Confirmed: grade B and marked_by_this_system; unconfirmed: grade C with the phantom caveat; another issuer: D (Grading).
  • Keyframe TrustMark: the key frames and the marking positions are decoded like images and voted on; the payload is confirmed against the registry by the same rule.
  • Detect refuses a video longer than max_duration_s (an error result, caveat video_too_long) and frames larger than max_pixels (caveat video_too_large). It decodes the video stream up to the last frame it samples, and the samples are spread over the whole video, so a check decodes about all frames up to the cap.
  • Frames are read upright (the display rotation applied) and in the source's colour, as when marking, so a copy that was re-encoded sideways with a rotation tag reads the same.
  • Memory: each sampled frame is resized to VideoSeal's 256x256 on the host as soon as it is decoded (the detector resizes to 256x256 itself; the logits match the full-frame path to within 5e-5), keyframe candidates go to TrustMark one at a time under decode: fast and in batches of 16 full-size frames under decode: hardened (the gated vote and the scan), and one video is detected at a time per process. A video that arrives while another is being checked does not wait (a waiting request would hold one of the server's worker threads): Marker.detect returns an error result with the caveat video_detect_busy: another video is being checked; retry shortly, and /detect answers 503 {"error": "video_detect_busy"} with Retry-After: 5. /healthz and /readyz run on the event loop, so they answer however busy the worker threads are.

Limits

The figures come from Benchmarks and Robustness.

  • Natural video only. VideoSeal hides the mark in texture: uniform noise frames decode at chance and very smooth frames carry a faint mark. Test with real footage.
  • Heavy compression removes the VideoSeal payload (53.5 percent exact at H.264 crf 28, 27.9 percent at H.265 crf 28, 62.8 percent after a social-media re-encode; see Robustness).
  • The keyframe scheme can mis-decode; a verdict comes from registry confirmation by fingerprint or bytes.
  • In the 2026-10 null campaign the unedited sensitivity of keyframe TrustMark was 167 of 173 clips (96.5 percent), under the sealed line of 97 percent, which it did not meet; 2 of the 6 misses were clips the marker never marked (the PSNR floor). Unkeyed VideoSeal read 169 of 174 (97.1 percent) and keyed VideoSeal 172 of 174 (98.9 percent). After H.264 crf 23 the three read 70 of 75, 69 of 73 and 70 of 73 clips.
  • 4096 x 2160 (DCI 4K) clips are refused by max_pixels for both marking and reading; the campaign left 40 such clips out of its unmarked count (0.71 percent of the set). Detect is CPU-bound: a check takes a median 7 to 12 s for a 5 s clip at 1080p or below and about 32 s at 2160p (the slowest read took 74 s).
  • The fingerprint frame at 1 s can be a black or title frame, which makes fingerprint confirmation weak for such videos; exact bytes still confirm.
  • Frame sizes must be even (yuv420p); odd sizes fail with encode_failed. NVENC also refuses frames narrower than 145 pixels.
  • Every published video figure is for libx264. OpenH264 and the hardware encoders have no measured robustness figure (Encoding without x264).
  • Frames above max_pixels (3840x2160 by default) are refused; 8K is not supported.
  • The job path is single-replica per directory; there is no shared queue, Redis backend or batch route.

What was proven, and how

  • Library, detection, job path, ingest and the reference mp4 case: run against the real VideoSeal and TrustMark weights on CPU.
  • Rotation, colour and the limits (CPU): a 320x256 clip remuxed with a display rotation of 90, -90 and 180 degrees reads upright in every reader, and marks to an upright 256x320 mp4 that detects as marked_by_this_system; a marked clip re-encoded sideways with a rotation tag still resolves. A BT.709-tagged 1280x720 flat colour (200, 120, 60) reads back and re-encodes within 3 levels per channel and keeps its tags. The frame counter, the max_pixels check, the upstream ingredient (a Laidline-signed mp4 marked again, from a path and from bytes: Trusted, one original ingredient) and the detect slot are each covered by a test.
  • Host-side resize for detect (CPU, 4 torch threads): 32 VideoSeal-marked frames re-encoded with x264 medium decoded to the same payload and the same bit decisions on both paths, the largest logit difference 2.3e-5 to 4.4e-5: 1280x720 crf 28 (bit accuracy 0.919) and 1920x1080 crf 28 (0.911) decode; 320x256 decodes at crf 23 (0.976) and fails at crf 28 on both paths.
  • GPU, RTX 5080, 27 Sep 2026: the CUDA container (NVIDIA Container Toolkit 1.20.1, driver 616.92) marked a 720p clip through the job path three times; the host validated the manifest (Trusted) and decoded the payload from all 120 frames (Benchmarks).
  • GPU, AWS L4 (g6.xlarge, eu-central-1), 22 Oct 2026: the same container, both checks passed; a 360-frame 720p job marked in 11.0 s, the payload read at bit accuracy 1.0 and the manifest trusted.
  • Robustness: 43 Xiph derf clips, VideoSeal on the RTX 5080 and keyframe TrustMark on CPU (Benchmarks).

Troubleshooting

Message Cause Fix
video_disabled (503 from /v1/mark/video, or the error from mark_video) watermark.video.enabled is false, its default. Set watermark.video.enabled: true, or LAIDLINE_WATERMARK__VIDEO__ENABLED=true, and restart.
video_too_long The video is longer than watermark.video.max_duration_s (600 s), by its header or by the frames counted while decoding. Cut the video, or raise the cap on a host with the time and memory for it. Neither marking nor ingest retries it.
video_too_large A frame is larger than max_pixels (8,294,400, 3840x2160). Scale the video down; 8K is not supported.
encode_failed yuv420p needs even frame sizes; NVENC also needs a width of at least 145 pixels. A bad value or an unknown name in watermark.video.encoder_options fails here too (the detail names it). Re-encode the source with even width and height (wider for NVENC), or fix the option.
video_encoder_unavailable: <encoder> (the start-up error; /readyz reports startup_failed) The one-frame trial encode with watermark.video.encoder failed: this FFmpeg build lacks the encoder (the PyPI PyAV wheel has no libopenh264), its device or driver is missing, or it refused an option. The detail in the log names the cause; an UnknownError from h264_nvenc means no usable NVIDIA device or driver. Install a build that has it (Encoding without x264), give the container the device, fix the option, or go back to libx264.
upload_too_large (413) The upload is over jobs.max_upload_bytes (2 GiB). Mark it with the library or the storage hook instead, or cut it.
jobs_queue_full (429) jobs.max_queued (16) jobs are already waiting. Retry later, or raise jobs.max_queued.
webhook_url_invalid (422) The webhook is not https, or names a private address, or a host outside jobs.webhook_hosts. Use an https URL on a host you listed in jobs.webhook_hosts.
job_not_found (404) The id is unknown or the job expired after jobs.ttl_hours (24). Mark again; poll sooner.
job_timeout MarkClient.mark_video waited 600 s; the job is still running on the sidecar. Poll GET /v1/jobs/{id} for it; do not call mark_video again.
video_detect_busy (503 with Retry-After: 5) Another video is being checked in this process. Retry after a few seconds.
watermark.device: cuda stops the start naming an sm_ architecture The GPU is older than compute capability 7.5, or the driver is older than release 580. Use a newer GPU or driver, or set device: auto to run on the CPU.

Reference