hermes · 2026-05-31 · hermes research

Media Optimization — OSS Self-Host Verdict Brief

Follow-up to Directus CMS stack adoption (2026-05-22). Original REPORT.md §05 picked Cloudflare Images (managed) for image transforms and Bunny Stream (managed) for video. Operator now wants OSS-self-host alternatives surveyed so the stack can keep media optimization in-VPS rather than outsourced. Audio + documents/PDF were gaps in the original stack — included here for completeness.

Posture: OSS-self-host, R2 origin, Caddy reverse-proxy, Bunny CDN edge, per-client Docker on shared VPS. On-the-fly transform proxy preferred.

Sibling files (verified candidate matrices, full per-axis scoring): - images.md · video.md · audio.md · docs-pdf.md


Axis Pick License Stars Why
Image imgproxy (DarthSim/imgproxy) Apache-2.0 10.8k Native R2 origin, HMAC signed URLs, AVIF+WebP+JXL, stateless Go+libvips, designed for reverse-proxy + CDN topology. Score 10/10.
Video shaka-packager (shaka-project) + Eyevinn shaka-packager-s3 wrapper + Eyevinn encore orchestrator BSD-3 / MIT 3.7k / 2 / 80 Google-maintained packager; S3-in/out wrapper for R2; ffmpeg ABR-mezzanine orchestrator. CMAF + HLS + DASH + Widevine/FairPlay/PlayReady DRM. Score 10/10. Caveat: S3 wrapper is single-maintainer 2★ — fork into Hermes-controlled vendored copy.
Audio FFmpeg + bbc/audiowaveform in a ~300-LOC stateless Hermes-owned shim (FFmpeg LGPL+) / GPL-3 — / 2.1k No off-the-shelf candidate fits — build the shim. FFmpeg loudnorm covers EBU R128; audiowaveform covers waveform PNG; output Opus + AAC + MP3. Liquidsoap (1.6k★, active) reserved as fallback if scheduled podcast assembly enters scope.
Docs/PDF Gotenberg (gotenberg/gotenberg) + mupdf-tools sidecar MIT / AGPL-3 12.3k / 2.8k Single Docker container REST API wrapping LibreOffice + Chromium + PDFtk; covers PDF/DOCX/XLSX/PPTX/ODT with native async webhooks. v8.33.0 released 2026-05-29 (active). MuPDF gives fast PDF→PNG thumbnails. Score 10/10.

Pattern observed: all 4 axes converge on stateless transform container behind Caddy, R2 source + target, content-addressed cache, Bunny CDN fronts cache. This is a real fleet-wide pattern, not coincidence. One Caddy block per axis, four images per client tenant. Stack composable.


Integration sketch with Directus stack

# docker-compose.yml (per-client tenant)
services:
  directus:
    image: directus/directus:latest
    environment:
      STORAGE_LOCATIONS: cloud
      STORAGE_CLOUD_DRIVER: s3
      STORAGE_CLOUD_ENDPOINT: ${R2_ENDPOINT}
      STORAGE_CLOUD_KEY: ${R2_KEY}
      STORAGE_CLOUD_SECRET: ${R2_SECRET}
      STORAGE_CLOUD_BUCKET: ${R2_BUCKET}

  imgproxy:
    image: ghcr.io/imgproxy/imgproxy:latest
    environment:
      IMGPROXY_S3_ENDPOINT: ${R2_ENDPOINT}
      IMGPROXY_USE_S3: "true"
      IMGPROXY_KEY: ${IMGPROXY_HMAC_KEY}
      IMGPROXY_SALT: ${IMGPROXY_HMAC_SALT}

  gotenberg:
    image: gotenberg/gotenberg:8
    command: ["gotenberg", "--api-port=3000", "--webhook-allow-list=.*"]

  shaka-packager-s3:
    image: ghcr.io/eyevinn/shaka-packager-s3:latest  # to be vendored — see Caveat
    environment:
      S3_ENDPOINT: ${R2_ENDPOINT}

  encore:
    image: eyevinnplatform/encore:latest
    environment:
      ENCORE_S3_ENDPOINT: ${R2_ENDPOINT}

  audio-shim:
    build: ./services/audio-shim  # Hermes-owned ~300 LOC
    environment:
      S3_ENDPOINT: ${R2_ENDPOINT}
# Caddyfile (per-client tenant subdomain)
img.{client}.boas.dev {
    reverse_proxy imgproxy:8080
    header Cache-Control "public, max-age=31536000, immutable"
}

vid.{client}.boas.dev {
    reverse_proxy shaka-packager-s3:8080
    header Cache-Control "public, max-age=2592000"
}

doc.{client}.boas.dev {
    reverse_proxy gotenberg:3000
}

aud.{client}.boas.dev {
    reverse_proxy audio-shim:8000
}

Bunny CDN pull-zone per subdomain. R2 lifecycle policies handle stale-cache eviction.


Rejected / disqualified

Candidate Axis Reason
flyimg Image AGPL-3 on public-facing transform endpoint — operator-decision blocker; otherwise scored 9/10
imaginary Image No AVIF; last tagged release 2020
imageflow Image imageflow_server removed upstream; .NET-only replacement
picfit / lilliput Image No AVIF / library not server
OvenMediaEngine Video AGPL-3 contamination for per-client commercial work
PeerTube Video AGPL-3 + federated-server scope mismatch
Tdarr / Unmanic / FileFlows Video Library-optimization farms, not ABR packagers
Bento4 Video Lacks native S3 (needs rclone); GPL-2 / commercial-license ambiguity. Held as fallback only.
loudness-scanner Audio Dormant since 2023
AzuraCast Audio Webradio appliance — scope mismatch
Icecast Audio Distribution layer, not processor
ONLYOFFICE / Collabora Docs Full collaborative editors — too heavy for thumb/preview
paperless-ngx Docs DMS scope
Apache Tika Docs Text extraction not preview rendering
pdf.js Docs Client-side only
Stirling-PDF v2 Docs License posture NOASSERTION — needs legal review for commercial bundling. v1 is GPL-3, usable.

Architectural notes

  1. Bunny CDN does not disappear. OSS sidecars replace transform, not edge delivery. Bunny pull-zone still fronts each subdomain. Transform sidecars are origin-shielded by CDN cache; absent Bunny, imgproxy/gotenberg eat thundering-herd risk on viral assets.

  2. Storage cost shift. HLS variants typically 2-4× source size at 4-rung ABR ladder. Directus stack budget (originally framed around image-only R2 cost) needs revision if video lands in scope. Per-client cap recommended.

  3. GPU optional, not required. All 4 axes run CPU-only acceptable for VPS posture. NVENC/QSV unlock 4-8× transcode throughput if a client's video catalog grows beyond hundreds of GB — defer until triggered.

  4. On-the-fly transcode is a stretch goal not delivered. Universal pattern in OSS video is pre-transcode-at-upload; no top pick supports per-request transcode. Image, audio, doc axes DO support on-the-fly transform first-class. Accept asymmetry; align Directus upload-webhook to fire encore-job for video, no extra step for the other three.

  5. License posture clean except for 3 flags: flyimg AGPL (image, secondary), Bento4 GPL+commercial (video, fallback only), Stirling-PDF v2 NOASSERTION (docs, fallback only). Top picks across all 4 axes are MIT / Apache-2 / BSD-3 / LGPL — no copyleft contamination on the recommended path.

  6. Audio shim is the only build-vs-adopt deviation. Hermes owns ~300 LOC. Trade-off accepted — no off-the-shelf candidate fits the lightweight on-the-fly transform-proxy shape for audio. Liquidsoap (1.6k★, daily commits) reserved as scope-expansion fallback if scheduled-feed audio enters scope.


Open questions for operator

# Question Why it matters Lean
Q1 Accept Hermes-vendored fork of Eyevinn shaka-packager-s3 wrapper (~500 LOC, BSD-3)? Or upstream-contribute first? Single-maintainer 2★ upstream = bus-factor risk on a load-bearing piece Vendor first, upstream-contribute second
Q2 AGPL posture for client-facing web app sidecars (relevant if flyimg or OvenMediaEngine revisited)? Reach-of-AGPL across HTTP boundary unsettled in case law Avoid AGPL on edge sidecars; keep AGPL acceptable for internal-only services
Q3 Smart cropping (saliency-aware) required for image pipeline? imgproxy has it via Pro tier; thumbor has open ML plugins Defer — operator add when first client demands
Q4 DRM scope for video — Widevine/FairPlay/PlayReady or token-gated only? Shaka supports full DRM but cert provisioning is per-client legal work Token-gated v1; DRM v2
Q5 Build audio shim now, or defer until first audio client? Avoid building until validated Defer — flag in client-onboarding checklist
Q6 Stirling-PDF v1 acceptable as fallback (GPL-3), or hard-reject all license-ambiguous candidates? v2 is in NOASSERTION; v1 is GPL-3 and safe Accept v1 as fallback only

Downstream routing (no handoffs filed by Hermes)

This is a research arc, not an adoption arc (per 2026-05-22 lesson — Pantheon ceremony only when operator explicitly asks for adoption-execution layer). Operator owns:

Hermes will not file these handoffs unsolicited. Watch-list only.


Provenance