New PrintfulGenerator module creates demo products in Printful with multi-variant support (multiple colours/sizes per product type). Mix task gets --provider flag to choose between Printify and Printful. After syncing Printful products, MockupEnricher Oban worker calls the legacy mockup generator API to produce extra angle images (back, left, right) and appends them as product images. Jobs are staggered 45s apart with snooze-based 429 handling. Flat products (canvas, poster) get no extras — apparel and 3D products get 1-5 extra angles each. Also fixes: - cross-provider slug uniqueness (appends -2, -3 suffix) - category mapping order (Accessories before Canvas Prints) - image dedup by URL instead of colour (fixes canvas variants) - artwork URL stored in provider_data for enricher access Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
|