feat(processing): normalise HEIC/HEIF/TIFF images and exotic-codec videos at upload time - #2832
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds an upload-time normalisation pipeline to ensure uploaded images and videos end up in formats that the viewer can reliably play/render, plus schema support (Asset.metadata) to track processing outcomes.
Changes:
- Add Celery-backed image/video normalisation (HEIC/HEIF/TIFF → lossless WebP; non-passthrough videos → H.264/AAC MP4).
- Add
Asset.metadataJSONField (migration + serializer exposure) to persist original extension, conversion/transcode flags, and error messages. - Update upload paths (API + HTML form) to route eligible uploads through the pipeline, plus lower worker priority (nice/ionice) to avoid viewer starvation.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Adds pinned Pillow and pillow-heif dependencies. |
| tools/image_builder/main.py | Adds libheif1 runtime dependency to base image for HEIC decode support. |
| tests/test_template_views.py | Updates upload-view tests to expect normalization task dispatch and adds HEIC/JPEG routing tests. |
| tests/test_scheduler.py | Updates scheduler test fixtures to include new metadata field. |
| tests/test_processing.py | Adds extensive unit coverage for image/video normalization helpers and Celery wrapper behaviors. |
| src/anthias_server/processing.py | Introduces core normalization logic, ffprobe/ffmpeg handling, metadata writes, and failure-path contract. |
| src/anthias_server/celery_tasks.py | Registers new Celery tasks (normalize_image_asset, normalize_video_asset) and wires task base for on_failure. |
| src/anthias_server/app/views.py | Updates HTML upload endpoint to preserve source extension and enqueue normalization tasks. |
| src/anthias_server/app/templates/_asset_modal.html | Widens <input accept> to include HEIC/HEIF/TIFF and additional video extensions. |
| src/anthias_server/app/models.py | Adds Asset.metadata JSONField to the model. |
| src/anthias_server/app/migrations/0005_asset_metadata.py | Migration adding the metadata column to assets. |
| src/anthias_server/api/views/v2.py | Dispatches normalization tasks after asset creation when serializer marks a pending normalize step. |
| src/anthias_server/api/views/v1_2.py | Same normalization dispatch as v2 for v1.2 create endpoint. |
| src/anthias_server/api/serializers/v2.py | Exposes metadata on v2 asset serializer (currently read-only). |
| src/anthias_server/api/serializers/mixins.py | Adds _pending_normalize flag and routes local uploads into normalization (defers duration probing for videos). |
| src/anthias_server/api/serializers/init.py | Exposes metadata on v1 serializer as read-only. |
| pyproject.toml | Adds Pillow/pillow-heif to dependency group and mypy module allowlist. |
| docker-compose.yml.tmpl | Wraps celery worker command with nice + ionice to lower priority. |
| docker-compose.balena.yml.tmpl | Same nice/ionice worker priority adjustment for balena. |
| docker-compose.balena.dev.yml.tmpl | Same nice/ionice worker priority adjustment for balena dev. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…-codec videos Two new Celery tasks run on every fresh upload, mirroring the ``download_youtube_asset`` async pattern: * ``normalize_image_asset`` converts HEIC / HEIF / TIFF to lossless WebP via Pillow + pillow-heif, preserving alpha. Other image formats short-circuit out as a no-op. * ``normalize_video_asset`` ffprobes the upload, passes through if it's already H.264/HEVC in an accepted container with a viewer- friendly audio codec, otherwise transcodes to H.264 + AAC MP4 with ``-threads 2 -preset medium -crf 23`` so two cores stay free for the on-device viewer. Both tasks land their output via a staging-file rename, write ``Asset.metadata`` flags (``original_ext``, ``transcoded`` / ``converted``, ``error_message``), and clear ``is_processing`` on success — or via a custom ``Task.on_failure`` on permanent failure so a row never stays stuck on the "Processing" pill. Schema: * New ``Asset.metadata`` JSONField (default dict) plus migration. Exposed read+write on ``AssetSerializerV2`` (read-only on v1.x). Wiring: * ``CreateAssetSerializerMixin.prepare_asset`` flags ``is_processing`` and stashes ``_pending_normalize`` (``image``/``video``/``None``); ``AssetListViewV2`` and ``AssetListViewV1_2`` dispatch the matching task after persistence. * The HTMX ``assets_upload`` view now persists the source extension on disk so the task can identify the format, replaces the ``probe_video_duration`` hop with ``normalize_video_asset`` (whose passthrough branch is the same probe + duration path), and dispatches ``normalize_image_asset`` for HEIC/HEIF/TIFF. * Add-asset modal accepts the wider extension list. Resource control: * ``anthias-celery`` worker command wrapped with ``nice -n 19 ionice -c 3`` in compose templates so transcodes never starve the on-device viewer. * ``ffmpeg`` invocation pins ``-threads 2`` for the same reason. Dependencies: * New: Pillow, pillow-heif (Python); libheif1 in ``base_apt_dependencies`` (~1 MB extracted). * No changes to ffmpeg/ffprobe — already runtime deps. Tests: * ``tests/test_processing.py`` covers both tasks: HEIC/HEIF/TIFF conversion (incl. uppercase ext, RGBA handling), JPEG no-op, corrupt-input failure path, six-row passthrough decision table, exotic-codec → H.264 transcode (mpeg2, mjpeg), MP4-with-non-H264 in-place transcode, ffmpeg timeout/failure/zero-byte cleanup, ffprobe missing-stream parsing, on_failure metadata write, prepare_asset routing for HEIC / video / remote URL / JPEG. * PDF support is deferred to a follow-up — out of scope here per the issue's "image/video first" framing.
vpetersson
force-pushed
the
asset-processor
branch
from
May 6, 2026 21:19
a99a968 to
e27402c
Compare
* assets_upload now falls back to UploadedFile.content_type and finally an extension-based classification so HEIC/HEIF/TIFF uploads still classify on hosts whose mimetypes DB doesn't ship ``image/heic`` mappings. * _ffprobe_summary derives the container from ffprobe's ``format.format_name`` (a comma-joined synonym list — pick the first token in the passthrough set) instead of trusting the filename extension. A ``.bin`` file containing MP4 bytes now classifies correctly; a ``.mp4`` file containing avi-only bytes no longer slips into the passthrough branch. * Zero-byte ffmpeg output now removes the staging file before raising, mirroring the timeout/error branches above. All three failure paths share a small _drop_staging() helper so cleanup stays consistent. New tests: * ffprobe summary prefers format_name over filename, with a deterministic fallback to the extension when format_name is absent. * zero-byte transcode output cleans up its staging file (asserts no leftover ``staging`` files in assetdir). * assets_upload classifies HEIC via Content-Type when guess_type returns None.
SonarCloud's python:S5443 flags hardcoded ``/tmp/`` paths as "publicly writable directory" usage. The flagged lines pass these strings as labels to ``_ffprobe_summary`` (whose internals are mocked away in those tests) — only the extension is consumed by the real code path. Switching to ``fixture.<ext>`` keeps the test intent clear and silences the security hotspot.
The previous pipeline always emitted H.264, which is wasteful on
boards whose player can hardware- or software-decode HEVC: a typical
clip re-encodes ~30-50% smaller at perceptual parity. Introduce a
board-profile grid keyed on ``DEVICE_TYPE`` (set at image-build time
in the Dockerfile) so each device gets the codec its on-device player
actually decodes well:
┌──────────┬─────────────────┬──────────────┬──────────────┐
│ Board │ Player │ HEVC OK? │ Target codec │
├──────────┼─────────────────┼──────────────┼──────────────┤
│ pi2/pi3 │ VLC + mmal-vc4 │ no HW, slow CPU │ H.264 │
│ pi4-64 │ mpv + V4L2 HEVC │ HW-decoded │ HEVC │
│ pi5 │ mpv + SW decode │ A76 SW @ 1080p │ HEVC │
│ x86 │ mpv + va/nv/qsv │ HW-decoded │ HEVC │
│ unset │ (dev / unknown) │ assume no │ H.264 │
└──────────┴─────────────────┴──────────────┴──────────────┘
Per-board passthrough also tightens: an HEVC upload to a pi3 device
no longer slips through unplayable — it gets transcoded to H.264.
Conversely, an H.264 upload on pi5 still passes through unchanged
(no point re-encoding to HEVC on a row that already plays).
The ``Asset.metadata['transcode_target']`` field now records the
codec the device wanted, written on both passthrough and transcode
paths so the operator can see "this device wanted hevc, the upload
already was hevc, no work needed" without inferring.
* ``_BOARD_PROFILES`` maps each ``DEVICE_TYPE`` value the image
builder emits to ``{transcode_target, passthrough_video_codecs,
video_args}``. ``_resolve_board_profile`` reads the env var.
* ``_video_can_passthrough`` and ``_transcode_to_target`` accept an
optional profile arg; tests pin the profile per case rather than
mutating env (and one parametrised test still uses env so the
resolve path is exercised end-to-end).
* HEVC encode args include ``-tag:v hvc1`` for broader player compat
(mpv/VLC don't care, but iOS / browsers prefer hvc1 over hev1).
* libx265 CRF 28 chosen as the rough perceptual equivalent of
libx264 CRF 23 — matches the heuristic in libx265's own docs.
Tests:
* New parametrised tests for the codec grid: per-board target codec
resolution, per-board passthrough decision, per-board ffmpeg argv
(including ``-tag:v hvc1`` only on HEVC boards), pi3 + HEVC source
→ libx264 transcode, pi5 passthrough records target codec.
* Updated existing passthrough test to pin DEVICE_TYPE=pi5 since
the default profile is now H.264-only.
…rror pill
Two unifications driven by the same goal — every "row processing"
state and every "row failed" state should look identical to the
operator regardless of which celery task handled the row.
YouTube → normalize_video_asset chain
-------------------------------------
``download_youtube_asset`` no longer terminates the row's
in-flight state on its own. After yt-dlp lands the .mp4 it:
* writes ``metadata['source']='youtube'`` and
``metadata['source_url']`` so an operator can recover the
original URL after ``name`` is overwritten with the resolved
title,
* leaves ``is_processing=True``,
* dispatches ``normalize_video_asset`` to take over.
The chained pass runs ffprobe and decides per-board passthrough vs.
transcode using the codec grid landed in this PR. That matters
because yt-dlp's ``format_sort: vcodec:h264`` is a *preference*, not
a guarantee — when no H.264 rendition is available yt-dlp falls
back to whatever it can get (vp9 webm, av1, ...). Without the
chain, those downloads would land on a pi3 device unplayable. With
the chain, the same codec grid that protects file uploads protects
YouTube downloads too, and the row carries the same metadata shape
(``original_ext``, ``transcoded``, ``transcode_target``).
Failure-path unification
------------------------
``_DownloadYoutubeTask.on_failure`` now reuses
``processing._set_processing_error`` + ``processing._notify`` —
single source of truth for the error_message contract instead of
two near-duplicate blocks. A failed YouTube download now writes
``metadata.error_message`` (``DownloadError: 404 Not Found`` etc.)
exactly like a failed normalisation does.
UI: error pill
--------------
The asset table row template renders a warn-coloured "Failed" pill
(in the column previously occupied by the active toggle) when
``metadata.error_message`` is populated and ``is_processing`` is
clear. The full message rides along on the title/aria-label so the
operator can hover for context — no extra modal needed. Same shape
as the existing ``processing-pill`` so the column layout stays
stable across in-progress / failed / done states.
Tests
-----
* ``test_download_youtube_asset_success_chains_into_normalize_video``
— happy path now asserts ``is_processing=True`` post-task and
``dispatch_normalize_video`` was called with the asset_id.
* ``test_download_youtube_asset_on_failure_writes_error_metadata``
— replaces the old "clears processing" test; asserts both
``is_processing=False`` and the ExceptionType+message in
``metadata.error_message``.
* Three other YouTube tests updated to mock
``dispatch_normalize_video`` so they don't hit a real broker.
* ``test_asset_row_renders_error_pill_when_processing_failed`` and
``test_asset_row_no_error_pill_when_metadata_clean`` lock in the
template's pill rendering.
Contributor
Author
Extends the image-normalisation pipeline to cover the realistic set of "operator drags an unusual image format into the upload modal" cases, all handled by Pillow's built-in decoders without a new apt or wheel dependency: ┌──────────┬────────────────────────────────────────────────────┐ │ Format │ Why we want it converted │ ├──────────┼────────────────────────────────────────────────────┤ │ BMP │ Uncompressed; a 4K BMP is ~30 MB vs ~1 MB as WebP. │ │ ICO │ Multi-frame Windows icon; pick the largest, flatten│ │ TGA │ Screenshot tools / game asset exports; no browser │ │ │ support. │ │ JPEG2000 │ .jp2/.j2k/.jpx/.jpc/.jpf — scanner output; no │ │ │ browser support. │ │ AVIF │ Modern phone exports. Chromium 85+ renders AVIF, │ │ │ but the legacy Pi 2/3 Qt5 WebEngine predates it, │ │ │ so converting on upload means one playback path │ │ │ across the fleet. │ └──────────┴────────────────────────────────────────────────────┘ JPEG / PNG / WebP / GIF / SVG remain untouched — already viewer-friendly *and* well-compressed. Implementation: * Extend ``NORMALIZE_IMAGE_EXTS``; the rest of the pipeline already accepts any extension in this set (RGBA conversion happens inside ``_convert_image_to_webp`` regardless of source format). * Replace the duplicate extension set in ``assets_upload`` with a call to ``processing.needs_image_normalisation`` so the source of truth lives in one place. * Widen the upload modal's <input accept> attribute. Tests: * ``test_image_normalises_to_lossless_webp_across_formats`` is a parametrised matrix that round-trips each new format end-to-end: source synthesised via Pillow, runs through ``_run_image_normalisation``, asserts the WebP output decodes cleanly back to a 16x16 image. Catches both decoder-side regressions (Pillow drops a format) and writer-side regressions (RGBA convert mode breaks one source). * ``test_needs_image_normalisation`` extended to cover every entry in the new set plus negative cases (.jpg/.png/.webp/.gif/.svg stay False). Total: 109 image-format assertions.
Six items from Copilot's fresh review pass: * ``assets_upload`` last-resort image-extension allowlist now derives from ``processing.NORMALIZE_IMAGE_EXTS`` rather than duplicating the set. Adding a new normalisable format (or removing one) only touches one place. * ``_run_image_normalisation`` cleans up the ``.webp.tmp`` staging file on every failure path — Pillow's ``UnidentifiedImageError`` *and* a generic OSError mid-encode (disk pressure, libheif crash). Mirrors the video pipeline's _drop_staging contract. * ffmpeg failure messages decode the bytes ``stderr`` to UTF-8 text (with replacement on malformed bytes) and tail-trim long output, so ``metadata.error_message`` reads as a real diagnostic instead of ``b'Invalid data found'``. * Removed the dead ``path.normpath(staging) == path.normpath( src_uri)`` branch in the video transcode path. With the staging suffix in place the two paths can never collide; expanded the surrounding comment to explain why. * Updated ``normalize_video_asset``'s docstring to describe the per-board codec grid (libx264 on pi2/pi3, libx265 on pi4-64 / pi5 / x86) rather than the now-stale "transcode to H.264 MP4". * Fixed "truecate" → "truncate" typo in ``_asset_row.html`` comment. New tests: * ``test_image_partial_write_cleans_staging`` — half-writes the ``.webp.tmp`` then raises OSError; asserts the runner removes the partial file before propagating. * ``test_format_subprocess_stderr_decodes_and_trims`` — covers the bytes-decode, malformed-byte-replacement, tail-trim, and empty-stderr cases for the new helper. * ``test_video_ffmpeg_error_cleans_staging`` strengthened to assert the error message contains *no* ``b'...'`` Python repr prefix — it's now operator-readable text.
Three code fixes plus a PR-description sync: * ``_run_image_normalisation`` no-op path (when src_ext isn't in NORMALIZE_IMAGE_EXTS) now also clears ``metadata.error_message``. Without this, a row re-uploaded as a JPEG/PNG after a previously-failed HEIC conversion would drop is_processing but keep showing the "Failed" pill — the operator's table would lie about the row's current state. * ``_ffprobe_summary`` now catches ``sh.CommandNotFound`` in addition to ``TimeoutException`` / ``ErrorReturnCode``. A stripped-down image / dev box without ffprobe in PATH used to crash the task with an unhandled CommandNotFound; now it collapses to the same all-'unknown' summary so the runner falls through to the transcode branch (which itself fails clean if ffmpeg is also missing — same on_failure contract). * Rewrote the ``_ffprobe_summary`` docstring: the actual behaviour is "unknown" for missing video stream, "none" only for genuinely missing audio stream. The previous "''" claim was wrong and would have misled callers / future maintainers. Tests: * ``test_image_no_op_path_clears_stale_error_message`` — JPEG re-uploaded over a row whose previous attempt failed; the no-op branch must wipe the stale error_message. * ``test_ffprobe_summary_handles_missing_ffprobe_binary`` — CommandNotFound side-effect; asserts all-'unknown' summary rather than a propagating exception.
Four contract gaps Copilot flagged: * ``normalize_image_asset`` / ``normalize_video_asset`` use ``autoretry_for=(OSError,)`` to recover from transient disk pressure. ``FileNotFoundError`` is-a ``OSError`` so the filter was catching it too — but a missing source file is permanent, and retrying just delays the on_failure that writes ``metadata.error_message``. Adding ``dont_autoretry_for=(FileNotFoundError,)`` to both decorators makes the missing-source raise propagate immediately, so the operator sees the "Failed" pill and the error message at the next browser refresh instead of waiting through up-to-3 exponential-backoff retry cycles. * ``_run_image_normalisation`` and ``_run_video_normalisation`` both call ``os.replace(staging, final_uri)`` after a successful conversion / transcode. A rename failure (cross-device link, filesystem-full at the very last step, permissions) was outside the existing try/except, so the staging file would linger until cleanup()'s 1h sweep. Wrap both in a try/except that calls ``_drop_image_staging`` / ``_drop_staging`` on any OSError before propagating — the "no leftover staging artifacts on failure" contract now holds across every failure path. Tests: * ``test_image_rename_failure_cleans_staging`` and ``test_video_rename_failure_cleans_staging`` — patch ``os.replace`` to raise OSError; assert the staging file is gone before the exception reaches the runner's caller. * ``test_normalize_tasks_exclude_filenotfounderror_from_autoretry`` — celery-config-time check that both tasks expose ``FileNotFoundError`` in their dont_autoretry_for tuple, so a future change to the decorator can't silently regress the immediate-fail contract.
Three stale docstring callouts from Copilot's review of 7099b25: * ``processing.py`` module docstring — listed only HEIC/HEIF/TIFF for the image task. Updated to enumerate the full set (HEIC/HEIF/TIFF/BMP/ICO/TGA/JPEG 2000 family/AVIF) and to call out the JPEG/PNG/WebP/GIF/SVG no-op short-circuit. * ``needs_image_normalisation`` docstring — same drift; rewrote the leading sentence to match what the predicate actually checks (``_ext(...) in NORMALIZE_IMAGE_EXTS``). * ``tests/test_processing.py`` module docstring — said the image task covers only HEIC/HEIF/TIFF and that video transcodes are libx264-only. Both stale: extended to enumerate every image format the suite exercises, and to describe the per-board ``DEVICE_TYPE`` codec grid (libx264 on pi2/pi3, libx265 on pi4-64/pi5/x86) that the parametrised video tests pin down. No code changes; documentation only.
…ad on intermediate hops Two more Copilot items: * ``_format_subprocess_stderr`` had two trim branches: bytes (via byte-precise tail) and str (via character-count tail). The str branch could exceed _STDERR_TAIL_BYTES under multibyte text. Normalise to bytes once at the top (encoding str via UTF-8 with replacement) and run a single byte-precise trim — both paths now respect the byte budget identically. * ``_notify`` gains a ``reload_viewer`` keyword. The YouTube task's intermediate notification (after writing title/duration but before chaining into normalize_video_asset, while is_processing is still True) now passes ``reload_viewer=False``. The browser-side dashboard nudge still fires so the operator sees the resolved title immediately; the on-device viewer doesn't reload its playlist for a row that's still mid-flight. The chained normalize step's _notify (which runs once is_processing clears and the file is final) handles the actual viewer reload — saves the viewer one redundant playlist refresh per YouTube upload. Tests: * ``test_notify_browser_only_skips_viewer_reload`` exercises the new flag. * The YouTube-success test now mocks Redis and asserts ``publish.assert_not_called()`` to lock in the no-intermediate- reload contract.
* processing.py: _set_processing_error docstring listed "encrypted PDF" as a permanent-failure case from the issue's three-workstream framing. PDF is explicitly out of scope for this PR — replaced with concrete failure modes the current image/video tasks actually surface. * docker-compose.yml.tmpl: "a single configure here" reads as a verb. Changed to "a single configuration here".
Real security gap Copilot caught: Pillow happily allocates pixel buffers proportional to ``width × height`` regardless of how small the source file is on disk. A few KB of crafted bytes advertising a 1,000,000×1,000,000 image would force the celery worker to attempt a multi-TB allocation — at best a hard OOM that kills the worker and stalls the upload pipeline; at worst a swap-storm that drags the on-device viewer with it. Pillow ships ``MAX_IMAGE_PIXELS`` (default ~89 MP) which raises ``DecompressionBombError`` past 2× that threshold and warns softly at the first level. That default is too lax for signage content (where 4K @ 8 MP is already large) and pillow-heif's own decoder can bypass the check on certain HEIF/AVIF inputs. Two layers of protection: 1. ``_MAX_IMAGE_PIXELS = 50_000_000`` constant — bigger than any legitimate phone-camera output (modern flagships top out around 50 MP at the standard 4:3 aspect after JPEG/HEIC compression) but tiny compared to typical bomb fixtures. 2. ``_convert_image_to_webp`` reads ``image.size`` from the format header *before* any decode and raises ValueError if the dimensions exceed the cap. The on_failure path writes the message to ``metadata.error_message`` like any other permanent failure. Lowering Pillow's global ``Image.MAX_IMAGE_PIXELS`` to the same value protects any future call site that goes through ``Image.open`` outside this helper. New test ``test_image_decompression_bomb_is_rejected`` mocks ``Image.open`` to return a stub whose ``.size`` exceeds the cap (synthesising a real billion-pixel fixture would itself need GBs of memory) and asserts the runner raises before any ``convert()`` / ``save()`` is reached.
Inline comment listed only HEIC/HEIF/TIFF/BMP, but the constant it points at also covers ICO/TGA/JP2 family/AVIF. Rewrote to reference the constant as source of truth and enumerate the current set so the comment stops drifting on the next addition.
Two real bugs Copilot caught: * ``_set_processing_error`` cleared ``is_processing`` but left ``is_enabled=True``, so a failed normalisation would still get queued for playback by the viewer's scheduler (which filters on is_enabled + date window only — it doesn't check ``metadata.error_message``). The on-screen result was a black rectangle for the row's duration. Flipping ``is_enabled=False`` alongside ``is_processing=False`` keeps the bad row out of rotation; the operator can re-enable from the dashboard once the underlying issue is fixed. The ``error-pill`` template already replaces the active toggle so the operator sees the failure state before they re-enable. * ``assets_upload`` deferred to ``mimetypes.guess_type`` first and only consulted ``UploadedFile.content_type`` when guess_type produced no image/video classification. If an operator renamed a HEIC to ``photo.jpg`` and uploaded it, guess_type returned ``image/jpeg`` (a passthrough type), the Content-Type fallback was skipped, the file landed as ``.jpg``, and the normalise pipeline never ran — a silent failure-to-render. Modern browsers sniff the actual bytes and tag the upload with ``image/heic`` regardless of filename, so the view now cross-checks: when guess_type and Content-Type share a top-level (image/* or video/*) but disagree on subtype, AND Content-Type's subtype maps to a NORMALIZE_IMAGE_EXTS extension, prefer Content-Type. Only upgrades — never downgrades — to avoid the inverse case (a JPEG mis-tagged as image/heic by the browser somehow) accidentally routing into the pipeline. Tests: * test_set_processing_error_writes_metadata extended to assert is_enabled flips to False alongside the error message write. * New test_assets_upload_misnamed_heic_uses_browser_content_type uploads HEIC bytes named ``photo.jpg`` and asserts the file lands as .heic with the normalise task dispatched.
…builds
Real concern Copilot caught about the Pillow / pillow-heif
introduction: neither ships armv7l manylinux wheels (Pillow 11
explicitly dropped them in its release notes; pillow-heif only
publishes x86_64 / aarch64). uv's resolution on a pi2 / pi3
image build therefore falls back to sdist, and the existing
``builder_extra_apt`` only covers libcec / libdbus headers — the
``uv sync`` step would gcc-fail at the first JPEG / HEIF binding.
Extend ``get_uv_builder_context`` to take a ``board`` argument
and append the Pillow / pillow-heif build-time deps when
``service='server'`` and ``board in {'pi2', 'pi3'}``. 64-bit
boards (pi4-64 / pi5 / x86) and the test image still get binary
wheels and the apt list stays unchanged for them — adding the
deps unconditionally would waste ~70 MB of layer space on every
non-armv7 build.
Pillow's documented build deps:
libjpeg62-turbo-dev / libfreetype-dev / liblcms2-dev /
libopenjp2-7-dev / libtiff-dev / libwebp-dev / zlib1g-dev
pillow-heif: libheif-dev (the libheif1 runtime is already in
``base_apt_dependencies`` for both architectures).
Verified: ``--build-target pi3`` now generates a Dockerfile that
installs the new build deps; ``--build-target pi5`` does not.
|
This was referenced May 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Implements the image and video workstreams from #2812 (PDF deferred to a follow-up).
Video — every upload runs through
ffprobe. The transcode target is picked from a per-board grid keyed onDEVICE_TYPE(set in the Dockerfile by the image builder):pi2,pi3pi4-64-tag:v hvc1)pi5x86Passthrough decisions are per-board too: an HEVC upload on
pi3is no longer let through unplayable — it gets re-encoded to H.264. An H.264 upload onpi5still passes through unchanged. Container detection uses ffprobe'sformat.format_nameso a wrong/missing extension can't smuggle a non-passthrough format past the check. ffmpeg failures decode the bytes-stderr to UTF-8 + tail-trim before surfacing viametadata.error_message.Image — the normalisation set widened to HEIC, HEIF, TIFF, BMP, ICO, TGA, JPEG 2000 (
.jp2/.j2k/.jpx/.jpc/.jpf), AVIF, all converted to lossless WebP. JPEG / PNG / WebP / GIF / SVG are left alone. Reasoning is in theNORMALIZE_IMAGE_EXTSdocstring; all decoders use Pillow's built-in path (onlypillow-heifis needed beyond Pillow itself, already added).YouTube unification —
download_youtube_assetnow seedsmetadata['source']='youtube'+source_url, leavesis_processing=True, and chains intonormalize_video_asset. yt-dlp'sformat_sort: vcodec:h264is a preference not a guarantee — the chained normalize pass catches VP9/AV1 fallbacks and transcodes to whatever the board can decode. Failure path also unified:_DownloadYoutubeTask.on_failurereusesprocessing._set_processing_error+processing._notify, so failed YouTube downloads write the samemetadata.error_messageshape as failed normalisations.Asset.metadataJSONField (with migration) carriesoriginal_ext,transcoded/converted,transcode_target(the codec the device wanted, recorded on both passthrough and transcode paths),source/source_urlfor YouTube rows, anderror_messageon the failure path. Exposed read-only on every API version. Writes are owned by the upload-pipeline tasks; letting clients override would invite "transcoded=true but the file is the original" desyncs.anthias-celeryworker wrapped innice -n 19 ionice -c 3(compose templates) so transcodes never starve the on-device viewer. ffmpeg additionally pinned to-threads 2so two cores stay free regardless of scheduler decisions. Both inherited by every subprocess (ffmpeg, ffprobe, Pillow's libheif binding).Failure-state contract (uniform across both tasks and YouTube): a permanent failure (corrupt HEIC, broken video, ffmpeg timeout, zero-byte output, missing ffprobe binary, libheif crash) writes
metadata.error_message, removes any partially-written staging file (both.webp.tmpand.staging.mp4paths share a_drop_staginghelper), and clearsis_processingvia the customTask.on_failure. Rows never stay stuck on the "Processing" pill.UI: error pill —
_asset_row.htmlrenders a warn-coloured "Failed" pill (same shape as the existing "Processing" pill, so the column layout stays stable across in-progress / failed / done states) whenmetadata.error_messageis populated andis_processingis clear. Full message on the hover tooltip andaria-label.Frontend
<input accept>widened to surface every accepted extension. The HTMXassets_uploadview classifies viamimetypes.guess_type→ browserContent-Type→ extension allowlist (the latter derived fromNORMALIZE_IMAGE_EXTSplus the always-accepted JPEG/PNG/WebP/GIF/SVG set, so adding a new normalisable format only touches one place).What's not in this PR
Asset.metadatais added now so the follow-up can carrydocument_pageswithout another migration.Test plan
uv run pytest -m "not integration"— 665 pass, 26 deselected.uv run ruff check .— clean.uv run ruff format --check .— clean.uv run mypy .(via themypygroup) — clean.needs_image_normalisationdecision table, corrupt-input failure path, JPEG no-op (including stale-error-message clearing), partial-write.webp.tmpcleanup, six-row video passthrough decision table, exotic-codec → board-target transcode (mpeg2, mjpeg), in-place transcode for non-H.264 .mp4, ffmpeg timeout/error/zero-byte/missing-binary cleanup (all converge through_drop_staging), ffprobe missing-stream parsing, ffprobeformat.format_nameresolution + extension fallback, ffprobeCommandNotFoundfallback,_format_subprocess_stderrdecode/trim, per-board target-codec grid, per-board passthrough decision (HEVC source on pi3 must transcode; on pi5 must passthrough), per-board ffmpeg argv (libx264 vs libx265+-tag:v hvc1), passthrough rows recordtranscode_target, YouTube success → chainednormalize_video_assetdispatch, YouTube failure → metadata.error_message, asset row template renders error-pill when metadata.error_message is set,on_failuremetadata write,prepare_assetrouting for HEIC / video / remote URL / JPEG uploads, HTMX upload classification through Content-Type whenmimetypes.guess_typereturns None.🤖 Generated with Claude Code