berrypod/lib/simpleshop_theme/sync/mockup_enricher.ex
jamey 29d8839ac2 put front-view mockup extras first in image gallery
When the enricher adds extra angle images, sort them so front views
come first (position 0) and shift existing images down. This ensures
the product gallery leads with a proper front shot rather than a
handle-side or angled preview.

Also adds Products.update_product_image/2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:58:54 +00:00

281 lines
8.7 KiB
Elixir

defmodule SimpleshopTheme.Sync.MockupEnricher do
@moduledoc """
Oban worker that enriches Printful products with extra mockup angle images.
After product sync, Printful products only have front-view preview images.
This worker uses the legacy mockup generator API to produce extra angles
(back, left, right, etc.) and appends them as additional product images.
Each product is processed as a separate job so failures don't block others.
The temporary S3 URLs from the mockup generator are downloaded via the
existing ImageDownloadWorker pipeline.
"""
use Oban.Worker, queue: :images, max_attempts: 5
alias SimpleshopTheme.Clients.Printful, as: Client
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Sync.ImageDownloadWorker
require Logger
@poll_interval_ms 3_000
@max_poll_attempts 20
# Mockup generator config per catalog product type:
# {placement, area_width, area_height}
# Apparel/accessories use "front", flat products use "default"
@product_configs %{
1 => {"default", 4500, 6750},
3 => {"default", 4800, 7200},
19 => {"default", 3150, 1350},
71 => {"front", 4500, 5100},
146 => {"front", 4500, 4500},
181 => {"default", 1092, 2286},
274 => {"default", 3300, 3300},
394 => {"default", 3900, 2925},
395 => {"default", 9000, 10800}
}
# Stagger jobs to avoid hammering Printful's rate limits
@job_stagger_seconds 45
@doc """
Enqueue mockup enrichment for a product.
Accepts an optional `delay_index` to stagger jobs (each index adds
#{@job_stagger_seconds}s of delay).
"""
def enqueue(conn_id, product_id, delay_index \\ 0) do
delay = delay_index * @job_stagger_seconds
scheduled_at = DateTime.add(DateTime.utc_now(), delay, :second)
%{provider_connection_id: conn_id, product_id: product_id}
|> new(scheduled_at: scheduled_at)
|> Oban.insert()
end
@impl Oban.Worker
def perform(%Oban.Job{
args: %{"provider_connection_id" => conn_id, "product_id" => product_id}
}) do
with %ProviderConnection{} = conn <- Products.get_provider_connection(conn_id),
product when not is_nil(product) <- Products.get_product(product_id),
:ok <- set_credentials(conn) do
enrich_product(product)
else
nil -> {:cancel, :not_found}
{:error, _} = error -> error
end
end
defp enrich_product(product) do
provider_data = product.provider_data || %{}
catalog_product_id =
provider_data["catalog_product_id"] || provider_data[:catalog_product_id]
artwork_url = provider_data["artwork_url"] || provider_data[:artwork_url]
catalog_variant_ids =
provider_data["catalog_variant_ids"] || provider_data[:catalog_variant_ids] || []
cond do
is_nil(catalog_product_id) ->
Logger.info("[MockupEnricher] No catalog_product_id for #{product.title}, skipping")
:ok
is_nil(artwork_url) ->
Logger.info("[MockupEnricher] No artwork_url for #{product.title}, skipping")
:ok
already_enriched?(product) ->
Logger.info("[MockupEnricher] Already enriched #{product.title}, skipping")
:ok
true ->
# Pick one representative variant for mockup generation
variant_id = List.first(catalog_variant_ids)
case generate_and_append(product, catalog_product_id, variant_id, artwork_url) do
{:ok, count} ->
Logger.info("[MockupEnricher] Added #{count} extra angle(s) for #{product.title}")
:ok
{:error, {429, _}} ->
Logger.info("[MockupEnricher] Rate limited for #{product.title}, snoozing 60s")
{:snooze, 60}
{:error, {400, _} = reason} ->
# Permanent API error (wrong placement, unsupported product, etc.)
Logger.info("[MockupEnricher] #{product.title} not supported: #{inspect(reason)}")
:ok
{:error, reason} ->
Logger.warning("[MockupEnricher] Failed for #{product.title}: #{inspect(reason)}")
{:error, reason}
end
end
end
defp generate_and_append(product, catalog_product_id, variant_id, artwork_url) do
{placement, area_width, area_height} =
Map.get(@product_configs, catalog_product_id, {"front", 4500, 5100})
body = %{
variant_ids: if(variant_id, do: [variant_id], else: []),
format: "jpg",
files: [
%{
placement: placement,
image_url: artwork_url,
position: %{
area_width: area_width,
area_height: area_height,
width: area_width,
height: area_height,
top: 0,
left: 0
}
}
]
}
with {:ok, task_data} <- Client.create_mockup_generator_task(catalog_product_id, body),
task_key <- task_data["task_key"],
{:ok, result} <- poll_generator_task(task_key),
extra_images <- extract_extra_images(result) do
if extra_images == [] do
{:ok, 0}
else
append_images_to_product(product, extra_images)
end
end
end
defp poll_generator_task(task_key), do: poll_generator_task(task_key, 0)
defp poll_generator_task(_task_key, attempt) when attempt >= @max_poll_attempts do
{:error, :timeout}
end
defp poll_generator_task(task_key, attempt) do
Process.sleep(@poll_interval_ms)
case Client.get_mockup_generator_task(task_key) do
{:ok, %{"status" => "completed"} = result} ->
{:ok, result}
{:ok, %{"status" => "error", "error" => error}} ->
{:error, {:mockup_error, error}}
{:ok, %{"status" => _pending}} ->
poll_generator_task(task_key, attempt + 1)
{:error, {429, _}} ->
# Rate limited — back off and retry
Process.sleep(60_000)
poll_generator_task(task_key, attempt + 1)
{:error, reason} ->
{:error, reason}
end
end
# Collect all extra angle URLs from the mockup generator response.
# The "extra" array contains alternate views (back, left, right, etc.)
defp extract_extra_images(result) do
(result["mockups"] || [])
|> Enum.flat_map(fn mockup ->
(mockup["extra"] || [])
|> Enum.map(fn extra ->
%{
src: extra["url"],
alt: extra["title"]
}
end)
end)
|> Enum.reject(&is_nil(&1.src))
|> Enum.uniq_by(& &1.src)
end
defp append_images_to_product(product, extra_images) do
existing_images = Products.list_product_images(product.id)
existing_count = length(existing_images)
# Sort extras: front views first, then the rest
sorted_extras =
Enum.sort_by(extra_images, fn img ->
title = String.downcase(img.alt || "")
if String.contains?(title, "front"), do: 0, else: 1
end)
has_front_extra =
Enum.any?(sorted_extras, fn img ->
String.contains?(String.downcase(img.alt || ""), "front")
end)
# If we have a front extra, shift existing images down to make room
if has_front_extra do
shift_images_down(existing_images, length(sorted_extras))
end
# Insert extras: front extras at position 0+, others after existing
start_position = if has_front_extra, do: 0, else: existing_count
results =
sorted_extras
|> Enum.with_index(start_position)
|> Enum.map(fn {img, position} ->
attrs = %{
product_id: product.id,
src: img.src,
alt: img.alt,
position: position
}
case Products.create_product_image(attrs) do
{:ok, product_image} ->
ImageDownloadWorker.enqueue(product_image.id)
{:ok, product_image}
{:error, reason} ->
Logger.warning("[MockupEnricher] Failed to create image: #{inspect(reason)}")
{:error, reason}
end
end)
count = Enum.count(results, &match?({:ok, _}, &1))
{:ok, count}
end
# Bump all existing image positions up by `offset` to make room at the front
defp shift_images_down(existing_images, offset) do
Enum.each(existing_images, fn img ->
Products.update_product_image(img, %{position: img.position + offset})
end)
end
# Check if this product already has extra angle images from a prior enrichment
defp already_enriched?(product) do
images = Products.list_product_images(product.id)
Enum.any?(images, fn img -> img.alt in ["Front", "Back", "Left", "Right"] end)
end
defp set_credentials(conn) do
case ProviderConnection.get_api_key(conn) do
api_key when is_binary(api_key) ->
Process.put(:printful_api_key, api_key)
store_id = get_in(conn.config, ["store_id"])
if store_id, do: Process.put(:printful_store_id, store_id)
:ok
nil ->
{:error, :no_api_key}
end
end
end