Current section
Files
Jump to
Current section
Files
lib/vibe/image.ex
defmodule Vibe.Image do
@moduledoc "Image data helpers for model, eval, and renderer boundaries."
alias Vibe.Image.{Dimensions, Resize}
alias Vibe.Model.Content
alias Vibe.Workspace
@supported_mime_types %{
".png" => "image/png",
".jpg" => "image/jpeg",
".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp"
}
defstruct [
:data,
:mime_type,
:path,
:filename,
:size_bytes,
:width,
:height,
:original_width,
:original_height,
was_resized?: false
]
@type t :: %__MODULE__{
data: String.t(),
mime_type: String.t(),
path: String.t() | nil,
filename: String.t() | nil,
size_bytes: non_neg_integer() | nil,
width: pos_integer() | nil,
height: pos_integer() | nil,
original_width: pos_integer() | nil,
original_height: pos_integer() | nil,
was_resized?: boolean()
}
@spec supported_mime_types() :: [String.t()]
def supported_mime_types, do: @supported_mime_types |> Map.values() |> Enum.uniq()
@spec mime_type(String.t()) :: String.t() | nil
def mime_type(path) when is_binary(path) do
@supported_mime_types[String.downcase(Path.extname(path))]
end
@spec supported?(String.t()) :: boolean()
def supported?(path), do: is_binary(mime_type(path))
@spec from_binary(String.t(), binary(), String.t(), File.Stat.t()) :: t()
def from_binary(path, binary, mime_type, %File.Stat{} = stat) do
{width, height} = dimensions(binary, mime_type)
%__MODULE__{
data: Base.encode64(binary),
mime_type: mime_type,
path: path,
filename: Path.basename(path),
size_bytes: stat.size,
width: width,
height: height,
original_width: width,
original_height: height,
was_resized?: false
}
end
@spec from_file(String.t(), keyword()) :: {:ok, t()} | {:error, String.t()}
def from_file(path, opts \\ []) when is_binary(path) do
with {:ok, absolute} <- resolve(path, opts),
mime_type when is_binary(mime_type) <- mime_type(absolute),
{:ok, stat} <- File.stat(absolute),
{:ok, binary} <- File.read(absolute) do
path |> from_binary(binary, mime_type, stat) |> maybe_resize(opts)
else
nil -> {:error, "unsupported image type: #{path}"}
{:error, reason} -> {:error, to_string(reason)}
end
end
@spec from_base64(String.t(), String.t(), keyword()) :: {:ok, t()} | {:error, String.t()}
def from_base64(data, mime_type, opts \\ []) when is_binary(data) and is_binary(mime_type) do
case Base.decode64(data) do
{:ok, binary} ->
{width, height} = dimensions(binary, mime_type)
{:ok,
%__MODULE__{
data: data,
mime_type: mime_type,
path: Keyword.get(opts, :path),
filename: Keyword.get(opts, :filename),
size_bytes: byte_size(binary),
width: width,
height: height,
original_width: width,
original_height: height,
was_resized?: false
}}
:error ->
{:error, "invalid base64 image data"}
end
end
@spec from_base64!(String.t(), String.t(), keyword()) :: t()
def from_base64!(data, mime_type, opts \\ []) do
case from_base64(data, mime_type, opts) do
{:ok, image} -> image
{:error, reason} -> raise ArgumentError, reason
end
end
@spec dimensions(binary(), String.t()) :: {pos_integer() | nil, pos_integer() | nil}
def dimensions(binary, mime_type) when is_binary(binary) and is_binary(mime_type) do
case Dimensions.detect(binary, mime_type) do
{:ok, {width, height}} -> {width, height}
:error -> {nil, nil}
end
end
@spec data_uri(t()) :: String.t()
def data_uri(%__MODULE__{} = image),
do: "#{image.mime_type};base64,#{image.data}" |> then(&("data:" <> &1))
@spec to_content_parts(t()) :: [map()]
def to_content_parts(%__MODULE__{} = image) do
note =
["Read image file [#{image.mime_type}]", dimension_text(image), resize_text(image)]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join("\n")
[
Content.text(note),
Content.image(
data: image.data,
mime_type: image.mime_type,
filename: image.filename,
width: image.width,
height: image.height
)
]
end
defp maybe_resize(image, opts) do
if Keyword.get(opts, :resize?, false), do: Resize.resize(image, opts), else: {:ok, image}
end
defp dimension_text(%{width: width, height: height})
when is_integer(width) and is_integer(height),
do: "#{width}x#{height}"
defp dimension_text(_image), do: nil
defp resize_text(%{
was_resized?: true,
original_width: original_width,
original_height: original_height
})
when is_integer(original_width) and is_integer(original_height),
do: "Original image: #{original_width}x#{original_height}"
defp resize_text(_image), do: nil
defp resolve(path, opts) do
case Keyword.fetch(opts, :absolute) do
{:ok, absolute} -> {:ok, absolute}
:error -> Workspace.resolve(path, opts)
end
end
end