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>
This commit is contained in:
@@ -172,6 +172,31 @@ defmodule SimpleshopTheme.Clients.Printful do
|
||||
get("/store/products/#{product_id}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Create a sync product with variants and design files.
|
||||
|
||||
## Example
|
||||
|
||||
create_sync_product(%{
|
||||
sync_product: %{name: "My T-Shirt"},
|
||||
sync_variants: [%{
|
||||
variant_id: 4011,
|
||||
retail_price: "29.99",
|
||||
files: [%{url: "https://example.com/design.png", type: "default"}]
|
||||
}]
|
||||
})
|
||||
"""
|
||||
def create_sync_product(product_data) do
|
||||
post("/store/products", product_data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delete a sync product and all its variants.
|
||||
"""
|
||||
def delete_sync_product(product_id) do
|
||||
delete("/store/products/#{product_id}")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Shipping (v2)
|
||||
# =============================================================================
|
||||
@@ -242,6 +267,28 @@ defmodule SimpleshopTheme.Clients.Printful do
|
||||
get(path)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Mockup generator (legacy, multi-angle)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Create a mockup generator task for a catalog product.
|
||||
|
||||
Returns `{:ok, %{"task_key" => "gt-...", "status" => "pending"}}`.
|
||||
"""
|
||||
def create_mockup_generator_task(catalog_product_id, body) do
|
||||
post("/mockup-generator/create-task/#{catalog_product_id}", body)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Poll a mockup generator task by task key.
|
||||
|
||||
Returns `{:ok, %{"status" => "completed", "mockups" => [...]}}` when done.
|
||||
"""
|
||||
def get_mockup_generator_task(task_key) do
|
||||
get("/mockup-generator/task?task_key=#{task_key}")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Files (v2)
|
||||
# =============================================================================
|
||||
|
||||
422
lib/simpleshop_theme/mockups/printful_generator.ex
Normal file
422
lib/simpleshop_theme/mockups/printful_generator.ex
Normal file
@@ -0,0 +1,422 @@
|
||||
defmodule SimpleshopTheme.Mockups.PrintfulGenerator do
|
||||
@moduledoc """
|
||||
Generates product mockups and/or creates demo products using the Printful API.
|
||||
|
||||
Two independent capabilities, composable via options:
|
||||
|
||||
1. **Mockup images** — uses the v2 mockup tasks API to generate on-product
|
||||
mockup images for the theme preview. Images are saved to `priv/static/mockups/`.
|
||||
|
||||
2. **Sync products** — creates real products in the Printful store via the
|
||||
v1 store products API. These can later be synced into the shop.
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Clients.Printful, as: Client
|
||||
alias SimpleshopTheme.Images.Optimizer
|
||||
alias SimpleshopTheme.Mockups.Generator
|
||||
|
||||
@output_dir "priv/static/mockups"
|
||||
@poll_interval_ms 2_000
|
||||
@max_poll_attempts 30
|
||||
@api_delay_ms 3_000
|
||||
@max_retries 3
|
||||
|
||||
# ============================================================================
|
||||
# Catalog config
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Maps product types to Printful catalog product IDs, techniques, and variant IDs.
|
||||
|
||||
Variant IDs are needed for sync product creation. Catalog product IDs and
|
||||
techniques are needed for mockup generation.
|
||||
"""
|
||||
def catalog_config do
|
||||
%{
|
||||
# Multiple variant IDs = multiple colour/size mockups per product
|
||||
canvas: %{
|
||||
catalog_product_id: 3,
|
||||
technique: "digital",
|
||||
variant_ids: [
|
||||
{19303, "16″×24″"},
|
||||
{19309, "20″×24″"},
|
||||
{19315, "24″×30″"},
|
||||
{825, "24″×36″"}
|
||||
]
|
||||
},
|
||||
tshirt: %{
|
||||
catalog_product_id: 71,
|
||||
technique: "dtg",
|
||||
variant_ids: [
|
||||
{4017, "Black / M"},
|
||||
{4027, "Ash / M"},
|
||||
{4022, "Aqua / M"},
|
||||
{4082, "Gold / M"},
|
||||
{8452, "Forest / M"}
|
||||
]
|
||||
},
|
||||
hoodie: %{
|
||||
catalog_product_id: 146,
|
||||
technique: "dtg",
|
||||
variant_ids: [
|
||||
{5531, "Black / M"},
|
||||
{20553, "Ash / M"},
|
||||
{21636, "Carolina Blue / M"},
|
||||
{5555, "Dark Chocolate / M"}
|
||||
]
|
||||
},
|
||||
tote: %{
|
||||
catalog_product_id: 274,
|
||||
technique: "cut-sew",
|
||||
variant_ids: [
|
||||
{9039, "Black"},
|
||||
{9040, "Red"},
|
||||
{9041, "Yellow"}
|
||||
]
|
||||
},
|
||||
mug: %{
|
||||
catalog_product_id: 19,
|
||||
technique: "sublimation",
|
||||
variant_ids: [{1320, "White / 11oz"}]
|
||||
},
|
||||
blanket: %{
|
||||
catalog_product_id: 395,
|
||||
technique: "sublimation",
|
||||
variant_ids: [{13222, "60″×80″"}]
|
||||
},
|
||||
laptop_sleeve: %{
|
||||
catalog_product_id: 394,
|
||||
technique: "sublimation",
|
||||
variant_ids: [{10984, "13″"}]
|
||||
},
|
||||
phone_case: %{
|
||||
catalog_product_id: 181,
|
||||
technique: "uv",
|
||||
variant_ids: [{17616, "iPhone 15 Pro Max"}]
|
||||
},
|
||||
poster: %{
|
||||
catalog_product_id: 1,
|
||||
technique: "digital",
|
||||
variant_ids: [
|
||||
{3876, "12″×18″"},
|
||||
{3877, "16″×20″"},
|
||||
{1, "18″×24″"}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Product definitions filtered to types available in Printful's catalog.
|
||||
|
||||
Reuses artwork URLs and slugs from the shared Printify generator definitions,
|
||||
but skips product types not available in Printful (cushion, notebook).
|
||||
"""
|
||||
def product_definitions do
|
||||
supported = Map.keys(catalog_config())
|
||||
|
||||
Generator.product_definitions()
|
||||
|> Enum.filter(fn def -> def.product_type in supported end)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Main entry point
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Generate mockups and/or create products.
|
||||
|
||||
## Options
|
||||
|
||||
* `:mockups` — generate mockup images (default: true)
|
||||
* `:products` — create sync products in Printful (default: true)
|
||||
* `:cleanup` — delete created sync products after (default: false)
|
||||
"""
|
||||
def generate_all(opts \\ []) do
|
||||
do_mockups = Keyword.get(opts, :mockups, true)
|
||||
do_products = Keyword.get(opts, :products, true)
|
||||
cleanup = Keyword.get(opts, :cleanup, false)
|
||||
|
||||
definitions = product_definitions()
|
||||
|
||||
IO.puts("Starting Printful generation...")
|
||||
IO.puts(" Mockups: #{do_mockups}, Products: #{do_products}, Cleanup: #{cleanup}")
|
||||
IO.puts(" #{length(definitions)} product definitions")
|
||||
IO.puts("")
|
||||
|
||||
# Generate mockup images
|
||||
mockup_results =
|
||||
if do_mockups do
|
||||
generate_mockups(definitions)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Create sync products
|
||||
product_results =
|
||||
if do_products do
|
||||
create_products(definitions)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Cleanup if requested
|
||||
if cleanup and do_products do
|
||||
IO.puts("")
|
||||
IO.puts("Cleaning up created products...")
|
||||
|
||||
product_results
|
||||
|> Enum.filter(&match?({:ok, _, _}, &1))
|
||||
|> Enum.each(fn {:ok, slug, product_id} ->
|
||||
IO.puts(" Deleting #{slug} (#{product_id})...")
|
||||
Client.delete_sync_product(product_id)
|
||||
Process.sleep(200)
|
||||
end)
|
||||
|
||||
IO.puts("Cleanup complete.")
|
||||
end
|
||||
|
||||
IO.puts("")
|
||||
IO.puts("Printful generation complete!")
|
||||
|
||||
%{mockups: mockup_results, products: product_results}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Mockup generation (v2 mockup tasks)
|
||||
# ============================================================================
|
||||
|
||||
defp generate_mockups(definitions) do
|
||||
IO.puts("Generating mockup images...")
|
||||
IO.puts("")
|
||||
|
||||
definitions
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {product_def, index} ->
|
||||
if index > 0, do: Process.sleep(@api_delay_ms)
|
||||
IO.puts(" Mockup: #{product_def.name}")
|
||||
|
||||
case generate_single_mockup(product_def) do
|
||||
{:ok, paths} ->
|
||||
IO.puts(" ✓ #{length(paths)} mockup(s)")
|
||||
{:ok, product_def.slug, paths}
|
||||
|
||||
{:error, reason} ->
|
||||
IO.puts(" ✗ #{inspect(reason)}")
|
||||
{:error, product_def.slug, reason}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp generate_single_mockup(product_def) do
|
||||
config = catalog_config()[product_def.product_type]
|
||||
|
||||
variant_ids = Enum.map(config.variant_ids, fn {id, _label} -> id end)
|
||||
|
||||
task_body = %{
|
||||
format: "jpg",
|
||||
products: [
|
||||
%{
|
||||
source: "catalog",
|
||||
catalog_product_id: config.catalog_product_id,
|
||||
catalog_variant_ids: variant_ids,
|
||||
placements: [
|
||||
%{
|
||||
placement: "front",
|
||||
technique: config.technique,
|
||||
layers: [
|
||||
%{type: "file", url: product_def.artwork_url}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with {:ok, task_data} <- Client.create_mockup_task(task_body),
|
||||
task_id <- extract_task_id(task_data),
|
||||
{:ok, completed} <- poll_mockup_task(task_id),
|
||||
mockup_urls <- extract_mockup_urls(completed) do
|
||||
download_mockups(product_def.slug, mockup_urls)
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_task_id(data) when is_list(data), do: hd(data)["id"]
|
||||
defp extract_task_id(%{"id" => id}), do: id
|
||||
defp extract_task_id(data), do: data["id"]
|
||||
|
||||
defp poll_mockup_task(task_id) do
|
||||
poll_mockup_task(task_id, 0)
|
||||
end
|
||||
|
||||
defp poll_mockup_task(_task_id, attempt) when attempt >= @max_poll_attempts do
|
||||
{:error, :timeout}
|
||||
end
|
||||
|
||||
defp poll_mockup_task(task_id, attempt) do
|
||||
Process.sleep(@poll_interval_ms)
|
||||
|
||||
case Client.get_mockup_tasks(%{"id" => task_id}) do
|
||||
{:ok, data} ->
|
||||
task = find_task(data, task_id)
|
||||
|
||||
case task["status"] do
|
||||
"completed" ->
|
||||
{:ok, task}
|
||||
|
||||
"failed" ->
|
||||
{:error, {:mockup_failed, task["failure_reasons"]}}
|
||||
|
||||
_pending ->
|
||||
IO.puts(" Polling... (#{attempt + 1}/#{@max_poll_attempts})")
|
||||
poll_mockup_task(task_id, attempt + 1)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_task(data, task_id) when is_list(data) do
|
||||
Enum.find(data, fn t -> t["id"] == task_id end) || hd(data)
|
||||
end
|
||||
|
||||
defp find_task(data, _task_id) when is_map(data), do: data
|
||||
|
||||
defp extract_mockup_urls(task) do
|
||||
(task["catalog_variant_mockups"] || [])
|
||||
|> Enum.flat_map(fn cvm -> cvm["mockups"] || [] end)
|
||||
|> Enum.map(fn m -> m["mockup_url"] end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Image download (shared logic with Printify generator)
|
||||
# ============================================================================
|
||||
|
||||
defp download_mockups(product_slug, mockup_urls) do
|
||||
File.mkdir_p!(@output_dir)
|
||||
|
||||
results =
|
||||
mockup_urls
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {url, index} ->
|
||||
basename = "#{product_slug}-#{index}"
|
||||
source_path = Path.join(@output_dir, "#{basename}.webp")
|
||||
IO.puts(" Processing mockup #{index}...")
|
||||
|
||||
temp_path = Path.join(System.tmp_dir!(), "#{basename}-pf-temp.jpg")
|
||||
|
||||
with {:ok, _} <- download_file(url, temp_path),
|
||||
{:ok, image_data} <- File.read(temp_path),
|
||||
{:ok, webp_data, source_width, _} <- Optimizer.to_optimized_webp(image_data),
|
||||
:ok <- File.write(source_path, webp_data),
|
||||
{:ok, _} <- Optimizer.process_file(webp_data, basename, @output_dir) do
|
||||
File.rm(temp_path)
|
||||
IO.puts(" Saved #{basename} (#{source_width}px)")
|
||||
{:ok, basename, source_width}
|
||||
else
|
||||
{:error, reason} ->
|
||||
File.rm(temp_path)
|
||||
{:error, {url, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
successful = Enum.filter(results, &match?({:ok, _, _}, &1))
|
||||
{:ok, Enum.map(successful, fn {:ok, basename, _} -> basename end)}
|
||||
end
|
||||
|
||||
# Download without auth headers (mockup URLs are pre-signed S3 URLs)
|
||||
defp download_file(url, output_path) do
|
||||
case Req.get(url, into: File.stream!(output_path), receive_timeout: 60_000) do
|
||||
{:ok, %Req.Response{status: status}} when status in 200..299 ->
|
||||
{:ok, output_path}
|
||||
|
||||
{:ok, %Req.Response{status: status}} ->
|
||||
File.rm(output_path)
|
||||
{:error, {:http_error, status}}
|
||||
|
||||
{:error, reason} ->
|
||||
File.rm(output_path)
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Sync product creation (v1 store products)
|
||||
# ============================================================================
|
||||
|
||||
defp create_products(definitions) do
|
||||
IO.puts("Creating sync products...")
|
||||
IO.puts("")
|
||||
|
||||
definitions
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {product_def, index} ->
|
||||
if index > 0, do: Process.sleep(@api_delay_ms)
|
||||
IO.puts(" Product: #{product_def.name}")
|
||||
|
||||
case create_single_product(product_def) do
|
||||
{:ok, product_id} ->
|
||||
IO.puts(" ✓ Created (ID: #{product_id})")
|
||||
{:ok, product_def.slug, product_id}
|
||||
|
||||
{:error, reason} ->
|
||||
IO.puts(" ✗ #{inspect(reason)}")
|
||||
{:error, product_def.slug, reason}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_single_product(product_def) do
|
||||
config = catalog_config()[product_def.product_type]
|
||||
|
||||
price =
|
||||
product_def.price
|
||||
|> Decimal.new()
|
||||
|> Decimal.div(100)
|
||||
|> Decimal.to_string()
|
||||
|
||||
sync_variants =
|
||||
Enum.map(config.variant_ids, fn {variant_id, _label} ->
|
||||
%{
|
||||
variant_id: variant_id,
|
||||
retail_price: price,
|
||||
files: [%{url: product_def.artwork_url, type: "default"}]
|
||||
}
|
||||
end)
|
||||
|
||||
product_data = %{
|
||||
sync_product: %{name: product_def.name},
|
||||
sync_variants: sync_variants
|
||||
}
|
||||
|
||||
with_retry(fn -> Client.create_sync_product(product_data) end)
|
||||
|> case do
|
||||
{:ok, result} -> {:ok, result["id"]}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Retry logic for rate limits
|
||||
# ============================================================================
|
||||
|
||||
defp with_retry(fun, attempt \\ 1) do
|
||||
case fun.() do
|
||||
{:error, {429, _}} when attempt <= @max_retries ->
|
||||
wait = attempt * 60_000
|
||||
|
||||
IO.puts(
|
||||
" Rate limited, waiting #{div(wait, 1000)}s (retry #{attempt}/#{@max_retries})..."
|
||||
)
|
||||
|
||||
Process.sleep(wait)
|
||||
with_retry(fun, attempt + 1)
|
||||
|
||||
result ->
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -352,9 +352,23 @@ defmodule SimpleshopTheme.Products do
|
||||
error -> error
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Not found or belongs to different connection - insert new
|
||||
nil ->
|
||||
# Not found at all - insert new
|
||||
do_insert_product(attrs)
|
||||
|
||||
_different_connection ->
|
||||
# Slug taken by a different provider connection - make it unique
|
||||
unique_slug = make_unique_slug(slug)
|
||||
do_insert_product(Map.put(attrs, :slug, unique_slug))
|
||||
end
|
||||
end
|
||||
|
||||
defp make_unique_slug(base_slug, suffix \\ 2) do
|
||||
candidate = "#{base_slug}-#{suffix}"
|
||||
|
||||
case Repo.get_by(Product, slug: candidate) do
|
||||
nil -> candidate
|
||||
_ -> make_unique_slug(base_slug, suffix + 1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -421,6 +435,14 @@ defmodule SimpleshopTheme.Products do
|
||||
Repo.get(ProductImage, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all images for a product, ordered by position.
|
||||
"""
|
||||
def list_product_images(product_id) do
|
||||
from(i in ProductImage, where: i.product_id == ^product_id, order_by: i.position)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Links a product image to a Media.Image by setting its image_id.
|
||||
"""
|
||||
|
||||
@@ -314,6 +314,7 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
blueprint_id: catalog_product_id,
|
||||
print_provider_id: 0,
|
||||
thumbnail_url: sync_product["thumbnail_url"],
|
||||
artwork_url: extract_artwork_url(sync_variants),
|
||||
options: build_option_types(sync_variants),
|
||||
raw: %{sync_product: sync_product}
|
||||
}
|
||||
@@ -345,7 +346,7 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
opts
|
||||
end
|
||||
|
||||
# Extract unique preview images from sync variants (one per unique colour)
|
||||
# Extract unique preview images from sync variants (one per unique image URL)
|
||||
defp extract_preview_images(sync_variants) do
|
||||
sync_variants
|
||||
|> Enum.flat_map(fn sv ->
|
||||
@@ -354,18 +355,28 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
|> Enum.map(fn file ->
|
||||
%{
|
||||
src: file["preview_url"] || file["thumbnail_url"],
|
||||
color: sv["color"]
|
||||
color: sv["color"],
|
||||
name: sv["name"]
|
||||
}
|
||||
end)
|
||||
end)
|
||||
|> Enum.uniq_by(& &1.color)
|
||||
|> Enum.uniq_by(& &1.src)
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {img, index} ->
|
||||
%{
|
||||
src: img.src,
|
||||
position: index,
|
||||
alt: img.color
|
||||
}
|
||||
alt = if img.color not in [nil, ""], do: img.color, else: img.name
|
||||
%{src: img.src, position: index, alt: alt}
|
||||
end)
|
||||
end
|
||||
|
||||
# Find the artwork (design file) URL from the first variant's "default" file
|
||||
defp extract_artwork_url(sync_variants) do
|
||||
sync_variants
|
||||
|> Enum.find_value(fn sv ->
|
||||
(sv["files"] || [])
|
||||
|> Enum.find_value(fn
|
||||
%{"type" => "default", "url" => url} when is_binary(url) -> url
|
||||
_ -> nil
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -423,10 +434,10 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
|
||||
cond do
|
||||
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
|
||||
has_keyword?(name_lower, ~w[bag tote hat cap sleeve phone case]) -> "Accessories"
|
||||
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion throw]) -> "Homewares"
|
||||
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
|
||||
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
|
||||
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
||||
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
|
||||
true -> "Apparel"
|
||||
end
|
||||
end
|
||||
|
||||
254
lib/simpleshop_theme/sync/mockup_enricher.ex
Normal file
254
lib/simpleshop_theme/sync/mockup_enricher.ex
Normal file
@@ -0,0 +1,254 @@
|
||||
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
|
||||
@@ -21,6 +21,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
alias SimpleshopTheme.Products.ProviderConnection
|
||||
alias SimpleshopTheme.Providers.Provider
|
||||
alias SimpleshopTheme.Sync.ImageDownloadWorker
|
||||
alias SimpleshopTheme.Sync.MockupEnricher
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -96,6 +97,11 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
|
||||
)
|
||||
|
||||
# Enqueue mockup enrichment for Printful products (extra angle images)
|
||||
if conn.provider_type == "printful" do
|
||||
enqueue_mockup_enrichment(conn, results)
|
||||
end
|
||||
|
||||
# Sync shipping rates (non-fatal — logged and skipped on failure)
|
||||
sync_shipping_rates(conn, provider, products)
|
||||
|
||||
@@ -228,4 +234,27 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
"Shipping rate sync crashed for #{conn.provider_type}: #{Exception.message(e)}"
|
||||
)
|
||||
end
|
||||
|
||||
# Enqueue MockupEnricher jobs for created/updated Printful products
|
||||
defp enqueue_mockup_enrichment(conn, results) do
|
||||
products_to_enrich =
|
||||
results
|
||||
|> Enum.filter(&match?({:ok, _, status} when status in [:created, :updated], &1))
|
||||
|> Enum.map(fn {:ok, product, _status} -> product end)
|
||||
|
||||
if products_to_enrich != [] do
|
||||
Logger.info(
|
||||
"Enqueueing mockup enrichment for #{length(products_to_enrich)} Printful product(s)"
|
||||
)
|
||||
|
||||
products_to_enrich
|
||||
|> Enum.with_index()
|
||||
|> Enum.each(fn {product, index} ->
|
||||
MockupEnricher.enqueue(conn.id, product.id, index)
|
||||
end)
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Mockup enrichment enqueue failed: #{Exception.message(e)}")
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user