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:
jamey 2026-02-15 16:52:53 +00:00
parent 61cb2b7a87
commit 1aceaf9444
7 changed files with 916 additions and 76 deletions

View File

@ -1,32 +1,23 @@
defmodule Mix.Tasks.GenerateMockups do
@moduledoc """
Generates product mockups using the Printify API.
Generates product mockups using Printify or Printful APIs.
This task automates the creation of product mockups for the SimpleshopTheme
sample content. It downloads artwork from Unsplash, uploads it to Printify,
creates products, and downloads the generated mockups.
## Requirements
- A Printify account with API access
- The PRINTIFY_API_TOKEN environment variable must be set
sample content. It downloads artwork from Unsplash, uploads it to the
print-on-demand provider, creates products, and downloads the generated mockups.
## Usage
# Generate mockups (keeps products in Printify)
# Printify (default)
mix generate_mockups
# Delete existing products first, then generate fresh ones
mix generate_mockups --cleanup
mix generate_mockups --replace
# Generate mockups and delete products afterwards
mix generate_mockups --cleanup
# Search for available blueprints
mix generate_mockups --search "poster"
# List all blueprints
mix generate_mockups --list-blueprints
# Printful
mix generate_mockups --provider printful
mix generate_mockups --provider printful --mockups-only
mix generate_mockups --provider printful --products-only
mix generate_mockups --provider printful --cleanup
## Output
@ -35,13 +26,13 @@ defmodule Mix.Tasks.GenerateMockups do
use Mix.Task
alias SimpleshopTheme.Mockups.Generator, as: MockupGenerator
alias SimpleshopTheme.Mockups.Generator, as: PrintifyGenerator
alias SimpleshopTheme.Mockups.PrintfulGenerator
@shortdoc "Generates product mockups using Printify API"
@shortdoc "Generates product mockups using Printify or Printful API"
@impl Mix.Task
def run(args) do
# Start required applications
Mix.Task.run("app.start")
{opts, _, _} =
@ -51,21 +42,30 @@ defmodule Mix.Tasks.GenerateMockups do
replace: :boolean,
search: :string,
list_blueprints: :boolean,
help: :boolean
help: :boolean,
provider: :string,
mockups_only: :boolean,
products_only: :boolean
],
aliases: [
c: :cleanup,
r: :replace,
s: :search,
l: :list_blueprints,
h: :help
h: :help,
p: :provider
]
)
provider = Keyword.get(opts, :provider, "printify")
cond do
opts[:help] ->
print_help()
provider == "printful" ->
run_printful(opts)
opts[:list_blueprints] ->
list_blueprints()
@ -73,48 +73,51 @@ defmodule Mix.Tasks.GenerateMockups do
search_blueprints(opts[:search])
true ->
generate_mockups(opts)
run_printify(opts)
end
end
defp print_help do
Mix.shell().info("""
Printify Mockup Generator
=========================
Mockup Generator
================
Generates product mockups using the Printify API.
Generates product mockups using Printify or Printful APIs.
Usage:
mix generate_mockups [options]
Options:
--replace, -r Delete ALL existing products from Printify before generating (with confirmation)
--cleanup, -c Delete created products from Printify after downloading mockups
--search, -s TERM Search for blueprints by name
--list-blueprints List all available blueprint IDs and names
--help, -h Show this help message
Common options:
--provider, -p NAME Provider to use: "printify" (default) or "printful"
--cleanup, -c Delete created products after downloading mockups
--help, -h Show this help message
Printify options:
--replace, -r Delete ALL existing products before generating (with confirmation)
--search, -s TERM Search for blueprints by name
--list-blueprints List all available blueprint IDs and names
Printful options:
--mockups-only Only generate mockup images (skip product creation)
--products-only Only create sync products (skip mockup images)
Environment:
PRINTIFY_API_TOKEN Required. Your Printify API token.
PRINTIFY_API_TOKEN Required for Printify provider
PRINTFUL_API_TOKEN Required for Printful provider
Examples:
# Generate all mockups
export PRINTIFY_API_TOKEN="your-token"
mix generate_mockups
# Generate and cleanup
mix generate_mockups --cleanup
# Find blueprint IDs
mix generate_mockups --search "poster"
mix generate_mockups --provider printful
mix generate_mockups --provider printful --mockups-only
mix generate_mockups --provider printful --cleanup
""")
end
defp list_blueprints do
Mix.shell().info("Fetching blueprints from Printify...")
case MockupGenerator.list_blueprints() do
case PrintifyGenerator.list_blueprints() do
blueprints when is_list(blueprints) ->
Mix.shell().info("\nAvailable Blueprints:\n")
@ -133,7 +136,7 @@ defmodule Mix.Tasks.GenerateMockups do
defp search_blueprints(term) do
Mix.shell().info("Searching for blueprints matching '#{term}'...")
case MockupGenerator.search_blueprints(term) do
case PrintifyGenerator.search_blueprints(term) do
results when is_list(results) ->
if results == [] do
Mix.shell().info("No blueprints found matching '#{term}'")
@ -153,7 +156,11 @@ defmodule Mix.Tasks.GenerateMockups do
end
end
defp generate_mockups(opts) do
# ===========================================================================
# Printify
# ===========================================================================
defp run_printify(opts) do
cleanup = Keyword.get(opts, :cleanup, false)
replace = Keyword.get(opts, :replace, false)
@ -168,39 +175,27 @@ defmodule Mix.Tasks.GenerateMockups do
""")
# Verify API token is set
case System.get_env("PRINTIFY_API_TOKEN") do
nil ->
Mix.shell().error("""
Error: PRINTIFY_API_TOKEN environment variable is not set.
To get your API token:
1. Log in to Printify
2. Go to Settings > API tokens
3. Create a new token with required permissions
Then run:
Set it and retry:
export PRINTIFY_API_TOKEN="your-token"
mix generate_mockups
""")
_token ->
if replace, do: purge_existing_products()
if replace, do: purge_existing_printify_products()
results = MockupGenerator.generate_all(cleanup: cleanup)
results = PrintifyGenerator.generate_all(cleanup: cleanup)
# Summary
successful = Enum.count(results, &match?({:ok, _, _, _}, &1))
failed = Enum.count(results, &match?({:error, _, _}, &1))
Mix.shell().info("""
Summary
Successful: #{successful}
Failed: #{failed}
Summary: #{successful} succeeded, #{failed} failed
""")
if failed > 0 do
@ -211,7 +206,7 @@ defmodule Mix.Tasks.GenerateMockups do
end
end
defp purge_existing_products do
defp purge_existing_printify_products do
alias SimpleshopTheme.Clients.Printify, as: Client
Mix.shell().info("Fetching existing products...")
@ -226,11 +221,71 @@ defmodule Mix.Tasks.GenerateMockups do
Mix.shell().info("Found #{count} existing products in Printify.")
if Mix.shell().yes?("Delete all #{count} products before generating new ones?") do
deleted = MockupGenerator.purge_all_products(shop_id)
deleted = PrintifyGenerator.purge_all_products(shop_id)
Mix.shell().info("Deleted #{deleted} products.\n")
else
Mix.shell().info("Skipping purge.\n")
end
end
end
# ===========================================================================
# Printful
# ===========================================================================
defp run_printful(opts) do
mockups_only = Keyword.get(opts, :mockups_only, false)
products_only = Keyword.get(opts, :products_only, false)
cleanup = Keyword.get(opts, :cleanup, false)
do_mockups = !products_only
do_products = !mockups_only
Mix.shell().info("""
Printful Mockup Generator
Mockups: #{if do_mockups, do: "ON ", else: "OFF"} ║
Products: #{if do_products, do: "ON ", else: "OFF"} ║
Cleanup: #{if cleanup, do: "ON ", else: "OFF"} ║
""")
case System.get_env("PRINTFUL_API_TOKEN") do
nil ->
Mix.shell().error("""
Error: PRINTFUL_API_TOKEN environment variable is not set.
Set it and retry:
export PRINTFUL_API_TOKEN="your-token"
mix generate_mockups --provider printful
""")
_token ->
results =
PrintfulGenerator.generate_all(
mockups: do_mockups,
products: do_products,
cleanup: cleanup
)
mockup_ok = Enum.count(results.mockups, &match?({:ok, _, _}, &1))
mockup_fail = Enum.count(results.mockups, &match?({:error, _, _}, &1))
product_ok = Enum.count(results.products, &match?({:ok, _, _}, &1))
product_fail = Enum.count(results.products, &match?({:error, _, _}, &1))
Mix.shell().info("""
Summary:
Mockups: #{mockup_ok} succeeded, #{mockup_fail} failed
Products: #{product_ok} succeeded, #{product_fail} failed
""")
if mockup_fail + product_fail > 0 do
Mix.shell().error("Some operations failed. Check the output above for details.")
end
end
end
end

View File

@ -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)
# =============================================================================

View 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

View File

@ -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.
"""

View File

@ -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

View 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

View File

@ -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