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("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 %>{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 %>{gettext("The preview is approximate; colours and blur are exact once saved.")}
{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" > <%!-- 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." )}