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>
423 lines
12 KiB
Elixir
423 lines
12 KiB
Elixir
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
|