diff --git a/assets/css/media_editor.css b/assets/css/media_editor.css index c08d03e..f6b5ebd 100644 --- a/assets/css/media_editor.css +++ b/assets/css/media_editor.css @@ -106,8 +106,8 @@ } [data-testid="media-editor"] .media-preview-image img { - width: 100%; - height: 100%; + max-width: 100%; + max-height: 100%; object-fit: contain; border-radius: 4px; } diff --git a/lib/bds/desktop/media_controller.ex b/lib/bds/desktop/media_controller.ex index c68635d..c29a84f 100644 --- a/lib/bds/desktop/media_controller.ex +++ b/lib/bds/desktop/media_controller.ex @@ -8,16 +8,45 @@ defmodule BDS.Desktop.MediaController do alias BDS.Projects alias BDS.Repo - def thumbnail(conn, %{"media_id" => media_id} = params) do - case active_media_thumbnail(media_id, Map.get(params, "size")) do - {:ok, content_type, path} -> - conn - |> Plug.Conn.put_resp_content_type(content_type) - |> Plug.Conn.send_file(200, path) + @web_image_types ~w(image/jpeg image/png image/webp image/gif image/svg+xml image/avif) - :error -> - send_resp(conn, 404, "not found") + def thumbnail(conn, %{"media_id" => media_id} = params) do + send_media(conn, active_media_thumbnail(media_id, Map.get(params, "size"))) + end + + def file(conn, %{"media_id" => media_id}) do + send_media(conn, active_media_file(media_id)) + end + + defp send_media(conn, {:ok, content_type, path}) do + conn + |> Plug.Conn.put_resp_content_type(content_type) + |> Plug.Conn.send_file(200, path) + end + + defp send_media(conn, :error), do: send_resp(conn, 404, "not found") + + # Serves the original image at full resolution; formats browsers cannot + # render inline fall back to the large thumbnail. + defp active_media_file(media_id) do + with %{} = project <- Projects.get_active_project(), + %MediaRecord{} = media <- Repo.get(MediaRecord, media_id), + true <- media.project_id == project.id, + true <- media.mime_type in @web_image_types, + absolute_path = Path.join(Projects.project_data_dir(project), media.file_path), + true <- File.exists?(absolute_path) do + {:ok, media.mime_type, absolute_path} + else + _other -> active_media_thumbnail(media_id, "large") end + rescue + error in [Exqlite.Error, DBConnection.OwnershipError] -> + if match?(%Exqlite.Error{}, error) and + not String.contains?(Exception.message(error), "no such table") do + reraise error, __STACKTRACE__ + end + + :error end defp active_media_thumbnail(media_id, size) do diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex index 7aceba3..70f6f54 100644 --- a/lib/bds/desktop/router.ex +++ b/lib/bds/desktop/router.ex @@ -19,6 +19,7 @@ defmodule BDS.Desktop.Router do get("/health", HealthController, :show) get("/media-thumbnail/:media_id", MediaController, :thumbnail) + get("/media-file/:media_id", MediaController, :file) live_session :desktop_shell, root_layout: {BDS.Desktop.Layouts, :root} do diff --git a/lib/bds/desktop/shell_live/media_editor.ex b/lib/bds/desktop/shell_live/media_editor.ex index f00dc18..9b10b5f 100644 --- a/lib/bds/desktop/shell_live/media_editor.ex +++ b/lib/bds/desktop/shell_live/media_editor.ex @@ -645,7 +645,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do defp preview_url(%MediaRecord{} = media) do if image?(media), - do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", + do: "/media-file/#{media.id}?t=#{media.updated_at}", else: nil end diff --git a/specs/editor_media.allium b/specs/editor_media.allium index e51c80d..3fba3e0 100644 --- a/specs/editor_media.allium +++ b/specs/editor_media.allium @@ -116,7 +116,11 @@ surface MediaEditorSurface { -- Translate Metadata (globe icon). @guarantee PreviewArea - -- Images: rendered via bds-media:// protocol with cache-busting timestamp. + -- Images: rendered at full resolution via the /media-file/:id route + -- (serves the original file; falls back to the large thumbnail for + -- formats browsers cannot display inline) with cache-busting + -- timestamp. Never upscaled beyond natural size. The sidebar keeps + -- using small thumbnails. -- Non-images: SVG file icon placeholder + original filename text. @guarantee MetadataForm diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index c48c0fc..10ac5cb 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -492,6 +492,81 @@ defmodule BDS.DesktopTest do assert byte_size(conn.resp_body) > 0 end + test "desktop endpoint serves the original media file for the editor preview" do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = + Path.join(System.tmp_dir!(), "bds-desktop-media-file-#{System.unique_integer([:positive])}") + + File.mkdir_p!(temp_dir) + + on_exit(fn -> + File.rm_rf(temp_dir) + end) + + {:ok, project} = + BDS.Projects.create_project(%{name: "Desktop Media File", data_path: temp_dir}) + + {:ok, _active} = BDS.Projects.set_active_project(project.id) + + source_path = Path.join(temp_dir, "sample.jpg") + original_bytes = tiny_jpeg_binary() + File.write!(source_path, original_bytes) + + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + + conn = conn(:get, "/media-file/#{media.id}?k=#{Desktop.Auth.login_key()}") + conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([])) + + assert conn.status == 200 + assert [content_type] = Plug.Conn.get_resp_header(conn, "content-type") + assert String.starts_with?(content_type, "image/jpeg") + assert conn.resp_body == original_bytes + end + + test "desktop endpoint falls back to the large thumbnail for non-web-displayable media" do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = + Path.join( + System.tmp_dir!(), + "bds-desktop-media-fallback-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(temp_dir) + + on_exit(fn -> + File.rm_rf(temp_dir) + end) + + {:ok, project} = + BDS.Projects.create_project(%{name: "Desktop Media Fallback", data_path: temp_dir}) + + {:ok, _active} = BDS.Projects.set_active_project(project.id) + + 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}) + + # Simulate a format browsers cannot render inline (e.g. TIFF): thumbnails + # exist from import, but the original must not be served directly. + {:ok, _media} = + media + |> Ecto.Changeset.change(mime_type: "image/tiff") + |> BDS.Repo.update() + + conn = conn(:get, "/media-file/#{media.id}?k=#{Desktop.Auth.login_key()}") + conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([])) + + assert conn.status == 200 + assert [content_type] = Plug.Conn.get_resp_header(conn, "content-type") + assert String.starts_with?(content_type, "image/webp") + assert byte_size(conn.resp_body) > 0 + end + defp menu_item(groups, id) do groups |> Enum.flat_map(& &1.items)