feat: media handling

This commit is contained in:
2026-04-23 16:22:32 +02:00
parent d3dda2bd40
commit b6255122a9
4 changed files with 250 additions and 18 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"},
}

View File

@@ -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
"""
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2\" height=\"3\">
<rect width=\"1\" height=\"1\" x=\"0\" y=\"0\" fill=\"#ff0000\"/>
<rect width=\"1\" height=\"1\" x=\"1\" y=\"0\" fill=\"#00ff00\"/>
<rect width=\"1\" height=\"1\" x=\"0\" y=\"1\" fill=\"#0000ff\"/>
<rect width=\"1\" height=\"1\" x=\"1\" y=\"1\" fill=\"#ffff00\"/>
<rect width=\"1\" height=\"1\" x=\"0\" y=\"2\" fill=\"#00ffff\"/>
<rect width=\"1\" height=\"1\" x=\"1\" y=\"2\" fill=\"#ff00ff\"/>
</svg>
"""
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