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
|