Packages

Dependency-free validation for structured image-generation briefs.

Current section

Files

Jump to
image_brief_validator lib image_brief_validator.ex
Raw

lib/image_brief_validator.ex

defmodule ImageBriefValidator do
@moduledoc """
Validates the structure of an image-generation brief before it is passed to
a rendering workflow.
The validator is provider-independent and performs no network requests.
"""
@valid_styles ~w(photographic illustration flat_vector product_shot poster)
@required_keys [:prompt, :width, :height]
@doc """
Validates a brief and returns either the original brief or all detected
errors.
## Examples
iex> ImageBriefValidator.validate(%{prompt: "A studio product photo", width: 1200, height: 800})
{:ok, %{prompt: "A studio product photo", width: 1200, height: 800}}
iex> ImageBriefValidator.validate(%{prompt: "", width: 0, height: 800})
{:error, [prompt: :must_be_present, width: :must_be_a_positive_integer]}
"""
@spec validate(map()) :: {:ok, map()} | {:error, keyword()}
def validate(%{} = brief) do
case validate_all(brief) do
[] -> {:ok, brief}
errors -> {:error, errors}
end
end
def validate(_brief), do: {:error, brief: :must_be_a_map}
@doc """
Returns all structural errors as a keyword list.
String and atom keys are both accepted. Optional `style` values are limited
to the documented presets, and `references` must be a list of non-empty
strings.
"""
@spec validate_all(map()) :: keyword()
def validate_all(%{} = brief) do
normalized = normalize_keys(brief)
[]
|> check_required(normalized)
|> check_prompt(normalized)
|> check_dimension(:width, normalized)
|> check_dimension(:height, normalized)
|> check_style(normalized)
|> check_references(normalized)
|> Enum.reverse()
end
@doc """
Returns the reduced aspect ratio for valid positive integer dimensions.
iex> ImageBriefValidator.aspect_ratio(1920, 1080)
{:ok, "16:9"}
"""
@spec aspect_ratio(integer(), integer()) :: {:ok, String.t()} | {:error, atom()}
def aspect_ratio(width, height)
when is_integer(width) and width > 0 and is_integer(height) and height > 0 do
divisor = Integer.gcd(width, height)
{:ok, "#{div(width, divisor)}:#{div(height, divisor)}"}
end
def aspect_ratio(_width, _height), do: {:error, :invalid_dimensions}
defp normalize_keys(brief) do
Map.new(brief, fn
{key, value} when is_binary(key) ->
normalized_key =
case key do
"prompt" -> :prompt
"width" -> :width
"height" -> :height
"style" -> :style
"references" -> :references
other -> other
end
{normalized_key, value}
pair ->
pair
end)
end
defp check_required(errors, brief) do
Enum.reduce(@required_keys, errors, fn key, acc ->
if Map.has_key?(brief, key), do: acc, else: [{key, :is_required} | acc]
end)
end
defp check_prompt(errors, %{prompt: prompt}) when is_binary(prompt) do
if String.trim(prompt) == "", do: [{:prompt, :must_be_present} | errors], else: errors
end
defp check_prompt(errors, %{prompt: _}), do: [{:prompt, :must_be_a_string} | errors]
defp check_prompt(errors, _brief), do: errors
defp check_dimension(errors, key, brief) do
case Map.fetch(brief, key) do
{:ok, value} when is_integer(value) and value > 0 -> errors
{:ok, _value} -> [{key, :must_be_a_positive_integer} | errors]
:error -> errors
end
end
defp check_style(errors, %{style: style}) when style in @valid_styles, do: errors
defp check_style(errors, %{style: nil}), do: errors
defp check_style(errors, %{style: _style}), do: [{:style, :is_not_supported} | errors]
defp check_style(errors, _brief), do: errors
defp check_references(errors, %{references: references}) when is_list(references) do
valid? =
Enum.all?(references, fn reference ->
is_binary(reference) and String.trim(reference) != ""
end)
if valid?, do: errors, else: [{:references, :must_contain_non_empty_strings} | errors]
end
defp check_references(errors, %{references: _references}),
do: [{:references, :must_be_a_list} | errors]
defp check_references(errors, _brief), do: errors
end