2026-02-15 16:52:53 +00:00
|
|
|
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.
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
The hero colour gets full angle coverage (front, back, left, right).
|
|
|
|
|
Other colours get a front-view mockup only (one API call each).
|
|
|
|
|
|
2026-02-15 16:52:53 +00:00
|
|
|
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
|
2026-02-15 23:21:22 +00:00
|
|
|
@inter_color_delay_ms 5_000
|
2026-02-15 16:52:53 +00:00
|
|
|
|
|
|
|
|
# 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]
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
color_variant_map =
|
|
|
|
|
provider_data["color_variant_map"] || provider_data[:color_variant_map] || %{}
|
2026-02-15 16:52:53 +00:00
|
|
|
|
|
|
|
|
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 ->
|
2026-02-15 23:21:22 +00:00
|
|
|
enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map)
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-02-15 16:52:53 +00:00
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
defp enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map) do
|
|
|
|
|
colors = Map.to_list(color_variant_map)
|
2026-02-15 16:52:53 +00:00
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
if colors == [] do
|
|
|
|
|
# No colour info — fall back to single-variant enrichment
|
|
|
|
|
Logger.info(
|
|
|
|
|
"[MockupEnricher] No color_variant_map for #{product.title}, using first variant"
|
|
|
|
|
)
|
2026-02-15 16:52:53 +00:00
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
enrich_single_colour(product, catalog_product_id, nil, nil, artwork_url, :hero)
|
|
|
|
|
else
|
|
|
|
|
# First colour is the hero (gets full angles), rest get front-only
|
|
|
|
|
[{hero_color, hero_variant_id} | other_colors] = colors
|
|
|
|
|
|
|
|
|
|
case enrich_single_colour(
|
|
|
|
|
product,
|
|
|
|
|
catalog_product_id,
|
|
|
|
|
hero_color,
|
|
|
|
|
hero_variant_id,
|
|
|
|
|
artwork_url,
|
|
|
|
|
:hero
|
|
|
|
|
) do
|
|
|
|
|
{:ok, hero_count} ->
|
|
|
|
|
Logger.info("[MockupEnricher] Hero colour #{hero_color}: #{hero_count} image(s)")
|
|
|
|
|
|
|
|
|
|
other_count =
|
|
|
|
|
enrich_other_colours(product, catalog_product_id, artwork_url, other_colors)
|
|
|
|
|
|
|
|
|
|
total = hero_count + other_count
|
|
|
|
|
Logger.info("[MockupEnricher] Total #{total} extra image(s) for #{product.title}")
|
|
|
|
|
:ok
|
|
|
|
|
|
|
|
|
|
{:error, {429, _}} ->
|
|
|
|
|
Logger.info("[MockupEnricher] Rate limited for #{product.title}, snoozing 60s")
|
|
|
|
|
{:snooze, 60}
|
|
|
|
|
|
|
|
|
|
{:error, {400, _} = reason} ->
|
|
|
|
|
Logger.info("[MockupEnricher] #{product.title} not supported: #{inspect(reason)}")
|
|
|
|
|
:ok
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
Logger.warning("[MockupEnricher] Failed for #{product.title}: #{inspect(reason)}")
|
|
|
|
|
{:error, reason}
|
|
|
|
|
end
|
2026-02-15 16:52:53 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
defp enrich_other_colours(product, catalog_product_id, artwork_url, colors) do
|
|
|
|
|
Enum.reduce(colors, 0, fn {color_name, variant_id}, acc ->
|
|
|
|
|
Process.sleep(@inter_color_delay_ms)
|
|
|
|
|
|
|
|
|
|
case enrich_single_colour(
|
|
|
|
|
product,
|
|
|
|
|
catalog_product_id,
|
|
|
|
|
color_name,
|
|
|
|
|
variant_id,
|
|
|
|
|
artwork_url,
|
|
|
|
|
:front_only
|
|
|
|
|
) do
|
|
|
|
|
{:ok, count} ->
|
|
|
|
|
Logger.info("[MockupEnricher] Colour #{color_name}: #{count} image(s)")
|
|
|
|
|
acc + count
|
|
|
|
|
|
|
|
|
|
{:error, {429, _}} ->
|
|
|
|
|
# Rate limited on a non-hero colour — log and continue with remaining
|
|
|
|
|
Logger.info(
|
|
|
|
|
"[MockupEnricher] Rate limited on #{color_name}, skipping remaining colours"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
acc
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
Logger.warning("[MockupEnricher] Failed for colour #{color_name}: #{inspect(reason)}")
|
|
|
|
|
acc
|
|
|
|
|
end
|
|
|
|
|
end)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp enrich_single_colour(
|
|
|
|
|
product,
|
|
|
|
|
catalog_product_id,
|
|
|
|
|
color_name,
|
|
|
|
|
variant_id,
|
|
|
|
|
artwork_url,
|
|
|
|
|
mode
|
|
|
|
|
) do
|
2026-02-15 16:52:53 +00:00
|
|
|
{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"],
|
2026-02-15 23:21:22 +00:00
|
|
|
{:ok, result} <- poll_generator_task(task_key) do
|
|
|
|
|
images = extract_images(result, mode)
|
|
|
|
|
|
|
|
|
|
if images == [] do
|
2026-02-15 16:52:53 +00:00
|
|
|
{:ok, 0}
|
|
|
|
|
else
|
2026-02-15 23:21:22 +00:00
|
|
|
append_images_to_product(product, images, color_name)
|
2026-02-15 16:52:53 +00:00
|
|
|
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, _}} ->
|
|
|
|
|
Process.sleep(60_000)
|
|
|
|
|
poll_generator_task(task_key, attempt + 1)
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
{:error, reason}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
# Hero mode: collect all extra angle images (front, back, left, right, etc.)
|
|
|
|
|
# Front-only mode: just the main mockup URL
|
|
|
|
|
defp extract_images(result, :hero) do
|
2026-02-15 16:52:53 +00:00
|
|
|
(result["mockups"] || [])
|
|
|
|
|
|> Enum.flat_map(fn mockup ->
|
|
|
|
|
(mockup["extra"] || [])
|
|
|
|
|
|> Enum.map(fn extra ->
|
2026-02-15 23:21:22 +00:00
|
|
|
%{src: extra["url"], alt: extra["title"]}
|
2026-02-15 16:52:53 +00:00
|
|
|
end)
|
|
|
|
|
end)
|
|
|
|
|
|> Enum.reject(&is_nil(&1.src))
|
|
|
|
|
|> Enum.uniq_by(& &1.src)
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
defp extract_images(result, :front_only) do
|
|
|
|
|
(result["mockups"] || [])
|
|
|
|
|
|> Enum.take(1)
|
|
|
|
|
|> Enum.map(fn mockup ->
|
|
|
|
|
%{src: mockup["mockup_url"], alt: "Front"}
|
|
|
|
|
end)
|
|
|
|
|
|> Enum.reject(&is_nil(&1.src))
|
|
|
|
|
end
|
2026-02-15 16:58:54 +00:00
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
defp append_images_to_product(product, extra_images, color_name) do
|
|
|
|
|
existing_images = Products.list_product_images(product.id)
|
|
|
|
|
next_position = max_position(existing_images) + 1
|
2026-02-15 16:52:53 +00:00
|
|
|
|
|
|
|
|
results =
|
2026-02-15 23:21:22 +00:00
|
|
|
extra_images
|
|
|
|
|
|> Enum.with_index(next_position)
|
2026-02-15 16:52:53 +00:00
|
|
|
|> Enum.map(fn {img, position} ->
|
|
|
|
|
attrs = %{
|
|
|
|
|
product_id: product.id,
|
|
|
|
|
src: img.src,
|
|
|
|
|
alt: img.alt,
|
2026-02-15 23:21:22 +00:00
|
|
|
color: color_name,
|
2026-02-15 16:52:53 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
defp max_position([]), do: -1
|
|
|
|
|
|
|
|
|
|
defp max_position(images) do
|
|
|
|
|
images |> Enum.map(& &1.position) |> Enum.max()
|
2026-02-15 16:58:54 +00:00
|
|
|
end
|
|
|
|
|
|
2026-02-15 23:21:22 +00:00
|
|
|
# Check if this product already has mockup-enriched images (those with a color tag)
|
2026-02-15 16:52:53 +00:00
|
|
|
defp already_enriched?(product) do
|
|
|
|
|
images = Products.list_product_images(product.id)
|
2026-02-15 23:21:22 +00:00
|
|
|
|
|
|
|
|
Enum.any?(images, fn img ->
|
|
|
|
|
img.color != nil && img.alt in ["Front", "Back", "Left", "Right"]
|
|
|
|
|
end)
|
2026-02-15 16:52:53 +00:00
|
|
|
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
|