add per-colour product images and gallery colour filtering
Tag product images with their colour during sync (both Printful and Printify providers). Printify images are cherry-picked: hero colour keeps all angles, other colours keep front + back only. Printful MockupEnricher now generates mockups per colour from the color_variant_map. PDP gallery filters by the selected colour, falling back to all images when the selected colour has none. Fix option name mismatch (Printify "Colors" vs variant "Color") by singularizing in Product.option_types. Generator creates multi-colour apparel products so mock data matches real sync behaviour. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
This worker uses the legacy mockup generator API to produce extra angles
|
||||
(back, left, right, etc.) and appends them as additional product images.
|
||||
|
||||
The hero colour gets full angle coverage (front, back, left, right).
|
||||
Other colours get a front-view mockup only (one API call each).
|
||||
|
||||
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.
|
||||
@@ -22,6 +25,7 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
|
||||
@poll_interval_ms 3_000
|
||||
@max_poll_attempts 20
|
||||
@inter_color_delay_ms 5_000
|
||||
|
||||
# Mockup generator config per catalog product type:
|
||||
# {placement, area_width, area_height}
|
||||
@@ -78,8 +82,8 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
|
||||
artwork_url = provider_data["artwork_url"] || provider_data[:artwork_url]
|
||||
|
||||
catalog_variant_ids =
|
||||
provider_data["catalog_variant_ids"] || provider_data[:catalog_variant_ids] || []
|
||||
color_variant_map =
|
||||
provider_data["color_variant_map"] || provider_data[:color_variant_map] || %{}
|
||||
|
||||
cond do
|
||||
is_nil(catalog_product_id) ->
|
||||
@@ -95,33 +99,96 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
: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
|
||||
enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map)
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_and_append(product, catalog_product_id, variant_id, artwork_url) do
|
||||
defp enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map) do
|
||||
colors = Map.to_list(color_variant_map)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
{placement, area_width, area_height} =
|
||||
Map.get(@product_configs, catalog_product_id, {"front", 4500, 5100})
|
||||
|
||||
@@ -146,12 +213,13 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
|
||||
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, result} <- poll_generator_task(task_key) do
|
||||
images = extract_images(result, mode)
|
||||
|
||||
if images == [] do
|
||||
{:ok, 0}
|
||||
else
|
||||
append_images_to_product(product, extra_images)
|
||||
append_images_to_product(product, images, color_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -176,7 +244,6 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
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)
|
||||
|
||||
@@ -185,55 +252,42 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
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
|
||||
# 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
|
||||
(result["mockups"] || [])
|
||||
|> Enum.flat_map(fn mockup ->
|
||||
(mockup["extra"] || [])
|
||||
|> Enum.map(fn extra ->
|
||||
%{
|
||||
src: extra["url"],
|
||||
alt: extra["title"]
|
||||
}
|
||||
%{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
|
||||
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
|
||||
|
||||
defp append_images_to_product(product, extra_images, color_name) 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
|
||||
next_position = max_position(existing_images) + 1
|
||||
|
||||
results =
|
||||
sorted_extras
|
||||
|> Enum.with_index(start_position)
|
||||
extra_images
|
||||
|> Enum.with_index(next_position)
|
||||
|> Enum.map(fn {img, position} ->
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
src: img.src,
|
||||
alt: img.alt,
|
||||
color: color_name,
|
||||
position: position
|
||||
}
|
||||
|
||||
@@ -252,17 +306,19 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
{: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)
|
||||
defp max_position([]), do: -1
|
||||
|
||||
defp max_position(images) do
|
||||
images |> Enum.map(& &1.position) |> Enum.max()
|
||||
end
|
||||
|
||||
# Check if this product already has extra angle images from a prior enrichment
|
||||
# Check if this product already has mockup-enriched images (those with a color tag)
|
||||
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)
|
||||
|
||||
Enum.any?(images, fn img ->
|
||||
img.color != nil && img.alt in ["Front", "Back", "Left", "Right"]
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_credentials(conn) do
|
||||
|
||||
@@ -176,7 +176,8 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
%{
|
||||
src: img[:src],
|
||||
position: img[:position],
|
||||
alt: img[:alt]
|
||||
alt: img[:alt],
|
||||
color: img[:color]
|
||||
}
|
||||
end)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user