diff --git a/lib/bds/media.ex b/lib/bds/media.ex index c1350d8..813454f 100644 --- a/lib/bds/media.ex +++ b/lib/bds/media.ex @@ -13,6 +13,8 @@ defmodule BDS.Media do project = Projects.get_project!(attr(attrs, :project_id)) source_path = attr(attrs, :source_path) original_name = Path.basename(source_path) + mime_type = detect_mime(original_name) + {width, height} = image_dimensions(source_path, mime_type) now = System.system_time(:second) file_name = Ecto.UUID.generate() <> Path.extname(original_name) file_path = media_file_path(file_name, now) @@ -28,10 +30,10 @@ defmodule BDS.Media do project_id: project.id, filename: file_name, original_name: original_name, - mime_type: detect_mime(original_name), + mime_type: mime_type, size: stat.size, - width: attr(attrs, :width), - height: attr(attrs, :height), + width: attr(attrs, :width) || width, + height: attr(attrs, :height) || height, title: attr(attrs, :title), alt: attr(attrs, :alt), caption: attr(attrs, :caption), @@ -167,6 +169,18 @@ defmodule BDS.Media do } end + def regenerate_thumbnails(media_id) do + case Repo.get(Media, media_id) do + nil -> + {:error, :not_found} + + media -> + project = Projects.get_project!(media.project_id) + :ok = ensure_thumbnails(project, media) + {:ok, media} + end + end + def rebuild_media_from_files(project_id) do project = Projects.get_project!(project_id) @@ -309,20 +323,59 @@ defmodule BDS.Media do if image_mime?(media.mime_type) do source_path = Path.join(Projects.project_data_dir(project), media.file_path) - Enum.each(thumbnail_paths(media), fn {_size, relative_path} -> - destination = Path.join(Projects.project_data_dir(project), relative_path) - :ok = File.mkdir_p(Path.dirname(destination)) + case Image.open(source_path) do + {:ok, image} -> + image + |> Image.autorotate!() + |> write_all_thumbnails(project, media) - case File.read(source_path) do - {:ok, contents} -> :ok = File.write(destination, contents) - {:error, _reason} -> :ok = File.write(destination, "") - end - end) + {:error, _reason} -> + :ok + end end :ok end + defp write_all_thumbnails(image, project, media) do + thumbnail_paths(media) + |> Enum.each(fn {size, relative_path} -> + destination = Path.join(Projects.project_data_dir(project), relative_path) + :ok = File.mkdir_p(Path.dirname(destination)) + + image + |> render_thumbnail(size) + |> write_thumbnail(destination, size) + end) + + :ok + end + + defp render_thumbnail(image, :small), do: bounded_thumbnail(image, 150, 150) + defp render_thumbnail(image, :medium), do: bounded_thumbnail(image, 400, 400) + defp render_thumbnail(image, :large), do: bounded_thumbnail(image, 800, 800) + + defp render_thumbnail(image, :ai) do + image + |> Image.thumbnail!("448x448", fit: :contain, resize: :both, autorotate: false) + |> Image.embed!(448, 448, x: :center, y: :center, background_color: :black) + end + + defp bounded_thumbnail(image, width, height) do + Image.thumbnail!(image, "#{width}x#{height}", fit: :contain, resize: :down, autorotate: false) + end + + defp write_thumbnail(image, destination, :ai) do + flattened = Image.flatten!(image, background_color: :black) + Image.write!(flattened, destination, quality: 85, strip_metadata: true) + :ok + end + + defp write_thumbnail(image, destination, _size) do + Image.write!(image, destination, quality: 80, strip_metadata: true) + :ok + end + defp delete_thumbnail_files(project_id, media) do Enum.each(Map.values(thumbnail_paths(media)), fn path -> delete_file_if_present(project_id, path) @@ -347,10 +400,26 @@ defmodule BDS.Media do ".png" -> "image/png" ".gif" -> "image/gif" ".webp" -> "image/webp" + ".tif" -> "image/tiff" + ".tiff" -> "image/tiff" + ".bmp" -> "image/bmp" + ".heic" -> "image/heic" + ".heif" -> "image/heif" _ -> "application/octet-stream" end end + defp image_dimensions(source_path, mime_type) do + if image_mime?(mime_type) do + case Image.open(source_path) do + {:ok, image} -> {Image.width(image), Image.height(image)} + {:error, _reason} -> {nil, nil} + end + else + {nil, nil} + end + end + defp image_mime?(mime_type), do: String.starts_with?(mime_type || "", "image/") defp binary_exists_for_sidecar?(sidecar_path) do diff --git a/mix.exs b/mix.exs index c544dfa..94a6d9d 100644 --- a/mix.exs +++ b/mix.exs @@ -24,7 +24,9 @@ defmodule BDS.MixProject do {:ecto_sql, "~> 3.13"}, {:ecto_sqlite3, "~> 0.21"}, {:luerl, "~> 1.5"}, - {:jason, "~> 1.4"} + {:jason, "~> 1.4"}, + {:plug, "~> 1.18"}, + {:image, "~> 0.65"} ] end diff --git a/mix.lock b/mix.lock index e6fe8f5..5e39534 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, @@ -7,7 +8,14 @@ "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, + "image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"}, } diff --git a/test/bds/media_test.exs b/test/bds/media_test.exs index 0e30755..18c7654 100644 --- a/test/bds/media_test.exs +++ b/test/bds/media_test.exs @@ -110,7 +110,7 @@ defmodule BDS.MediaTest do binary_path = Path.join(media_dir, "asset.jpg") sidecar_path = binary_path <> ".meta" - File.write!(binary_path, "fake-jpeg") + File.write!(binary_path, tiny_jpeg_binary()) File.write!( sidecar_path, @@ -118,9 +118,9 @@ defmodule BDS.MediaTest do "id: media-from-file", "original_name: original.jpg", "mime_type: image/jpeg", - "size: 9", - "width: 0", - "height: 0", + "size: #{byte_size(tiny_jpeg_binary())}", + "width: 3", + "height: 2", "title: Recovered", "alt: Recovered alt", "caption: Recovered caption", @@ -157,7 +157,7 @@ defmodule BDS.MediaTest do assert media.filename == "asset.jpg" assert media.original_name == "original.jpg" assert media.mime_type == "image/jpeg" - assert media.size == 9 + assert media.size == byte_size(tiny_jpeg_binary()) assert media.title == "Recovered" assert media.alt == "Recovered alt" assert media.caption == "Recovered caption" @@ -183,7 +183,7 @@ defmodule BDS.MediaTest do test "import_media generates the four thumbnail files in bucketed thumbnail paths", %{project: project, temp_dir: temp_dir} do source_path = Path.join(temp_dir, "sample.jpg") - File.write!(source_path, "fake-jpeg") + File.write!(source_path, tiny_jpeg_binary()) assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) @@ -198,6 +198,108 @@ defmodule BDS.MediaTest do end) end + test "import_media extracts image dimensions and writes real encoded thumbnails", %{project: project, temp_dir: temp_dir} do + source_path = Path.join(temp_dir, "sample.jpg") + File.write!(source_path, tiny_jpeg_binary()) + + assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + + assert media.mime_type == "image/jpeg" + assert media.width == 3 + assert media.height == 2 + + thumbnail_paths = BDS.Media.thumbnail_paths(media) + + small = Image.open!(Path.join(temp_dir, thumbnail_paths.small)) + medium = Image.open!(Path.join(temp_dir, thumbnail_paths.medium)) + large = Image.open!(Path.join(temp_dir, thumbnail_paths.large)) + ai = Image.open!(Path.join(temp_dir, thumbnail_paths.ai)) + + assert Image.width(small) == 3 + assert Image.height(small) == 2 + assert Image.width(medium) == 3 + assert Image.height(medium) == 2 + assert Image.width(large) == 3 + assert Image.height(large) == 2 + assert Image.width(ai) == 448 + assert Image.height(ai) == 448 + + assert Path.extname(thumbnail_paths.small) == ".webp" + assert Path.extname(thumbnail_paths.medium) == ".webp" + assert Path.extname(thumbnail_paths.large) == ".webp" + assert Path.extname(thumbnail_paths.ai) == ".jpg" + end + + test "import_media keeps raw header dimensions but autorotates thumbnails from EXIF orientation", %{project: project, temp_dir: temp_dir} do + source_path = Path.join(temp_dir, "rotated.jpg") + write_oriented_jpeg!(source_path, 6) + + assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + + assert media.width == 2 + assert media.height == 3 + + thumbnail_path = Path.join(temp_dir, BDS.Media.thumbnail_paths(media).small) + actual_thumbnail = Image.open!(thumbnail_path) + expected_thumbnail = expected_small_thumbnail(source_path) + + assert Image.width(actual_thumbnail) == 3 + assert Image.height(actual_thumbnail) == 2 + assert_images_match!(actual_thumbnail, expected_thumbnail) + end + + test "regenerate_thumbnails recreates thumbnail files for an existing image media item", %{project: project, temp_dir: temp_dir} do + source_path = Path.join(temp_dir, "sample.jpg") + File.write!(source_path, tiny_jpeg_binary()) + + assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + + thumbnail_paths = BDS.Media.thumbnail_paths(media) + File.rm!(Path.join(temp_dir, thumbnail_paths.small)) + File.rm!(Path.join(temp_dir, thumbnail_paths.medium)) + + assert {:ok, regenerated} = BDS.Media.regenerate_thumbnails(media.id) + assert regenerated.id == media.id + + Enum.each(Map.values(thumbnail_paths), fn path -> + assert File.exists?(Path.join(temp_dir, path)) + end) + end + + test "import_media generates thumbnails for png and webp sources", %{project: project, temp_dir: temp_dir} do + Enum.each([{ ".png", "image/png"}, {".webp", "image/webp"}], fn {extension, mime_type} -> + source_path = Path.join(temp_dir, "sample#{extension}") + File.write!(source_path, sample_image_binary(extension)) + + assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert media.mime_type == mime_type + assert media.width == 2 + assert media.height == 3 + + Enum.each(Map.values(BDS.Media.thumbnail_paths(media)), fn path -> + assert File.exists?(Path.join(temp_dir, path)) + end) + end) + end + + test "import_media detects supported TIFF, BMP, HEIC, and HEIF extensions", %{project: project, temp_dir: temp_dir} do + Enum.each([ + {"asset.tif", "image/tiff"}, + {"asset.tiff", "image/tiff"}, + {"asset.bmp", "image/bmp"}, + {"asset.heic", "image/heic"}, + {"asset.heif", "image/heif"} + ], fn {file_name, mime_type} -> + source_path = Path.join(temp_dir, file_name) + File.write!(source_path, "placeholder") + + assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert media.mime_type == mime_type + assert media.width == nil + assert media.height == nil + end) + end + test "upsert_media_translation persists the row and writes a translated sidecar next to the binary", %{project: project, temp_dir: temp_dir} do source_path = Path.join(temp_dir, "sample.txt") File.write!(source_path, "hello media") @@ -223,4 +325,55 @@ defmodule BDS.MediaTest do assert contents =~ "alt: Alt text\n" assert contents =~ "caption: Bildunterschrift\n" end + + defp tiny_jpeg_binary do + Image.new!(3, 2, color: [255, 0, 0]) + |> Image.write!(:memory, suffix: ".jpg", quality: 85) + end + + defp sample_image_binary(extension) do + sample_svg_binary() + |> Image.from_svg!() + |> Image.write!(:memory, suffix: extension, quality: 85) + end + + defp sample_svg_binary do + """ + + + + + + + + + """ + end + + defp write_oriented_jpeg!(path, orientation) do + image = sample_image_binary(".jpg") |> Image.open!() + + {:ok, oriented_image} = + Vix.Vips.Image.mutate(image, fn mutable_image -> + :ok = Vix.Vips.MutableImage.update(mutable_image, "orientation", orientation) + end) + + Image.write!(oriented_image, path, quality: 85) + end + + defp expected_small_thumbnail(source_path) do + source_path + |> Image.open!() + |> Image.autorotate!() + |> Image.thumbnail!("150x150", fit: :contain, resize: :down, autorotate: false) + |> Image.write!(:memory, suffix: ".webp", quality: 80, strip_metadata: true) + |> Image.open!() + end + + defp assert_images_match!(left, right) do + assert Image.shape(left) == Image.shape(right) + + assert {:ok, score, _difference_image} = Image.compare(left, right) + assert score <= 0.0 + end end