mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
feat(processing): normalise BMP, ICO, TGA, JPEG 2000, and AVIF to WebP
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.
This commit is contained in:
@@ -99,7 +99,7 @@
|
||||
WebP, exotic video codecs → H.264 MP4) makes the wider
|
||||
accept range safe to surface here. #}
|
||||
<input type="file" class="hidden" id="add-file" name="file_upload"
|
||||
accept="image/*,video/*,.heic,.heif,.tif,.tiff,.mov,.m4v,.mkv,.webm,.avi,.mpg,.mpeg,.ts,.flv"
|
||||
accept="image/*,video/*,.heic,.heif,.tif,.tiff,.bmp,.ico,.tga,.jp2,.j2k,.jpx,.jpc,.jpf,.avif,.mov,.m4v,.mkv,.webm,.avi,.mpg,.mpeg,.ts,.flv"
|
||||
@change="if ($event.target.files.length) { uploadFileName = $event.target.files[0].name; $event.target.form.requestSubmit() }">
|
||||
</label>
|
||||
|
||||
|
||||
@@ -342,16 +342,17 @@ def assets_upload(request: HttpRequest) -> HttpResponse:
|
||||
# "ffprobe + write duration" the old probe_video_duration
|
||||
# did, and the transcode branch covers the non-H.264 case
|
||||
# this issue exists to fix.
|
||||
# * HEIC / HEIF / TIFF images are converted to lossless WebP.
|
||||
# Other images (JPEG / PNG / WebP / GIF) skip the pipeline
|
||||
# entirely and land ready-to-play.
|
||||
# * HEIC / HEIF / TIFF / BMP images are converted to lossless
|
||||
# WebP. Other images (JPEG / PNG / WebP / GIF) skip the
|
||||
# pipeline entirely and land ready-to-play. The exact set
|
||||
# lives in anthias_server.processing.NORMALIZE_IMAGE_EXTS so
|
||||
# adding a new format only touches one place.
|
||||
from anthias_server.processing import needs_image_normalisation
|
||||
|
||||
is_video = mimetype == 'video'
|
||||
needs_image_normalize = mimetype == 'image' and src_ext.lower() in {
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
}
|
||||
needs_image_normalize = mimetype == 'image' and needs_image_normalisation(
|
||||
final_path
|
||||
)
|
||||
is_processing = is_video or needs_image_normalize
|
||||
|
||||
duration = settings['default_duration']
|
||||
|
||||
@@ -225,10 +225,51 @@ def _resolve_board_profile() -> _BoardProfile:
|
||||
return _BOARD_PROFILES.get(device_type, _DEFAULT_PROFILE)
|
||||
|
||||
|
||||
# Image extensions we route through the conversion task. Anything not
|
||||
# in this set is left as-is — the existing pipeline already handles
|
||||
# JPEG/PNG/WebP/GIF/BMP via direct Qt webview rendering.
|
||||
NORMALIZE_IMAGE_EXTS = frozenset({'.heic', '.heif', '.tif', '.tiff'})
|
||||
# Image extensions we route through the conversion task. The
|
||||
# motivation differs by format:
|
||||
#
|
||||
# * HEIC / HEIF — Qt webview can't render them at all on most
|
||||
# boards (libheif binding is server-side only via pillow-heif).
|
||||
# * TIFF — patchy browser support; a multi-page TIFF flattens
|
||||
# awkwardly. Normalising flattens it once, deterministically.
|
||||
# * BMP — uncompressed; a 4K BMP is ~30 MB vs ~1 MB as WebP.
|
||||
# Browsers do render BMP, but the on-disk size matters on a Pi
|
||||
# and BMP is a one-shot convert (Pillow built-in, no apt dep).
|
||||
# * ICO — Windows icons. Often multi-frame; the largest frame is
|
||||
# what we want, flattened to a single WebP.
|
||||
# * TGA — Truevision Targa (screenshot tools, game assets). No
|
||||
# browser support.
|
||||
# * JPEG 2000 (.jp2/.j2k/.jpx/.jpc/.jpf) — scanner output. No
|
||||
# browser support.
|
||||
# * AVIF — modern phone exports / Android camera output. Modern
|
||||
# Chromium renders AVIF, but the Qt5 WebEngine on legacy Pi 2/3
|
||||
# predates the AVIF support in Chromium 85, so converting on
|
||||
# upload guarantees the viewer renders correctly across the
|
||||
# fleet without per-board branching.
|
||||
#
|
||||
# JPEG / PNG / WebP / GIF / SVG stay untouched — already
|
||||
# viewer-friendly *and* well-compressed.
|
||||
#
|
||||
# All formats above are handled by Pillow's built-in decoders (no
|
||||
# extra apt or wheel dependency beyond pillow-heif, which is
|
||||
# already required for HEIC/HEIF).
|
||||
NORMALIZE_IMAGE_EXTS = frozenset(
|
||||
{
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
'.bmp',
|
||||
'.ico',
|
||||
'.tga',
|
||||
'.jp2',
|
||||
'.j2k',
|
||||
'.jpx',
|
||||
'.jpc',
|
||||
'.jpf',
|
||||
'.avif',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def needs_image_normalisation(uri_or_filename: str) -> bool:
|
||||
|
||||
@@ -220,19 +220,55 @@ def _make_video(
|
||||
@pytest.mark.parametrize(
|
||||
('fmt', 'ext'),
|
||||
[
|
||||
# TIFF — both extension spellings must route through the
|
||||
# same path; covers the multi-page-flatten case implicitly
|
||||
# (Pillow opens the first frame).
|
||||
('TIFF', '.tif'),
|
||||
('TIFF', '.tiff'),
|
||||
# BMP — uncompressed source, dramatic size win after WebP.
|
||||
('BMP', '.bmp'),
|
||||
# ICO — Windows icon. Pillow saves+reloads as a single
|
||||
# frame, which is what we want for a signage asset.
|
||||
('ICO', '.ico'),
|
||||
# TGA — Truevision Targa, common in screenshot tools.
|
||||
('TGA', '.tga'),
|
||||
# JPEG 2000 family — every Pillow-recognised extension we
|
||||
# accept must round-trip.
|
||||
('JPEG2000', '.jp2'),
|
||||
('JPEG2000', '.j2k'),
|
||||
('JPEG2000', '.jpx'),
|
||||
# AVIF — modern phone camera output. Pillow 12+ supports
|
||||
# AVIF natively; round-trip proves the libavif binding is
|
||||
# actually linked at runtime, not just registered.
|
||||
('AVIF', '.avif'),
|
||||
],
|
||||
)
|
||||
def test_image_tiff_converts_to_lossless_webp(
|
||||
def test_image_normalises_to_lossless_webp_across_formats(
|
||||
asset_dir: str, fmt: str, ext: str
|
||||
) -> None:
|
||||
"""TIFF → WebP, original removed, row's URI swapped, metadata
|
||||
populated. Both ``.tif`` and ``.tiff`` extensions route through
|
||||
the same path."""
|
||||
"""Every entry in ``NORMALIZE_IMAGE_EXTS`` produces a valid WebP
|
||||
output: the original is removed, the row's URI is swapped to the
|
||||
new ``.webp`` path, ``metadata.original_ext`` carries the source
|
||||
extension, and the resulting file actually decodes back as WebP
|
||||
(not a stub or a bytewise-renamed source).
|
||||
|
||||
Parametrising over the full grid catches a future change to
|
||||
``_convert_image_to_webp`` that breaks one decoder while leaving
|
||||
the others intact — e.g. a move to a non-RGBA convert mode would
|
||||
crash JPEG2000 (which Pillow opens in mode 'RGB' by default for
|
||||
some files), and a Pillow version drop that loses libavif would
|
||||
fail AVIF specifically. Each case is one assertion per
|
||||
invariant; failures point at one row in the matrix."""
|
||||
src = path.join(asset_dir, f'fixture{ext}')
|
||||
_write_image(src, fmt)
|
||||
asset = _make_processing_asset('img-tiff', src)
|
||||
# AVIF needs RGB (Pillow's libavif binding doesn't accept RGBA on
|
||||
# write); ICO/TGA/JP2/BMP all accept RGBA. The conversion target
|
||||
# (``_convert_image_to_webp``'s internal ``.convert('RGBA')``) is
|
||||
# what unifies — just make sure the SOURCE encodes happily.
|
||||
if fmt in ('AVIF', 'JPEG2000'):
|
||||
_write_image(src, fmt, mode='RGB', colour=(0, 200, 0))
|
||||
else:
|
||||
_write_image(src, fmt)
|
||||
asset = _make_processing_asset(f'img-{fmt.lower()}', src)
|
||||
|
||||
with mock.patch.object(processing, '_notify') as notify:
|
||||
processing._run_image_normalisation(asset)
|
||||
@@ -241,12 +277,12 @@ def test_image_tiff_converts_to_lossless_webp(
|
||||
expected_uri = path.join(asset_dir, 'fixture.webp')
|
||||
assert asset.uri == expected_uri
|
||||
assert path.isfile(expected_uri)
|
||||
assert not path.exists(src), 'original tiff must be removed'
|
||||
assert not path.exists(src), f'original {ext} must be removed'
|
||||
assert asset.is_processing is False
|
||||
assert asset.mimetype == 'image'
|
||||
assert asset.metadata['original_ext'] == ext
|
||||
assert asset.metadata['converted'] is True
|
||||
notify.assert_called_once_with('img-tiff')
|
||||
notify.assert_called_once_with(f'img-{fmt.lower()}')
|
||||
|
||||
# Round-trip the WebP — proves the file isn't a stub.
|
||||
with Image.open(expected_uri) as im:
|
||||
@@ -1212,15 +1248,35 @@ def test_normalize_on_failure_no_args_is_safe() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
('filename', 'expected'),
|
||||
[
|
||||
# Every entry in NORMALIZE_IMAGE_EXTS routes through; case
|
||||
# variants exercise the case-insensitive lstrip-and-lower
|
||||
# path in ``_ext``.
|
||||
('foo.heic', True),
|
||||
('FOO.HEIC', True),
|
||||
('foo.heif', True),
|
||||
('foo.tif', True),
|
||||
('foo.tiff', True),
|
||||
('foo.bmp', True),
|
||||
('foo.BMP', True),
|
||||
('foo.ico', True),
|
||||
('foo.tga', True),
|
||||
('foo.jp2', True),
|
||||
('foo.j2k', True),
|
||||
('foo.jpx', True),
|
||||
('foo.jpc', True),
|
||||
('foo.jpf', True),
|
||||
('foo.avif', True),
|
||||
# Already-friendly formats stay untouched — no Celery hop.
|
||||
('foo.jpg', False),
|
||||
('foo.jpeg', False),
|
||||
('foo.png', False),
|
||||
('foo.webp', False),
|
||||
('foo.gif', False),
|
||||
('foo.svg', False),
|
||||
# No extension and unknown extensions also fall through to
|
||||
# the no-op branch.
|
||||
('foo', False),
|
||||
('foo.psd', False),
|
||||
],
|
||||
)
|
||||
def test_needs_image_normalisation(filename: str, expected: bool) -> None:
|
||||
|
||||
Reference in New Issue
Block a user