berrypod/lib/simpleshop_theme/sync/mockup_enricher.ex
jamey 1aceaf9444 add Printful mockup generator and post-sync angle enrichment
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>
2026-02-15 16:52:53 +00:00

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