Ingest video, and run it through the pipeline via an extracted frame

07a12d4 · gmorell · 2026-08-30 00:51

37 files +489 -76
Message
{commit_body(@commit)}

Files changed

modified Cargo.lock
+11 −0
@@ -4375,6 +4375,16 @@ version = "1.3.0"
4375 4375 source = "registry+https://github.com/rust-lang/crates.io-index"
4376 4376 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
4377 4377
4378 +[[package]]
4379 +name = "signal-hook-registry"
4380 +version = "1.4.8"
4381 +source = "registry+https://github.com/rust-lang/crates.io-index"
4382 +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
4383 +dependencies = [
4384 + "errno",
4385 + "libc",
4386 +]
4387 +
4378 4388 [[package]]
4379 4389 name = "signature"
4380 4390 version = "2.2.0"
@@ -4887,6 +4897,7 @@ dependencies = [
4887 4897 "libc",
4888 4898 "mio",
4889 4899 "pin-project-lite",
4900 + "signal-hook-registry",
4890 4901 "slab",
4891 4902 "socket2 0.6.0",
4892 4903 "tokio-macros",
modified Cargo.toml
+1 −1
@@ -19,7 +19,7 @@ path = "src/lib.rs"
19 19 [dependencies]
20 20 axum = { version = "0.8.5", features = ["multipart"] }
21 21 log = "0.4.28"
22 −tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "sync"] }
22 +tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "sync", "process", "time"] }
23 23 tower-http = { version = "0.6.6", features = ["fs", "trace"] }
24 24 tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
25 25 anyhow = "1.0.100"
modified Dockerfile
+4 −1
@@ -77,8 +77,11 @@ RUN curl -fL --retry 3 -o text-detection.rten https://ocrs-models.s3-accelerat
77 77 # Stage 4 — runtime: minimal Debian slim with the binaries + assets + templates.
78 78 # ---------------------------------------------------------------------------
79 79 FROM debian:bookworm-slim AS runtime
80 +# ffmpeg is here for the `poster` worker, which lifts a still frame out of a
81 +# video so the image-only categorizers have something to read. It is the
82 +# heaviest thing in this layer; drop it only if video uploads go away.
80 83 RUN apt-get update \
81 − && apt-get install -y --no-install-recommends ca-certificates libssl3 \
84 + && apt-get install -y --no-install-recommends ca-certificates libssl3 ffmpeg \
82 85 && rm -rf /var/lib/apt/lists/* \
83 86 && groupadd --system --gid 10001 dms \
84 87 && useradd --system --uid 10001 --gid dms --home /app --shell /usr/sbin/nologin dms \
modified Justfile
+1 −1
@@ -44,7 +44,7 @@ migrate:
44 44 serve:
45 45 cargo run --bin webservice
46 46
47 −# Run a categorizer worker. Usage: just worker thumbnail | ocr | describe | extract_tags
47 +# Run a categorizer worker. Usage: just worker poster | thumbnail | ocr | describe | extract_tags
48 48 worker kind:
49 49 cargo run --bin queue-worker -- --kind {{kind}}
50 50
modified README.md
+29 −3
@@ -111,9 +111,11 @@ All env-driven — defaults make sense for local dev.
111 111 | `AUTO_MIGRATE` | `true` | Run pending migrations on webservice boot. Disable in environments that prefer a separate migration step. |
112 112 | `REGISTRATION_ENABLED` | `false` | When off, plain `/register` 404s; `/register?token=…` invite links still work. |
113 113 | `DMS_PUBLIC_URL` | (unset) | Override what the QR pairing URL encodes when behind a proxy. Falls back to the request's Host header, then the bind address. |
114 −| `WORKER_KIND` | (required for workers) | `thumbnail` / `ocr` / `describe` / `extract_tags`. Same as `--kind`. |
114 +| `WORKER_KIND` | (required for workers) | `poster` / `thumbnail` / `ocr` / `describe` / `extract_tags`. Same as `--kind`. |
115 115 | `WORKER_PREFETCH` | `4` | Per-worker concurrency (RabbitMQ basic_qos). Tune CPU-bound (OCR) lower than I/O-bound. |
116 116 | `WORKER_MAX_ATTEMPTS` | `100` | Hard cap on per-(upload, kind) DB-tracked attempts. Once exceeded, the worker DLQs the next redelivery instead of running it — protects the queue from a single poisoned message hogging consumers. Set `0` to disable. |
117 +| `FFMPEG_BIN` | `ffmpeg` | Binary the `poster` worker shells out to for video frame extraction. |
118 +| `POSTER_TIMEOUT_SECS` | `120` | Hard cap on one frame extraction, so a video that hangs the decoder fails the job rather than parking a worker. |
117 119 | `OCRS_DETECTION_MODEL` | `/opt/dms/models/text-detection.rten` | Bundled in the image; override to swap models. Required for OCR — outside Docker (e.g. `cargo run`), set this to a downloaded `.rten` file or the worker DLQs jobs with a clear log line. |
118 120 | `OCRS_RECOGNITION_MODEL` | `/opt/dms/models/text-recognition.rten` | Same as above. |
119 121
@@ -184,8 +186,9 @@ hosts, which would put those gigabytes back in RAM.
184 186
185 187 Per entry:
186 188
187 −- Only entries whose name resolves to an image type are stored. Everything
188 − else is counted and skipped.
189 +- Only entries whose name resolves to an image or video type are stored.
190 + Everything else, such as an export's stray `.json` or `.txt`, is counted and
191 + skipped rather than becoming an upload nobody asked for.
189 192 - Directories, `__MACOSX/` resource forks, and dotfiles are ignored. Nested
190 193 paths flatten to the basename — storage is content-addressed, so the
191 194 archive's layout doesn't survive.
@@ -228,6 +231,29 @@ a namespace wildcard. Bare values match across whichever sources are enabled.
228 231 The whole pipeline is covered by unit tests for the parser plus integration tests
229 232 that exercise the SQL against a real Postgres (`tests/search_integration.rs`).
230 233
234 +## Video
235 +
236 +A video upload cannot go straight through the categorizers: thumbnail, OCR and
237 +describe all work on a decoded still, and none of them can open an `.mp4`. So a
238 +video gets one job first, `poster`, which shells out to ffmpeg for a single
239 +representative frame, stores it as `<stem>.poster.jpg` beside the original, and
240 +then enqueues thumbnail/ocr/describe itself. From their point of view the video
241 +has become an image; `workers::source_image_relative` is what quietly hands them
242 +the frame instead of the original.
243 +
244 +ffmpeg's `thumbnail` filter picks the frame, scoring a window rather than taking
245 +frame 0, which on a GIF-turned-mp4 is routinely a blank fade-in. Captions burned
246 +into the picture survive, so OCR on an imgur-style export finds real text.
247 +
248 +Run the worker like any other: `just worker poster`. It needs ffmpeg on PATH
249 +(`FFMPEG_BIN` overrides the binary, `POSTER_TIMEOUT_SECS` the 120s cap); the
250 +runtime image installs it.
251 +
252 +Every kind that exists must appear in `queue::ALL_KINDS`. That list drives the
253 +queue topology, and a kind missing from it has no queue, so anything published
254 +for it is dropped by the broker without an error. A unit test keeps the list
255 +exhaustive.
256 +
231 257 ## Pipelines
232 258
233 259 Every upload row keeps a `processing_status`. Per-upload, per-kind state
modified assets/f/js/src.js
+16 −0

Click to load diff…

modified compose.yml
+7 −0

Click to load diff…

modified src/bin/queue-worker.rs
+3 −2

Click to load diff…

modified src/bin/webservice.rs
+9 −0

Click to load diff…

modified src/config.rs
+8 −0

Click to load diff…

modified src/handlers/failed.rs
+1 −7

Click to load diff…

modified src/handlers/uploads.rs
+46 −26

Click to load diff…

modified src/queue/messages.rs
+60 −0

Click to load diff…

modified src/queue/mod.rs
+3 −8

Click to load diff…

modified src/queue/publisher.rs
+1 −8

Click to load diff…

modified src/storage/local.rs
+15 −10

Click to load diff…

modified src/storage/mod.rs
+29 −1

Click to load diff…

modified src/telemetry.rs
+1 −1

Click to load diff…

modified src/workers/describe.rs
+11 −3

Click to load diff…

modified src/workers/mod.rs
+22 −1

Click to load diff…

modified src/workers/ocr.rs
+1 −1

Click to load diff…

added src/workers/poster.rs
+171 −0

Click to load diff…

modified src/workers/thumbnail.rs
+20 −2

Click to load diff…

modified templates/search.html
+6 −0

Click to load diff…

modified templates/uploads/list.html
+6 −0

Click to load diff…

modified templates/uploads/show.html
+7 −0

Click to load diff…

Parents: 6fa3ef9