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