defmodule PhoenixKitWeb.Components.ImageEditor do @moduledoc """ Edits a stored image after upload — crop, rotate, flip, straighten, redact, brightness and contrast — through `PhoenixKit.Modules.Storage.ImageEditing`. Everything is a server-rendered form, so the editor works with nothing but LiveView: numeric crop and region fields, buttons for the quarter turns, sliders for the rest. The `ImageEditor` hook adds drawing the crop and the redaction areas on the preview. The preview is CSS over the unedited original, so it is close to the result, not exact — tones in particular; the saved image is rendered by ImageMagick. ## Usage <.live_component module={PhoenixKitWeb.Components.ImageEditor} id={"image-editor-" <> @file.uuid} file={@file} scope={@phoenix_kit_current_scope} on_close={JS.push("close_image_editor")} /> ## Assigns * `:file` (required) — the `PhoenixKit.Modules.Storage.File`. * `:scope` — who is editing. `ImageEditing` decides whether they may (the owner, an Owner/Admin, a `"media"` permission holder). * `:authorized` (default `false`) — `true` when the host has already decided this user may manage the file, and `scope` is only who they are. `MediaBrowser` passes it for files inside its folder scope, as it does for rotating and deleting them. * `:on_close` — a `Phoenix.LiveView.JS` run by the close button. Without it there is no close button. ## Keeping it current Saving queues the rendering; the editor shows the progress until the file is processed. The host forwards the storage file events (see `PhoenixKit.Modules.Storage.subscribe_to_file_events/0`): send_update(PhoenixKitWeb.Components.ImageEditor, id: editor_id, file_processed: file_uuid ) """ use PhoenixKitWeb, :live_component require Logger alias PhoenixKit.Modules.Storage alias PhoenixKit.Modules.Storage.ImageEdit alias PhoenixKit.Modules.Storage.ImageEditing alias PhoenixKit.Modules.Storage.URLSigner alias PhoenixKit.Users.Auth.Scope alias PhoenixKitWeb.FileController @styles ~w(blur pixelate fill) @aspects [ {"free", nil}, {"1:1", {1, 1}}, {"4:3", {4, 3}}, {"3:2", {3, 2}}, {"16:9", {16, 9}}, {"3:4", {3, 4}}, {"9:16", {9, 16}} ] # Seconds a render may take before the editor offers to start it again. @stalled_after 120 # As many areas as `ImageEdit.normalize/1` accepts. @max_regions 50 # Where "Add area" puts a new region, in percent of the frame. @new_region %{"x" => 40.0, "y" => 40.0, "w" => 20.0, "h" => 20.0, "style" => "blur"} @impl true def mount(socket) do {:ok, socket |> assign(:authorized, false) |> assign(:scope, nil) |> assign(:on_close, nil) |> assign(:tool, "crop") |> assign(:aspect, "free") |> assign(:error, nil) |> assign(:notice, nil) |> assign(:confirm, nil)} end @impl true def update(%{file_processed: uuid}, %{assigns: %{file: %{uuid: uuid}}} = socket) do {:ok, reload(socket)} end def update(%{file_processed: _other}, socket), do: {:ok, socket} # See `watch_stall/1`. def update(%{stall_check: true}, socket), do: {:ok, assign(socket, :now, DateTime.utc_now())} def update(assigns, socket) do previous = socket.assigns[:file] socket = assign(socket, Map.take(assigns, [:id, :scope, :authorized, :on_close])) cond do is_nil(previous) -> {:ok, load(socket, assigns.file)} file_changed?(previous, assigns.file) -> {:ok, load(socket, assigns.file, keep_draft: true)} true -> {:ok, socket} end end defp file_changed?(a, b) do Map.take(a, [:uuid, :edit_revision, :edit_state, :file_checksum, :original_file_uuid]) != Map.take(b, [:uuid, :edit_revision, :edit_state, :file_checksum, :original_file_uuid]) end # ────────────────────────────────────────────────────────────── # State # ────────────────────────────────────────────────────────────── defp reload(socket) do case Storage.get_file(socket.assigns.file.uuid) do nil -> assign(socket, :file, nil) file -> load(socket, file, keep_draft: true) end end # `keep_draft`: a finished render must not throw away what the user has # been changing meanwhile — unless the draft is the edit that just # finished, which it then simply stays. defp load(socket, file, opts \\ []) do draft = if Keyword.get(opts, :keep_draft, false) and Map.has_key?(socket.assigns, :draft), do: socket.assigns.draft, else: file.edits || %{} edited? = ImageEditing.edited?(file) socket |> assign(:file, file) |> assign(:now, DateTime.utc_now()) |> watch_stall() |> assign(:draft, draft) |> assign(:size, source_size(file)) |> assign(:annotations, ImageEditing.annotation_count(file)) |> assign(:preview_url, preview_url(socket, file)) |> assign(:unedited_url, if(edited?, do: unedited_url(socket, file))) end # A pending edit offers "start again" once it has taken long; re-render # then even if nothing else happens. defp watch_stall( %{assigns: %{file: %{edit_state: "pending", updated_at: %DateTime{} = at}}} = socket ) do if connected?(socket) do due = max( DateTime.diff(DateTime.add(at, @stalled_after + 1), DateTime.utc_now(), :millisecond), 0 ) send_update_after(__MODULE__, %{id: socket.assigns.id, stall_check: true}, due) end socket end defp watch_stall(socket), do: socket # The unedited original's dimensions (what the edit is applied to). defp source_size(file) do source = if ImageEditing.edited?(file), do: ImageEditing.backup(file), else: file case source do %{width: w, height: h} when is_integer(w) and is_integer(h) and w > 0 and h > 0 -> {w, h} _ -> nil end end defp preview_url(socket, file) do if ImageEditing.edited?(file) do unedited_url(socket, file, "large") else instance = Storage.get_file_instance_by_name(file.uuid, "large") || Storage.get_file_instance_by_name(file.uuid, "original") instance && URLSigner.signed_url(file.uuid, instance.variant_name, version: instance) end end defp unedited_url(socket, file, variant \\ nil) do FileController.unedited_url(socket, file.uuid, user_uuid(socket), variant: variant) end defp user_uuid(socket), do: socket.assigns.scope && Scope.user_uuid(socket.assigns.scope) defp auth_opts(%{assigns: %{authorized: true}}), do: [system: true] defp auth_opts(socket), do: [scope: socket.assigns.scope] defp allowed?(socket), do: ImageEditing.can_edit?(socket.assigns.file, auth_opts(socket)) # ────────────────────────────────────────────────────────────── # Events # ────────────────────────────────────────────────────────────── @impl true def handle_event("change", %{"edit" => params} = all, socket) do socket = socket |> assign(:draft, merge_draft(socket.assigns.draft, params)) |> assign(:tool, pick(all["tool"], ~w(crop redact), socket.assigns.tool)) |> assign(:error, nil) {:noreply, socket} end def handle_event("change", _params, socket), do: {:noreply, socket} # Turning and mirroring keep the crop and the areas on their pixels # (`ImageEdit.turn/2`, `ImageEdit.mirror/2`). def handle_event("turn", %{"to" => to}, socket) when to in ~w(left right) do draft = ImageEdit.turn(socket.assigns.draft, String.to_existing_atom(to)) {:noreply, socket |> assign(:draft, draft) |> assign(:aspect, "free")} end def handle_event("mirror", %{"axis" => axis}, socket) when axis in ~w(horizontal vertical) do draft = ImageEdit.mirror(socket.assigns.draft, String.to_existing_atom(axis)) {:noreply, assign(socket, :draft, draft)} end def handle_event("aspect", %{"aspect" => aspect}, socket) do case {List.keyfind(@aspects, aspect, 0), socket.assigns.size} do {{_, {aw, ah}}, {_, _} = size} -> crop = ImageEdit.centred_crop(socket.assigns.draft, {aw, ah}, size) {:noreply, socket |> assign(:aspect, aspect) |> assign(:draft, Map.put(socket.assigns.draft, "crop", crop))} {{_, nil}, _} -> {:noreply, assign(socket, :aspect, aspect)} _ -> {:noreply, socket} end end def handle_event("clear_crop", _params, socket) do {:noreply, socket |> assign(:aspect, "free") |> assign(:draft, Map.delete(socket.assigns.draft, "crop"))} end # From the hook: the preview image is the recorded size turned a quarter # (an upload with an EXIF rotation, recorded before dimensions were read # oriented). The frame follows what is displayed — and rendered. def handle_event("turned_source", _params, socket) do case socket.assigns.size do {w, h} -> {:noreply, assign(socket, :size, {h, w})} nil -> {:noreply, socket} end end # From the hook: a rectangle drawn on the preview, in percent of the frame. def handle_event("drawn", %{"tool" => tool} = rect, socket) do case drawn_rect(rect) do {:ok, rect} -> {:noreply, assign(socket, :draft, add_drawn(socket, tool, rect))} :error -> {:noreply, socket} end end def handle_event("add_region", _params, socket) do regions = regions(socket.assigns.draft) if length(regions) < @max_regions do region = Map.put(@new_region, "style", last_style(socket)) {:noreply, assign(socket, :draft, Map.put(socket.assigns.draft, "redact", regions ++ [region]))} else {:noreply, socket} end end def handle_event("remove_region", %{"index" => index}, socket) do case Integer.parse(to_string(index)) do {i, ""} -> regions = List.delete_at(regions(socket.assigns.draft), i) {:noreply, assign(socket, :draft, Map.put(socket.assigns.draft, "redact", regions))} _ -> {:noreply, socket} end end def handle_event("reset", _params, socket) do {:noreply, socket |> assign(:draft, socket.assigns.file.edits || %{}) |> assign(:aspect, "free") |> assign(:error, nil)} end def handle_event("save", params, socket) do socket = assign(socket, :draft, merge_draft(socket.assigns.draft, params["edit"] || %{})) file = socket.assigns.file draft = socket.assigns.draft case params["intent"] do "copy" -> case ImageEditing.save_copy(file, draft, auth_opts(socket)) do {:ok, _job} -> {:noreply, assign( socket, :notice, gettext("A copy is being made. It will appear next to the original.") )} {:error, reason} -> {:noreply, assign(socket, :error, error_message(reason))} end _save -> case ImageEditing.edit(file, draft, auth_opts(socket)) do {:ok, file} -> {:noreply, socket |> load(file, keep_draft: true) |> assign(:notice, nil) |> assign(:error, nil)} {:error, reason} -> {:noreply, socket |> assign(:error, error_message(reason)) |> reload()} end end end def handle_event("retry", _params, socket) do result = ImageEditing.retry(socket.assigns.file, auth_opts(socket)) {:noreply, after_action(socket, result)} end def handle_event("confirm", %{"action" => action}, socket) when action in ~w(revert delete_unedited) do {:noreply, assign(socket, :confirm, action)} end def handle_event("cancel_confirm", _params, socket), do: {:noreply, assign(socket, :confirm, nil)} def handle_event("revert", _params, socket) do result = ImageEditing.revert(socket.assigns.file, auth_opts(socket)) socket |> assign(:confirm, nil) |> after_action(result) |> then(fn socket -> {:noreply, assign(socket, :draft, %{})} end) end def handle_event("delete_unedited", _params, socket) do result = ImageEditing.delete_unedited_original(socket.assigns.file, auth_opts(socket)) socket |> assign(:confirm, nil) |> after_action(result) |> then(&{:noreply, &1}) end defp after_action(socket, {:ok, file}), do: socket |> load(file) |> assign(:error, nil) defp after_action(socket, {:error, reason}), do: socket |> assign(:error, error_message(reason)) |> reload() # ────────────────────────────────────────────────────────────── # Draft helpers # ────────────────────────────────────────────────────────────── # The form carries the fields; the quarter turn and the mirrors are kept # from the draft (only their buttons change them, together with the # rectangles). defp merge_draft(draft, params) do params = params |> Map.merge(Map.take(draft, ~w(rotate flip_h flip_v))) |> Map.update("crop", nil, &blank_crop/1) case ImageEdit.normalize(params) do {:ok, nil} -> %{} {:ok, edit} -> edit {:error, _} -> draft end end # An untouched crop (the whole frame) is no crop. defp blank_crop(%{} = crop) do if Enum.all?(~w(x y w h), &(to_string(crop[&1] || "") == "")), do: nil, else: crop end defp blank_crop(other), do: other defp regions(draft), do: Map.get(draft, "redact", []) defp last_style(socket) do case List.last(regions(socket.assigns.draft)) do %{"style" => style} -> style _ -> "blur" end end defp add_drawn(socket, "redact", rect) do regions = regions(socket.assigns.draft) ++ [Map.put(rect, "style", last_style(socket))] Map.put(socket.assigns.draft, "redact", Enum.take(regions, @max_regions)) end defp add_drawn(socket, _crop, rect), do: Map.put(socket.assigns.draft, "crop", rect) defp drawn_rect(params) do case ImageEdit.normalize(%{"redact" => [Map.put(params, "style", "fill")]}) do {:ok, %{"redact" => [rect]}} -> {:ok, Map.delete(rect, "style")} _ -> :error end end defp frame(socket) do case socket.assigns.size do nil -> nil size -> ImageEdit.frame_size(socket.assigns.draft, size) end end defp pick(value, allowed, default), do: if(value in allowed, do: value, else: default) defp error_message(:forbidden), do: gettext("You can't edit this image.") defp error_message(:not_editable), do: gettext("This file can't be edited.") defp error_message(:not_found), do: gettext("This file no longer exists.") defp error_message(:not_queued), do: gettext("Saved, but the image could not be queued for rendering. Try again.") defp error_message(:no_edit), do: gettext("Nothing to save: the copy would be the same as the original.") defp error_message(:not_edited), do: gettext("This image has no unedited original.") defp error_message(:nothing_to_retry), do: gettext("There is nothing to retry.") defp error_message(:edit_changed), do: gettext("The image changed meanwhile. Check it and try again.") defp error_message(:edit_in_progress), do: gettext("Wait until the image has finished rendering.") defp error_message({:annotated, count}), do: ngettext( "This image has an annotation. Cropping, turning, flipping or straightening it would move the annotation off its place. Save a copy instead.", "This image has %{count} annotations. Cropping, turning, flipping or straightening it would move them off their places. Save a copy instead.", count ) defp error_message({:invalid_edit, _}), do: gettext("Some of the values are not valid.") defp error_message(reason) do Logger.warning("ImageEditor: #{inspect(reason)}") gettext("Something went wrong. Try again.") end # ────────────────────────────────────────────────────────────── # Render # ────────────────────────────────────────────────────────────── @impl true def render(%{file: nil} = assigns) do ~H"""
{gettext("This file no longer exists.")}
""" end def render(assigns) do assigns = assigns |> assign(:allowed, allowed?(%{assigns: assigns})) |> assign(:editable, ImageEditing.editable?(assigns.file)) |> assign(:busy, ImageEditing.edit_in_progress?(assigns.file)) |> assign(:failed, assigns.file.edit_state == "failed") |> assign(:edited, ImageEditing.edited?(assigns.file)) |> assign(:frame, frame(%{assigns: assigns})) |> assign(:geometry_locked, assigns.annotations > 0) |> assign(:replace_mode, ImageEditing.mode() == "replace_original") |> assign(:dirty, assigns.draft != (assigns.file.edits || %{})) |> assign(:styles, @styles) |> assign(:aspects, Enum.map(@aspects, &elem(&1, 0))) ~H"""
<%!-- Preview --%>
<%= if @preview_url && @frame do %> <% {fw, fh} = @frame %>
<%!-- Redaction areas --%>
{index + 1}
<%!-- Crop: the parts that go are shaded --%> <%= if crop = @draft["crop"] do %>
<% end %>
<% else %>

{gettext("The image has no dimensions yet. Try again once it has been processed.")}

{gettext("No preview is available. The fields still work.")}

<% end %> <%= if @busy do %>
<%= if @failed do %> <.icon name="hero-exclamation-triangle" class="w-8 h-8 text-error mx-auto" />

{gettext("The edit could not be applied.")}

{gettext("Until it is, the image shows a placeholder everywhere.")}

<% else %>

{gettext("Applying the edit…")}

{gettext("The image shows a placeholder until it is done.")}

<%!-- A run that died without a trace (a timeout, a node going away) leaves nothing to finish the edit. --%> <% end %>
<% end %>

{gettext("The preview is approximate; colours and blur are exact once saved.")}

<%!-- Controls --%>

{gettext("Edit image")}

{@notice}
<%= cond do %> <% not @editable -> %>

{gettext("Only still JPEG, PNG, WebP, AVIF, TIFF and BMP images can be edited.")}

<% not @allowed -> %>

{gettext("You can't edit this image.")}

<% true -> %> <.form for={%{}} as={:edit} id={"#{@id}-form"} phx-change="change" phx-submit="save" phx-target={@myself} class="flex flex-col gap-4" >
{ngettext( "This image has an annotation, so it can't be cropped, turned, flipped or straightened (the annotation would end up in the wrong place). Save a copy for that.", "This image has %{count} annotations, so it can't be cropped, turned, flipped or straightened (they would end up in the wrong place). Save a copy for that.", @annotations )}
<%!-- Geometry --%>
{gettext("Crop")}
<%!-- The geometric fields still travel with the form while another tool is open, so they are not reset. --%> <%!-- Redaction --%>

{gettext( "Drag on the image to cover an area, or add one and set it below. Covered areas are permanently removed from the saved image." )}

{index + 1}
<%!-- Light --%>
<% {ow, oh} = ImageEdit.output_size(@draft, @size) %> {gettext("Result: %{width} × %{height} px", width: ow, height: oh)}
{gettext("Saving replaces the original image. This can't be undone.")}
<%!-- The unedited original --%>

{gettext("Unedited original")}

{gettext( "Kept privately so the edit can be changed or undone. Only people who can edit this image can get it." )}

<.icon name="hero-arrow-down-tray" class="w-3 h-3" /> {gettext("Download")}
{gettext( "Undo the edit and show the unedited original again everywhere this image is used?" )}
{gettext( "Delete the unedited original for good? The edit becomes permanent and can no longer be changed or undone." )}
<% end %>
""" end # ────────────────────────────────────────────────────────────── # Render helpers # ────────────────────────────────────────────────────────────── # The unedited image inside the frame: turned and mirrored as the edit # says, straightened (and scaled so no corner shows), toned. defp image_style(draft, {w, h}, {fw, fh}) do rotate = draft["rotate"] || 0 straighten = draft["straighten"] || 0 scale = ImageEdit.straighten_scale(fw, fh, straighten) {sx, sy} = {if(draft["flip_h"], do: -1, else: 1), if(draft["flip_v"], do: -1, else: 1)} # Width and height in percent of the frame, before the quarter turn. {pw, ph} = if rotate in [90, 270], do: {w / fw * 100, h / fh * 100}, else: {100, 100} brightness = 1 + (draft["brightness"] || 0) / 100 contrast = 1 + (draft["contrast"] || 0) / 100 [ "width: #{num(pw)}%; height: #{num(ph)}%;", "transform: translate(-50%, -50%) rotate(#{num(straighten)}deg) scale(#{num(scale)}) ", "scale(#{sx}, #{sy}) rotate(#{rotate}deg);", "filter: brightness(#{num(brightness)}) contrast(#{num(contrast)});" ] |> Enum.join() end # Saved (or retried) a while ago and still rendering. defp stalled?(%{edit_state: "pending", updated_at: %DateTime{} = at}, now), do: DateTime.diff(now, at) > @stalled_after defp stalled?(_file, _now), do: false defp rect_style(%{"x" => x, "y" => y, "w" => w, "h" => h}), do: "left: #{num(x)}%; top: #{num(y)}%; width: #{num(w)}%; height: #{num(h)}%;" # Everything outside the crop, darkened: one element, four inset shadows # would not do; a clip-path polygon with a hole does. defp shade_style(%{"x" => x, "y" => y, "w" => w, "h" => h}) do {l, t, r, b} = {num(x), num(y), num(x + w), num(y + h)} "background: rgb(0 0 0 / 0.55); clip-path: polygon(evenodd, " <> "0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, " <> "#{l}% #{t}%, #{r}% #{t}%, #{r}% #{b}%, #{l}% #{b}%, #{l}% #{t}%);" end defp region_class("fill"), do: "bg-black" defp region_class("pixelate"), do: "backdrop-blur-md bg-base-content/20 [image-rendering:pixelated] outline outline-2 outline-white/70" defp region_class(_blur), do: "backdrop-blur-xl bg-base-100/10 outline outline-2 outline-white/70" defp style_label("blur"), do: gettext("Blur") defp style_label("pixelate"), do: gettext("Pixelate") defp style_label("fill"), do: gettext("Black box") defp rect_label("x"), do: gettext("Left %") defp rect_label("y"), do: gettext("Top %") defp rect_label("w"), do: gettext("Width %") defp rect_label("h"), do: gettext("Height %") defp crop_value(nil, key), do: if(key in ~w(w h), do: 100, else: 0) defp crop_value(crop, key), do: crop[key] defp format_degrees(nil), do: "0°" defp format_degrees(value), do: "#{num(value)}°" defp aspect_ratio(aspect) do case List.keyfind(@aspects, aspect, 0) do {_, {w, h}} -> "#{w}/#{h}" _ -> "" end end defp num(value) when is_integer(value), do: Integer.to_string(value) defp num(value) when is_float(value) do rounded = Float.round(value, 3) if rounded == trunc(rounded), do: Integer.to_string(trunc(rounded)), else: :erlang.float_to_binary(rounded, [:compact, decimals: 3]) end end