berrypod/lib/simpleshop_theme/mockups/printful_generator.ex
jamey 1aceaf9444 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>
2026-02-15 16:52:53 +00:00

423 lines
12 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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