255 lines
7.9 KiB
Elixir
255 lines
7.9 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
|
||
|
|
# Find the current max position so we append after existing images
|
||
|
|
existing_images = Products.list_product_images(product.id)
|
||
|
|
max_position = existing_images |> Enum.map(& &1.position) |> Enum.max(fn -> -1 end)
|
||
|
|
|
||
|
|
results =
|
||
|
|
extra_images
|
||
|
|
|> Enum.with_index(max_position + 1)
|
||
|
|
|> 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
|
||
|
|
|
||
|
|
# 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
|