From 6e9995218483783fadabcc8fc34243f596f4a8b6 Mon Sep 17 00:00:00 2001 From: mdashlw Date: Sun, 8 Feb 2026 01:00:24 +0400 Subject: [PATCH] Better ICC profile handling in image processing --- docker/mediaproc/Dockerfile | 4 +- lib/philomena_media/icc.ex | 53 +++++++++++++++++++++++++ lib/philomena_media/processors/jpeg.ex | 38 ++++-------------- lib/philomena_media/processors/png.ex | 26 +++++++++--- lib/philomena_media/strip.ex | 36 +++++++++++++++++ priv/icc/reference.png | Bin 0 -> 99 bytes priv/icc/reference.rgb | Bin 0 -> 18 bytes 7 files changed, 118 insertions(+), 39 deletions(-) create mode 100644 lib/philomena_media/icc.ex create mode 100644 lib/philomena_media/strip.ex create mode 100644 priv/icc/reference.png create mode 100644 priv/icc/reference.rgb diff --git a/docker/mediaproc/Dockerfile b/docker/mediaproc/Dockerfile index f606aab59..b464b50e6 100644 --- a/docker/mediaproc/Dockerfile +++ b/docker/mediaproc/Dockerfile @@ -2,7 +2,7 @@ FROM rust:1.91-slim-trixie AS builder RUN apt update \ && apt install -y build-essential git libmagic-dev libturbojpeg0-dev libpng-dev \ - gifsicle optipng libjpeg-turbo-progs librsvg2-bin librsvg2-dev file imagemagick \ + gifsicle optipng libjpeg-turbo-progs librsvg2-bin librsvg2-dev file imagemagick-7.q16hdri \ libx264-dev libx265-dev libvpx-dev libdav1d-dev libaom-dev libopus-dev \ libmp3lame-dev libvorbis-dev libwebp-dev libjxl-dev nasm wget @@ -69,7 +69,7 @@ FROM debian:trixie-slim # Programs and runtime libraries. RUN apt update \ - && apt install -y gifsicle optipng libjpeg-turbo-progs file imagemagick \ + && apt install -y gifsicle optipng libjpeg-turbo-progs file imagemagick-7.q16hdri \ libvpx9 libmp3lame0 libopus0 libvorbis0a libvorbisenc2 libx264-164 \ libx265-215 librsvg2-2 librsvg2-bin \ && rm -rf /var/lib/apt/lists/* diff --git a/lib/philomena_media/icc.ex b/lib/philomena_media/icc.ex new file mode 100644 index 000000000..ada81dcdf --- /dev/null +++ b/lib/philomena_media/icc.ex @@ -0,0 +1,53 @@ +defmodule PhilomenaMedia.Icc do + @moduledoc """ + ICC color profile handling for image files. + """ + + alias PhilomenaMedia.Remote + + @doc """ + Returns whether the embedded ICC profile is effectively equivalent to sRGB. + + This assumes the file has an embedded profile. If it does not, the extraction + will fail and this function returns `false`. + """ + @spec srgb_profile?(Path.t()) :: boolean() + def srgb_profile?(file) do + profile = Briefly.create!(extname: ".icc") + + with {_output, 0} <- Remote.cmd("magick", [file, profile]), + {test, 0} <- + Remote.cmd("magick", [ + reference_image(), + "-profile", + profile, + "-profile", + srgb_profile(), + "-depth", + "8", + "RGB:-" + ]), + {:ok, reference} <- File.read(reference_rgb()) do + test == reference + else + _ -> + false + end + end + + @doc """ + Returns the path to the bundled sRGB ICC profile. + """ + @spec srgb_profile() :: Path.t() + def srgb_profile do + Path.join(File.cwd!(), "priv/icc/sRGB.icc") + end + + defp reference_image do + Path.join(File.cwd!(), "priv/icc/reference.png") + end + + defp reference_rgb do + Path.join(File.cwd!(), "priv/icc/reference.rgb") + end +end diff --git a/lib/philomena_media/processors/jpeg.ex b/lib/philomena_media/processors/jpeg.ex index 30b2d5aff..08a3ab26c 100644 --- a/lib/philomena_media/processors/jpeg.ex +++ b/lib/philomena_media/processors/jpeg.ex @@ -5,6 +5,7 @@ defmodule PhilomenaMedia.Processors.Jpeg do alias PhilomenaMedia.Analyzers.Result alias PhilomenaMedia.Remote alias PhilomenaMedia.Processors.Processor + alias PhilomenaMedia.Strip alias PhilomenaMedia.Processors @behaviour Processor @@ -41,39 +42,18 @@ defmodule PhilomenaMedia.Processors.Jpeg do intensities end - defp requires_lossy_transformation?(file) do - with {output, 0} <- - Remote.cmd("magick", ["identify", "-format", "%[orientation]\t%[profile:icc]", file]), - [orientation, profile] <- String.split(output, "\t") do - orientation not in ["Undefined", "TopLeft"] or profile != "" - else - _ -> - true - end - end - defp strip(file) do - stripped = Briefly.create!(extname: ".jpg") - # ImageMagick always reencodes the image, resulting in quality loss, so # be more clever - if requires_lossy_transformation?(file) do - # Transcode: strip EXIF, embedded profile and reorient image - {_output, 0} = - Remote.cmd("magick", [ - file, - "-profile", - srgb_profile(), - "-auto-orient", - "-strip", - stripped - ]) + if Strip.requires_strip?(file) do + # Transcode: normalize orientation, ICC profile and strip metadata + Strip.strip(file, ".jpg") else - # Transmux only: Strip EXIF without touching orientation + # Transmux only: Strip EXIF without touching pixel data + stripped = Briefly.create!(extname: ".jpg") validate_return(Remote.cmd("jpegtran", ["-copy", "none", "-outfile", stripped, file])) + stripped end - - stripped end defp optimize(file) do @@ -107,10 +87,6 @@ defmodule PhilomenaMedia.Processors.Jpeg do [{:copy, scaled, "#{thumb_name}.jpg"}] end - defp srgb_profile do - Path.join(File.cwd!(), "priv/icc/sRGB.icc") - end - defp validate_return({_output, ret}) when ret in [@exit_success, @exit_warning] do :ok end diff --git a/lib/philomena_media/processors/png.ex b/lib/philomena_media/processors/png.ex index 24222ce8e..30d60d94e 100644 --- a/lib/philomena_media/processors/png.ex +++ b/lib/philomena_media/processors/png.ex @@ -5,6 +5,7 @@ defmodule PhilomenaMedia.Processors.Png do alias PhilomenaMedia.Analyzers.Result alias PhilomenaMedia.Remote alias PhilomenaMedia.Processors.Processor + alias PhilomenaMedia.Strip alias PhilomenaMedia.Processors @behaviour Processor @@ -18,14 +19,24 @@ defmodule PhilomenaMedia.Processors.Png do def process(analysis, file, versions) do animated? = analysis.animated? - {:ok, intensities} = Intensities.file(file) + stripped = strip(file, animated?) + + {:ok, intensities} = Intensities.file(stripped) - scaled = Enum.flat_map(versions, &scale(file, animated?, &1)) + scaled = Enum.flat_map(versions, &scale(stripped, animated?, &1)) - [ - intensities: intensities, - thumbnails: scaled - ] + if stripped != file do + [ + replace_original: stripped, + intensities: intensities, + thumbnails: scaled + ] + else + [ + intensities: intensities, + thumbnails: scaled + ] + end end @spec post_process(Result.t(), Path.t()) :: Processors.edit_script() @@ -44,6 +55,9 @@ defmodule PhilomenaMedia.Processors.Png do intensities end + defp strip(file, true = _animated?), do: file + defp strip(file, _animated?), do: Strip.strip(file, ".png") + # Sobelow misidentifies removing the .bak file # sobelow_skip ["Traversal.FileModule"] defp optimize(file) do diff --git a/lib/philomena_media/strip.ex b/lib/philomena_media/strip.ex new file mode 100644 index 000000000..64eb99438 --- /dev/null +++ b/lib/philomena_media/strip.ex @@ -0,0 +1,36 @@ +defmodule PhilomenaMedia.Strip do + @moduledoc false + + alias PhilomenaMedia.Icc + alias PhilomenaMedia.Remote + + @spec requires_strip?(Path.t()) :: boolean() + def requires_strip?(file) do + with {output, 0} <- + Remote.cmd("magick", ["identify", "-format", "%[orientation]\t%[profile:icc]", file]), + [orientation, profile] <- String.split(output, "\t") do + orientation not in ["Undefined", "TopLeft"] or + (profile != "" and not Icc.srgb_profile?(file)) + else + _ -> + true + end + end + + @spec strip(Path.t(), String.t()) :: Path.t() + def strip(file, extname) do + stripped = Briefly.create!(extname: extname) + + {_output, 0} = + Remote.cmd("magick", [ + file, + "-profile", + Icc.srgb_profile(), + "-auto-orient", + "-strip", + stripped + ]) + + stripped + end +end diff --git a/priv/icc/reference.png b/priv/icc/reference.png new file mode 100644 index 0000000000000000000000000000000000000000..55feb9454fc6efb4f4e697bf04777841aee3ef5c GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^Y(UJ&!VDz&pC395q=W)|LR of^~sh9#0p?5RU7~2_a>y3=ESPvI6_;B7ouyp00i_>zopr0JsAc^Z)<= literal 0 HcmV?d00001 diff --git a/priv/icc/reference.rgb b/priv/icc/reference.rgb new file mode 100644 index 0000000000000000000000000000000000000000..e441b630136c2b107db256085dfcc407b2533161 GIT binary patch literal 18 UcmWd-5NH4b1_lN&c4#;N04Bl&u>b%7 literal 0 HcmV?d00001