Tag product images with their colour during sync (both Printful and Printify providers). Printify images are cherry-picked: hero colour keeps all angles, other colours keep front + back only. Printful MockupEnricher now generates mockups per colour from the color_variant_map. PDP gallery filters by the selected colour, falling back to all images when the selected colour has none. Fix option name mismatch (Printify "Colors" vs variant "Color") by singularizing in Product.option_types. Generator creates multi-colour apparel products so mock data matches real sync behaviour. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
569 lines
18 KiB
Elixir
569 lines
18 KiB
Elixir
defmodule SimpleshopTheme.Mockups.Generator do
|
|
@moduledoc """
|
|
Generates product mockups using the Printify API.
|
|
|
|
This module handles the end-to-end process of:
|
|
1. Looking up product blueprints and variants
|
|
2. Downloading artwork from Unsplash
|
|
3. Uploading artwork to Printify
|
|
4. Creating products with the artwork
|
|
5. Downloading generated mockup images
|
|
6. Optionally cleaning up created products
|
|
"""
|
|
|
|
alias SimpleshopTheme.Clients.Printify, as: Client
|
|
|
|
@output_dir "priv/static/mockups"
|
|
|
|
@doc """
|
|
Product definitions with their artwork URLs and Printify product types.
|
|
"""
|
|
def product_definitions do
|
|
[
|
|
%{
|
|
name: "Mountain Sunrise Canvas",
|
|
slug: "mountain-sunrise-canvas",
|
|
category: "Canvas Prints",
|
|
artwork_url: unsplash_download_url("UweNcthlmDc"),
|
|
product_type: :canvas,
|
|
price: 2400
|
|
},
|
|
%{
|
|
name: "Ocean Waves Canvas",
|
|
slug: "ocean-waves-canvas",
|
|
category: "Canvas Prints",
|
|
artwork_url: unsplash_download_url("XRhUTUVuXAE"),
|
|
product_type: :canvas,
|
|
price: 2400
|
|
},
|
|
%{
|
|
name: "Wildflower Meadow Canvas",
|
|
slug: "wildflower-meadow-canvas",
|
|
category: "Canvas Prints",
|
|
artwork_url: unsplash_download_url("QvjL4y7SF9k"),
|
|
product_type: :canvas,
|
|
price: 2400
|
|
},
|
|
%{
|
|
name: "Geometric Abstract Canvas",
|
|
slug: "geometric-abstract-canvas",
|
|
category: "Canvas Prints",
|
|
artwork_url: unsplash_download_url("-6GvTDpkkPU"),
|
|
product_type: :canvas,
|
|
price: 2800
|
|
},
|
|
%{
|
|
name: "Botanical Illustration Canvas",
|
|
slug: "botanical-illustration-canvas",
|
|
category: "Canvas Prints",
|
|
artwork_url: unsplash_download_url("FNtNIDQWUZY"),
|
|
product_type: :canvas,
|
|
price: 2400
|
|
},
|
|
%{
|
|
name: "Forest Silhouette T-Shirt",
|
|
slug: "forest-silhouette-tshirt",
|
|
category: "Apparel",
|
|
artwork_url: unsplash_download_url("EhvMzMRO4_o"),
|
|
product_type: :tshirt,
|
|
price: 2999,
|
|
colors: ["Black", "White", "Sport Grey", "Forest Green"]
|
|
},
|
|
%{
|
|
name: "Forest Light Hoodie",
|
|
slug: "forest-light-hoodie",
|
|
category: "Apparel",
|
|
artwork_url: unsplash_download_url("FwVkxITt8Bg"),
|
|
product_type: :hoodie,
|
|
price: 4499,
|
|
colors: ["Dark Heather", "Navy", "Forest Green", "Sand"]
|
|
},
|
|
%{
|
|
name: "Wildflower Meadow Tote Bag",
|
|
slug: "wildflower-meadow-tote",
|
|
category: "Apparel",
|
|
artwork_url: unsplash_download_url("QvjL4y7SF9k"),
|
|
product_type: :tote,
|
|
price: 1999
|
|
},
|
|
%{
|
|
name: "Sunset Gradient Tote Bag",
|
|
slug: "sunset-gradient-tote",
|
|
category: "Apparel",
|
|
artwork_url: unsplash_download_url("XRhUTUVuXAE"),
|
|
product_type: :tote,
|
|
price: 1999
|
|
},
|
|
%{
|
|
name: "Fern Leaf Mug",
|
|
slug: "fern-leaf-mug",
|
|
category: "Homewares",
|
|
artwork_url: unsplash_download_url("bYiJojtkHnc"),
|
|
product_type: :mug,
|
|
price: 1499
|
|
},
|
|
%{
|
|
name: "Ocean Waves Cushion",
|
|
slug: "ocean-waves-cushion",
|
|
category: "Homewares",
|
|
artwork_url: unsplash_download_url("XRhUTUVuXAE"),
|
|
product_type: :cushion,
|
|
price: 2999
|
|
},
|
|
%{
|
|
name: "Night Sky Blanket",
|
|
slug: "night-sky-blanket",
|
|
category: "Homewares",
|
|
artwork_url: unsplash_download_url("oQR1B87HsNs"),
|
|
product_type: :blanket,
|
|
price: 5999
|
|
},
|
|
%{
|
|
name: "Autumn Leaves Notebook",
|
|
slug: "autumn-leaves-notebook",
|
|
category: "Stationery",
|
|
artwork_url: unsplash_download_url("Aa3ALtIxEGY"),
|
|
product_type: :notebook,
|
|
price: 1999
|
|
},
|
|
%{
|
|
name: "Monstera Leaf Notebook",
|
|
slug: "monstera-leaf-notebook",
|
|
category: "Stationery",
|
|
artwork_url: unsplash_download_url("hETU8_b2IM0"),
|
|
product_type: :notebook,
|
|
price: 1999
|
|
},
|
|
%{
|
|
name: "Monstera Leaf Phone Case",
|
|
slug: "monstera-leaf-phone-case",
|
|
category: "Accessories",
|
|
artwork_url: unsplash_download_url("hETU8_b2IM0"),
|
|
product_type: :phone_case,
|
|
price: 2499
|
|
},
|
|
%{
|
|
name: "Blue Waves Laptop Sleeve",
|
|
slug: "blue-waves-laptop-sleeve",
|
|
category: "Accessories",
|
|
artwork_url: unsplash_download_url("dYksH3vHorc"),
|
|
product_type: :laptop_sleeve,
|
|
price: 3499
|
|
}
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Blueprint configurations for each product type.
|
|
These IDs need to be looked up from the Printify catalog.
|
|
"""
|
|
def blueprint_config do
|
|
%{
|
|
# Search terms matched to Printify's actual blueprint titles (partial match).
|
|
# preferred_provider_id selects a UK-based provider where available:
|
|
# 29 = Monster Digital (UK) — apparel, mugs
|
|
# 72 = Print Clever (UK) — canvas prints
|
|
canvas: %{search_term: "Satin Canvas, Stretched", preferred_provider_id: 72},
|
|
tshirt: %{search_term: "Softstyle T-Shirt", preferred_provider_id: 29},
|
|
hoodie: %{search_term: "Heavy Blend™ Hooded Sweatshirt", preferred_provider_id: 29},
|
|
tote: %{search_term: "Cotton Tote Bag", preferred_provider_id: nil},
|
|
mug: %{search_term: "Ceramic Mug", preferred_provider_id: 29},
|
|
cushion: %{search_term: "Spun Polyester Square Pillow", preferred_provider_id: nil},
|
|
blanket: %{search_term: "Sherpa Fleece Blanket", preferred_provider_id: nil},
|
|
notebook: %{search_term: "Hardcover Journal Matte", preferred_provider_id: nil},
|
|
phone_case: %{search_term: "Tough Phone Cases", preferred_provider_id: nil},
|
|
laptop_sleeve: %{search_term: "Laptop Sleeve", preferred_provider_id: nil}
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Generate Unsplash download URL from photo ID.
|
|
Uses the Unsplash download API which provides high-quality images.
|
|
"""
|
|
def unsplash_download_url(photo_id) do
|
|
"https://unsplash.com/photos/#{photo_id}/download?force=true"
|
|
end
|
|
|
|
@doc """
|
|
Search for a blueprint by name/term.
|
|
"""
|
|
def find_blueprint(search_term) do
|
|
case Client.get_blueprints() do
|
|
{:ok, blueprints} ->
|
|
found =
|
|
Enum.find(blueprints, fn bp ->
|
|
String.contains?(String.downcase(bp["title"] || ""), String.downcase(search_term))
|
|
end)
|
|
|
|
case found do
|
|
nil -> {:error, {:blueprint_not_found, search_term}}
|
|
bp -> {:ok, bp}
|
|
end
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Find a suitable print provider for a blueprint.
|
|
|
|
When `preferred_provider_id` is given, uses that provider if available
|
|
for the blueprint. Falls back to the first provider otherwise.
|
|
"""
|
|
def find_print_provider(blueprint_id, preferred_provider_id \\ nil) do
|
|
case Client.get_print_providers(blueprint_id) do
|
|
{:ok, providers} when is_list(providers) and providers != [] ->
|
|
provider =
|
|
if preferred_provider_id do
|
|
Enum.find(providers, fn p -> p["id"] == preferred_provider_id end)
|
|
end
|
|
|
|
{:ok, provider || hd(providers)}
|
|
|
|
{:ok, []} ->
|
|
{:error, :no_providers}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get variant and placeholder information for a blueprint/provider combination.
|
|
"""
|
|
def get_variant_info(blueprint_id, print_provider_id) do
|
|
case Client.get_variants(blueprint_id, print_provider_id) do
|
|
{:ok, %{"variants" => variants}} when is_list(variants) ->
|
|
{:ok, variants}
|
|
|
|
{:ok, variants} when is_list(variants) ->
|
|
{:ok, variants}
|
|
|
|
{:ok, response} when is_map(response) ->
|
|
# Handle case where variants might be nested differently
|
|
{:ok, response["variants"] || []}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Upload artwork to Printify from a URL.
|
|
"""
|
|
def upload_artwork(name, url) do
|
|
file_name = "#{name}.jpg"
|
|
Client.upload_image(file_name, url)
|
|
end
|
|
|
|
@doc """
|
|
Calculate scale factor for "cover" behavior.
|
|
Image will fill entire placeholder, cropping edges if necessary.
|
|
Printify scale is relative to placeholder width (1.0 = artwork width matches placeholder width).
|
|
"""
|
|
def calculate_cover_scale(artwork_width, artwork_height, placeholder_width, placeholder_height)
|
|
when is_number(artwork_width) and is_number(artwork_height) and
|
|
is_number(placeholder_width) and is_number(placeholder_height) and
|
|
artwork_width > 0 and artwork_height > 0 and
|
|
placeholder_width > 0 and placeholder_height > 0 do
|
|
# For cover: use the larger scale to ensure full coverage
|
|
width_scale = 1.0
|
|
height_scale = placeholder_height * artwork_width / (artwork_height * placeholder_width)
|
|
max(width_scale, height_scale)
|
|
end
|
|
|
|
def calculate_cover_scale(_, _, _, _), do: 1.0
|
|
|
|
@doc """
|
|
Create a product with the uploaded artwork.
|
|
|
|
When the product definition includes a `colors` list, enables one variant
|
|
per colour (picking a middle size for each). Printify generates mockup
|
|
images for every enabled colour automatically.
|
|
"""
|
|
def create_product(
|
|
shop_id,
|
|
product_def,
|
|
image_id,
|
|
image_width,
|
|
image_height,
|
|
blueprint_id,
|
|
print_provider_id,
|
|
variants
|
|
) do
|
|
selected_variants = select_variants(variants, product_def)
|
|
|
|
IO.puts(" Enabling #{length(selected_variants)} variant(s)")
|
|
|
|
# Use the first selected variant for placeholder/scale calculations
|
|
variant = hd(selected_variants)
|
|
placeholders = variant["placeholders"] || []
|
|
|
|
front_placeholder =
|
|
Enum.find(placeholders, fn p -> p["position"] == "front" end) || hd(placeholders)
|
|
|
|
placeholder_width = front_placeholder["width"]
|
|
placeholder_height = front_placeholder["height"]
|
|
|
|
scale =
|
|
calculate_cover_scale(image_width, image_height, placeholder_width, placeholder_height)
|
|
|
|
IO.puts(
|
|
" Scale calculation: artwork #{image_width}x#{image_height}, placeholder #{placeholder_width}x#{placeholder_height} -> scale #{Float.round(scale, 3)}"
|
|
)
|
|
|
|
variant_ids = Enum.map(selected_variants, & &1["id"])
|
|
|
|
product_data = %{
|
|
title: product_def.name,
|
|
description: "#{product_def.name} - Nature-inspired design from Wildprint Studio",
|
|
blueprint_id: blueprint_id,
|
|
print_provider_id: print_provider_id,
|
|
variants:
|
|
Enum.map(selected_variants, fn v ->
|
|
%{id: v["id"], price: product_def.price, is_enabled: true}
|
|
end),
|
|
print_areas: [
|
|
%{
|
|
variant_ids: variant_ids,
|
|
placeholders: [
|
|
%{
|
|
position: front_placeholder["position"] || "front",
|
|
images: [
|
|
%{id: image_id, x: 0.5, y: 0.5, scale: scale, angle: 0}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Client.create_product(shop_id, product_data)
|
|
end
|
|
|
|
# Pick one variant per requested colour (middle size), or fall back to hd.
|
|
defp select_variants(variants, %{colors: colors}) when is_list(colors) and colors != [] do
|
|
# Group variants by the colour portion of their title ("Dark Heather / L" → "Dark Heather")
|
|
by_color =
|
|
Enum.group_by(variants, fn v ->
|
|
v["title"] |> to_string() |> String.split(" / ") |> hd() |> String.trim()
|
|
end)
|
|
|
|
selected =
|
|
Enum.flat_map(colors, fn color ->
|
|
case Map.get(by_color, color) do
|
|
nil ->
|
|
IO.puts(" Warning: colour #{inspect(color)} not found in blueprint variants")
|
|
[]
|
|
|
|
color_variants ->
|
|
# Pick the middle variant (typically a medium size)
|
|
mid = div(length(color_variants), 2)
|
|
[Enum.at(color_variants, mid)]
|
|
end
|
|
end)
|
|
|
|
if selected == [], do: [hd(variants)], else: selected
|
|
end
|
|
|
|
defp select_variants(variants, _product_def), do: [hd(variants)]
|
|
|
|
@doc """
|
|
Extract mockup image URLs from a created product.
|
|
"""
|
|
def extract_mockup_urls(product) do
|
|
images = product["images"] || []
|
|
Enum.map(images, fn img -> img["src"] end)
|
|
end
|
|
|
|
@doc """
|
|
Download mockup images, save as WebP source, and generate variants.
|
|
Sources are saved for regeneration on startup via VariantCache.
|
|
"""
|
|
def download_mockups(product_slug, mockup_urls) do
|
|
alias SimpleshopTheme.Images.Optimizer
|
|
|
|
File.mkdir_p!(@output_dir)
|
|
|
|
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}-temp.jpg")
|
|
|
|
with {:ok, _} <- Client.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 source + variants for #{basename} (#{source_width}px)")
|
|
{:ok, basename, source_width}
|
|
else
|
|
{:error, reason} ->
|
|
File.rm(temp_path)
|
|
{:error, {url, reason}}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Deletes all products from the Printify shop.
|
|
Returns the number of products deleted.
|
|
"""
|
|
def purge_all_products(shop_id) do
|
|
case Client.list_products(shop_id) do
|
|
{:ok, %{"data" => products}} when is_list(products) ->
|
|
Enum.each(products, fn p ->
|
|
IO.puts(" Deleting: #{p["title"]} (#{p["id"]})")
|
|
Client.delete_product(shop_id, p["id"])
|
|
Process.sleep(200)
|
|
end)
|
|
|
|
length(products)
|
|
|
|
_ ->
|
|
0
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Generate mockups for all products.
|
|
"""
|
|
def generate_all(opts \\ []) do
|
|
cleanup = Keyword.get(opts, :cleanup, false)
|
|
|
|
IO.puts("Starting mockup generation...")
|
|
IO.puts("")
|
|
|
|
# Get shop ID
|
|
IO.puts("Fetching shop ID...")
|
|
{:ok, shop_id} = Client.get_shop_id()
|
|
IO.puts("Using shop ID: #{shop_id}")
|
|
IO.puts("")
|
|
|
|
results =
|
|
product_definitions()
|
|
|> Enum.map(fn product_def ->
|
|
IO.puts("Processing: #{product_def.name}")
|
|
result = generate_single(shop_id, product_def)
|
|
|
|
case result do
|
|
{:ok, product_id, mockup_paths} ->
|
|
IO.puts(" ✓ Generated #{length(mockup_paths)} mockups")
|
|
{:ok, product_def.slug, product_id, mockup_paths}
|
|
|
|
{:error, reason} ->
|
|
IO.puts(" ✗ Error: #{inspect(reason)}")
|
|
{:error, product_def.slug, reason}
|
|
end
|
|
end)
|
|
|
|
# Cleanup if requested
|
|
if cleanup do
|
|
IO.puts("")
|
|
IO.puts("Cleaning up created products...")
|
|
|
|
results
|
|
|> Enum.filter(fn
|
|
{:ok, _, _, _} -> true
|
|
_ -> false
|
|
end)
|
|
|> Enum.each(fn {:ok, slug, product_id, _} ->
|
|
IO.puts(" Deleting #{slug}...")
|
|
Client.delete_product(shop_id, product_id)
|
|
end)
|
|
|
|
IO.puts("Cleanup complete.")
|
|
end
|
|
|
|
IO.puts("")
|
|
IO.puts("Mockup generation complete!")
|
|
IO.puts("Output directory: #{@output_dir}")
|
|
|
|
results
|
|
end
|
|
|
|
@doc """
|
|
Generate mockups for a single product.
|
|
"""
|
|
def generate_single(shop_id, product_def) do
|
|
config = blueprint_config()[product_def.product_type]
|
|
|
|
with {:ok, blueprint} <- find_blueprint(config.search_term),
|
|
blueprint_id = blueprint["id"],
|
|
_ = IO.puts(" Found blueprint: #{blueprint["title"]} (#{blueprint_id})"),
|
|
{:ok, provider} <- find_print_provider(blueprint_id, config[:preferred_provider_id]),
|
|
provider_id = provider["id"],
|
|
_ = IO.puts(" Using provider: #{provider["title"]} (#{provider_id})"),
|
|
{:ok, variants} <- get_variant_info(blueprint_id, provider_id),
|
|
_ = IO.puts(" Found #{length(variants)} variants"),
|
|
_ = IO.puts(" Uploading artwork..."),
|
|
{:ok, upload} <- upload_artwork(product_def.slug, product_def.artwork_url),
|
|
image_id = upload["id"],
|
|
image_width = upload["width"],
|
|
image_height = upload["height"],
|
|
_ = IO.puts(" Artwork uploaded (ID: #{image_id}, #{image_width}x#{image_height})"),
|
|
_ = IO.puts(" Creating product..."),
|
|
{:ok, product} <-
|
|
create_product(
|
|
shop_id,
|
|
product_def,
|
|
image_id,
|
|
image_width,
|
|
image_height,
|
|
blueprint_id,
|
|
provider_id,
|
|
variants
|
|
),
|
|
product_id = product["id"],
|
|
mockup_urls = extract_mockup_urls(product),
|
|
_ = IO.puts(" Product created (ID: #{product_id})"),
|
|
_ = IO.puts(" Downloading #{length(mockup_urls)} mockups..."),
|
|
download_results <- download_mockups(product_def.slug, mockup_urls) do
|
|
successful_downloads =
|
|
download_results
|
|
|> Enum.filter(&match?({:ok, _}, &1))
|
|
|> Enum.map(fn {:ok, path} -> path end)
|
|
|
|
{:ok, product_id, successful_downloads}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
List all available blueprints (for discovery).
|
|
"""
|
|
def list_blueprints do
|
|
case Client.get_blueprints() do
|
|
{:ok, blueprints} ->
|
|
blueprints
|
|
|> Enum.map(fn bp -> {bp["id"], bp["title"]} end)
|
|
|> Enum.sort_by(fn {_, title} -> title end)
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Search blueprints by keyword.
|
|
"""
|
|
def search_blueprints(keyword) do
|
|
case Client.get_blueprints() do
|
|
{:ok, blueprints} ->
|
|
blueprints
|
|
|> Enum.filter(fn bp ->
|
|
String.contains?(String.downcase(bp["title"] || ""), String.downcase(keyword))
|
|
end)
|
|
|> Enum.map(fn bp -> {bp["id"], bp["title"]} end)
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
end
|