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:
parent
61cb2b7a87
commit
1aceaf9444
@ -1,32 +1,23 @@
|
|||||||
defmodule Mix.Tasks.GenerateMockups do
|
defmodule Mix.Tasks.GenerateMockups do
|
||||||
@moduledoc """
|
@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
|
This task automates the creation of product mockups for the SimpleshopTheme
|
||||||
sample content. It downloads artwork from Unsplash, uploads it to Printify,
|
sample content. It downloads artwork from Unsplash, uploads it to the
|
||||||
creates products, and downloads the generated mockups.
|
print-on-demand provider, creates products, and downloads the generated mockups.
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- A Printify account with API access
|
|
||||||
- The PRINTIFY_API_TOKEN environment variable must be set
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
# Generate mockups (keeps products in Printify)
|
# Printify (default)
|
||||||
mix generate_mockups
|
mix generate_mockups
|
||||||
|
mix generate_mockups --cleanup
|
||||||
# Delete existing products first, then generate fresh ones
|
|
||||||
mix generate_mockups --replace
|
mix generate_mockups --replace
|
||||||
|
|
||||||
# Generate mockups and delete products afterwards
|
# Printful
|
||||||
mix generate_mockups --cleanup
|
mix generate_mockups --provider printful
|
||||||
|
mix generate_mockups --provider printful --mockups-only
|
||||||
# Search for available blueprints
|
mix generate_mockups --provider printful --products-only
|
||||||
mix generate_mockups --search "poster"
|
mix generate_mockups --provider printful --cleanup
|
||||||
|
|
||||||
# List all blueprints
|
|
||||||
mix generate_mockups --list-blueprints
|
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
@ -35,13 +26,13 @@ defmodule Mix.Tasks.GenerateMockups do
|
|||||||
|
|
||||||
use Mix.Task
|
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
|
@impl Mix.Task
|
||||||
def run(args) do
|
def run(args) do
|
||||||
# Start required applications
|
|
||||||
Mix.Task.run("app.start")
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
{opts, _, _} =
|
{opts, _, _} =
|
||||||
@ -51,21 +42,30 @@ defmodule Mix.Tasks.GenerateMockups do
|
|||||||
replace: :boolean,
|
replace: :boolean,
|
||||||
search: :string,
|
search: :string,
|
||||||
list_blueprints: :boolean,
|
list_blueprints: :boolean,
|
||||||
help: :boolean
|
help: :boolean,
|
||||||
|
provider: :string,
|
||||||
|
mockups_only: :boolean,
|
||||||
|
products_only: :boolean
|
||||||
],
|
],
|
||||||
aliases: [
|
aliases: [
|
||||||
c: :cleanup,
|
c: :cleanup,
|
||||||
r: :replace,
|
r: :replace,
|
||||||
s: :search,
|
s: :search,
|
||||||
l: :list_blueprints,
|
l: :list_blueprints,
|
||||||
h: :help
|
h: :help,
|
||||||
|
p: :provider
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
provider = Keyword.get(opts, :provider, "printify")
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
opts[:help] ->
|
opts[:help] ->
|
||||||
print_help()
|
print_help()
|
||||||
|
|
||||||
|
provider == "printful" ->
|
||||||
|
run_printful(opts)
|
||||||
|
|
||||||
opts[:list_blueprints] ->
|
opts[:list_blueprints] ->
|
||||||
list_blueprints()
|
list_blueprints()
|
||||||
|
|
||||||
@ -73,48 +73,51 @@ defmodule Mix.Tasks.GenerateMockups do
|
|||||||
search_blueprints(opts[:search])
|
search_blueprints(opts[:search])
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
generate_mockups(opts)
|
run_printify(opts)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp print_help do
|
defp print_help do
|
||||||
Mix.shell().info("""
|
Mix.shell().info("""
|
||||||
|
|
||||||
Printify Mockup Generator
|
Mockup Generator
|
||||||
=========================
|
================
|
||||||
|
|
||||||
Generates product mockups using the Printify API.
|
Generates product mockups using Printify or Printful APIs.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
mix generate_mockups [options]
|
mix generate_mockups [options]
|
||||||
|
|
||||||
Options:
|
Common options:
|
||||||
--replace, -r Delete ALL existing products from Printify before generating (with confirmation)
|
--provider, -p NAME Provider to use: "printify" (default) or "printful"
|
||||||
--cleanup, -c Delete created products from Printify after downloading mockups
|
--cleanup, -c Delete created products 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
|
--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:
|
Environment:
|
||||||
PRINTIFY_API_TOKEN Required. Your Printify API token.
|
PRINTIFY_API_TOKEN Required for Printify provider
|
||||||
|
PRINTFUL_API_TOKEN Required for Printful provider
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Generate all mockups
|
|
||||||
export PRINTIFY_API_TOKEN="your-token"
|
|
||||||
mix generate_mockups
|
mix generate_mockups
|
||||||
|
mix generate_mockups --provider printful
|
||||||
# Generate and cleanup
|
mix generate_mockups --provider printful --mockups-only
|
||||||
mix generate_mockups --cleanup
|
mix generate_mockups --provider printful --cleanup
|
||||||
|
|
||||||
# Find blueprint IDs
|
|
||||||
mix generate_mockups --search "poster"
|
|
||||||
""")
|
""")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp list_blueprints do
|
defp list_blueprints do
|
||||||
Mix.shell().info("Fetching blueprints from Printify...")
|
Mix.shell().info("Fetching blueprints from Printify...")
|
||||||
|
|
||||||
case MockupGenerator.list_blueprints() do
|
case PrintifyGenerator.list_blueprints() do
|
||||||
blueprints when is_list(blueprints) ->
|
blueprints when is_list(blueprints) ->
|
||||||
Mix.shell().info("\nAvailable Blueprints:\n")
|
Mix.shell().info("\nAvailable Blueprints:\n")
|
||||||
|
|
||||||
@ -133,7 +136,7 @@ defmodule Mix.Tasks.GenerateMockups do
|
|||||||
defp search_blueprints(term) do
|
defp search_blueprints(term) do
|
||||||
Mix.shell().info("Searching for blueprints matching '#{term}'...")
|
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) ->
|
results when is_list(results) ->
|
||||||
if results == [] do
|
if results == [] do
|
||||||
Mix.shell().info("No blueprints found matching '#{term}'")
|
Mix.shell().info("No blueprints found matching '#{term}'")
|
||||||
@ -153,7 +156,11 @@ defmodule Mix.Tasks.GenerateMockups do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_mockups(opts) do
|
# ===========================================================================
|
||||||
|
# Printify
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
defp run_printify(opts) do
|
||||||
cleanup = Keyword.get(opts, :cleanup, false)
|
cleanup = Keyword.get(opts, :cleanup, false)
|
||||||
replace = Keyword.get(opts, :replace, 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
|
case System.get_env("PRINTIFY_API_TOKEN") do
|
||||||
nil ->
|
nil ->
|
||||||
Mix.shell().error("""
|
Mix.shell().error("""
|
||||||
Error: PRINTIFY_API_TOKEN environment variable is not set.
|
Error: PRINTIFY_API_TOKEN environment variable is not set.
|
||||||
|
|
||||||
To get your API token:
|
Set it and retry:
|
||||||
1. Log in to Printify
|
|
||||||
2. Go to Settings > API tokens
|
|
||||||
3. Create a new token with required permissions
|
|
||||||
|
|
||||||
Then run:
|
|
||||||
export PRINTIFY_API_TOKEN="your-token"
|
export PRINTIFY_API_TOKEN="your-token"
|
||||||
mix generate_mockups
|
mix generate_mockups
|
||||||
""")
|
""")
|
||||||
|
|
||||||
_token ->
|
_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))
|
successful = Enum.count(results, &match?({:ok, _, _, _}, &1))
|
||||||
failed = Enum.count(results, &match?({:error, _, _}, &1))
|
failed = Enum.count(results, &match?({:error, _, _}, &1))
|
||||||
|
|
||||||
Mix.shell().info("""
|
Mix.shell().info("""
|
||||||
|
|
||||||
═══════════════════════════════════════════
|
Summary: #{successful} succeeded, #{failed} failed
|
||||||
Summary
|
|
||||||
═══════════════════════════════════════════
|
|
||||||
Successful: #{successful}
|
|
||||||
Failed: #{failed}
|
|
||||||
═══════════════════════════════════════════
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
if failed > 0 do
|
if failed > 0 do
|
||||||
@ -211,7 +206,7 @@ defmodule Mix.Tasks.GenerateMockups do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp purge_existing_products do
|
defp purge_existing_printify_products do
|
||||||
alias SimpleshopTheme.Clients.Printify, as: Client
|
alias SimpleshopTheme.Clients.Printify, as: Client
|
||||||
|
|
||||||
Mix.shell().info("Fetching existing products...")
|
Mix.shell().info("Fetching existing products...")
|
||||||
@ -226,11 +221,71 @@ defmodule Mix.Tasks.GenerateMockups do
|
|||||||
Mix.shell().info("Found #{count} existing products in Printify.")
|
Mix.shell().info("Found #{count} existing products in Printify.")
|
||||||
|
|
||||||
if Mix.shell().yes?("Delete all #{count} products before generating new ones?") do
|
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")
|
Mix.shell().info("Deleted #{deleted} products.\n")
|
||||||
else
|
else
|
||||||
Mix.shell().info("Skipping purge.\n")
|
Mix.shell().info("Skipping purge.\n")
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -172,6 +172,31 @@ defmodule SimpleshopTheme.Clients.Printful do
|
|||||||
get("/store/products/#{product_id}")
|
get("/store/products/#{product_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Create a sync product with variants and design files.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
create_sync_product(%{
|
||||||
|
sync_product: %{name: "My T-Shirt"},
|
||||||
|
sync_variants: [%{
|
||||||
|
variant_id: 4011,
|
||||||
|
retail_price: "29.99",
|
||||||
|
files: [%{url: "https://example.com/design.png", type: "default"}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
def create_sync_product(product_data) do
|
||||||
|
post("/store/products", product_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Delete a sync product and all its variants.
|
||||||
|
"""
|
||||||
|
def delete_sync_product(product_id) do
|
||||||
|
delete("/store/products/#{product_id}")
|
||||||
|
end
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Shipping (v2)
|
# Shipping (v2)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -242,6 +267,28 @@ defmodule SimpleshopTheme.Clients.Printful do
|
|||||||
get(path)
|
get(path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mockup generator (legacy, multi-angle)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Create a mockup generator task for a catalog product.
|
||||||
|
|
||||||
|
Returns `{:ok, %{"task_key" => "gt-...", "status" => "pending"}}`.
|
||||||
|
"""
|
||||||
|
def create_mockup_generator_task(catalog_product_id, body) do
|
||||||
|
post("/mockup-generator/create-task/#{catalog_product_id}", body)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Poll a mockup generator task by task key.
|
||||||
|
|
||||||
|
Returns `{:ok, %{"status" => "completed", "mockups" => [...]}}` when done.
|
||||||
|
"""
|
||||||
|
def get_mockup_generator_task(task_key) do
|
||||||
|
get("/mockup-generator/task?task_key=#{task_key}")
|
||||||
|
end
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Files (v2)
|
# Files (v2)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
422
lib/simpleshop_theme/mockups/printful_generator.ex
Normal file
422
lib/simpleshop_theme/mockups/printful_generator.ex
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
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
|
||||||
@ -352,9 +352,23 @@ defmodule SimpleshopTheme.Products do
|
|||||||
error -> error
|
error -> error
|
||||||
end
|
end
|
||||||
|
|
||||||
_ ->
|
nil ->
|
||||||
# Not found or belongs to different connection - insert new
|
# Not found at all - insert new
|
||||||
do_insert_product(attrs)
|
do_insert_product(attrs)
|
||||||
|
|
||||||
|
_different_connection ->
|
||||||
|
# Slug taken by a different provider connection - make it unique
|
||||||
|
unique_slug = make_unique_slug(slug)
|
||||||
|
do_insert_product(Map.put(attrs, :slug, unique_slug))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp make_unique_slug(base_slug, suffix \\ 2) do
|
||||||
|
candidate = "#{base_slug}-#{suffix}"
|
||||||
|
|
||||||
|
case Repo.get_by(Product, slug: candidate) do
|
||||||
|
nil -> candidate
|
||||||
|
_ -> make_unique_slug(base_slug, suffix + 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -421,6 +435,14 @@ defmodule SimpleshopTheme.Products do
|
|||||||
Repo.get(ProductImage, id)
|
Repo.get(ProductImage, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all images for a product, ordered by position.
|
||||||
|
"""
|
||||||
|
def list_product_images(product_id) do
|
||||||
|
from(i in ProductImage, where: i.product_id == ^product_id, order_by: i.position)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Links a product image to a Media.Image by setting its image_id.
|
Links a product image to a Media.Image by setting its image_id.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -314,6 +314,7 @@ defmodule SimpleshopTheme.Providers.Printful do
|
|||||||
blueprint_id: catalog_product_id,
|
blueprint_id: catalog_product_id,
|
||||||
print_provider_id: 0,
|
print_provider_id: 0,
|
||||||
thumbnail_url: sync_product["thumbnail_url"],
|
thumbnail_url: sync_product["thumbnail_url"],
|
||||||
|
artwork_url: extract_artwork_url(sync_variants),
|
||||||
options: build_option_types(sync_variants),
|
options: build_option_types(sync_variants),
|
||||||
raw: %{sync_product: sync_product}
|
raw: %{sync_product: sync_product}
|
||||||
}
|
}
|
||||||
@ -345,7 +346,7 @@ defmodule SimpleshopTheme.Providers.Printful do
|
|||||||
opts
|
opts
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract unique preview images from sync variants (one per unique colour)
|
# Extract unique preview images from sync variants (one per unique image URL)
|
||||||
defp extract_preview_images(sync_variants) do
|
defp extract_preview_images(sync_variants) do
|
||||||
sync_variants
|
sync_variants
|
||||||
|> Enum.flat_map(fn sv ->
|
|> Enum.flat_map(fn sv ->
|
||||||
@ -354,18 +355,28 @@ defmodule SimpleshopTheme.Providers.Printful do
|
|||||||
|> Enum.map(fn file ->
|
|> Enum.map(fn file ->
|
||||||
%{
|
%{
|
||||||
src: file["preview_url"] || file["thumbnail_url"],
|
src: file["preview_url"] || file["thumbnail_url"],
|
||||||
color: sv["color"]
|
color: sv["color"],
|
||||||
|
name: sv["name"]
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|> Enum.uniq_by(& &1.color)
|
|> Enum.uniq_by(& &1.src)
|
||||||
|> Enum.with_index()
|
|> Enum.with_index()
|
||||||
|> Enum.map(fn {img, index} ->
|
|> Enum.map(fn {img, index} ->
|
||||||
%{
|
alt = if img.color not in [nil, ""], do: img.color, else: img.name
|
||||||
src: img.src,
|
%{src: img.src, position: index, alt: alt}
|
||||||
position: index,
|
end)
|
||||||
alt: img.color
|
end
|
||||||
}
|
|
||||||
|
# Find the artwork (design file) URL from the first variant's "default" file
|
||||||
|
defp extract_artwork_url(sync_variants) do
|
||||||
|
sync_variants
|
||||||
|
|> Enum.find_value(fn sv ->
|
||||||
|
(sv["files"] || [])
|
||||||
|
|> Enum.find_value(fn
|
||||||
|
%{"type" => "default", "url" => url} when is_binary(url) -> url
|
||||||
|
_ -> nil
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -423,10 +434,10 @@ defmodule SimpleshopTheme.Providers.Printful do
|
|||||||
|
|
||||||
cond do
|
cond do
|
||||||
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
|
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
|
||||||
|
has_keyword?(name_lower, ~w[bag tote hat cap sleeve phone case]) -> "Accessories"
|
||||||
|
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion throw]) -> "Homewares"
|
||||||
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
|
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
|
||||||
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
|
|
||||||
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
||||||
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
|
|
||||||
true -> "Apparel"
|
true -> "Apparel"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
254
lib/simpleshop_theme/sync/mockup_enricher.ex
Normal file
254
lib/simpleshop_theme/sync/mockup_enricher.ex
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||||
|
@moduledoc """
|
||||||
|
Oban worker that enriches Printful products with extra mockup angle images.
|
||||||
|
|
||||||
|
After product sync, Printful products only have front-view preview images.
|
||||||
|
This worker uses the legacy mockup generator API to produce extra angles
|
||||||
|
(back, left, right, etc.) and appends them as additional product images.
|
||||||
|
|
||||||
|
Each product is processed as a separate job so failures don't block others.
|
||||||
|
The temporary S3 URLs from the mockup generator are downloaded via the
|
||||||
|
existing ImageDownloadWorker pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Oban.Worker, queue: :images, max_attempts: 5
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Clients.Printful, as: Client
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Products.ProviderConnection
|
||||||
|
alias SimpleshopTheme.Sync.ImageDownloadWorker
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@poll_interval_ms 3_000
|
||||||
|
@max_poll_attempts 20
|
||||||
|
|
||||||
|
# Mockup generator config per catalog product type:
|
||||||
|
# {placement, area_width, area_height}
|
||||||
|
# Apparel/accessories use "front", flat products use "default"
|
||||||
|
@product_configs %{
|
||||||
|
1 => {"default", 4500, 6750},
|
||||||
|
3 => {"default", 4800, 7200},
|
||||||
|
19 => {"default", 3150, 1350},
|
||||||
|
71 => {"front", 4500, 5100},
|
||||||
|
146 => {"front", 4500, 4500},
|
||||||
|
181 => {"default", 1092, 2286},
|
||||||
|
274 => {"default", 3300, 3300},
|
||||||
|
394 => {"default", 3900, 2925},
|
||||||
|
395 => {"default", 9000, 10800}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stagger jobs to avoid hammering Printful's rate limits
|
||||||
|
@job_stagger_seconds 45
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Enqueue mockup enrichment for a product.
|
||||||
|
|
||||||
|
Accepts an optional `delay_index` to stagger jobs (each index adds
|
||||||
|
#{@job_stagger_seconds}s of delay).
|
||||||
|
"""
|
||||||
|
def enqueue(conn_id, product_id, delay_index \\ 0) do
|
||||||
|
delay = delay_index * @job_stagger_seconds
|
||||||
|
scheduled_at = DateTime.add(DateTime.utc_now(), delay, :second)
|
||||||
|
|
||||||
|
%{provider_connection_id: conn_id, product_id: product_id}
|
||||||
|
|> new(scheduled_at: scheduled_at)
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{
|
||||||
|
args: %{"provider_connection_id" => conn_id, "product_id" => product_id}
|
||||||
|
}) do
|
||||||
|
with %ProviderConnection{} = conn <- Products.get_provider_connection(conn_id),
|
||||||
|
product when not is_nil(product) <- Products.get_product(product_id),
|
||||||
|
:ok <- set_credentials(conn) do
|
||||||
|
enrich_product(product)
|
||||||
|
else
|
||||||
|
nil -> {:cancel, :not_found}
|
||||||
|
{:error, _} = error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp enrich_product(product) do
|
||||||
|
provider_data = product.provider_data || %{}
|
||||||
|
|
||||||
|
catalog_product_id =
|
||||||
|
provider_data["catalog_product_id"] || provider_data[:catalog_product_id]
|
||||||
|
|
||||||
|
artwork_url = provider_data["artwork_url"] || provider_data[:artwork_url]
|
||||||
|
|
||||||
|
catalog_variant_ids =
|
||||||
|
provider_data["catalog_variant_ids"] || provider_data[:catalog_variant_ids] || []
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(catalog_product_id) ->
|
||||||
|
Logger.info("[MockupEnricher] No catalog_product_id for #{product.title}, skipping")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
is_nil(artwork_url) ->
|
||||||
|
Logger.info("[MockupEnricher] No artwork_url for #{product.title}, skipping")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
already_enriched?(product) ->
|
||||||
|
Logger.info("[MockupEnricher] Already enriched #{product.title}, skipping")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
true ->
|
||||||
|
# Pick one representative variant for mockup generation
|
||||||
|
variant_id = List.first(catalog_variant_ids)
|
||||||
|
|
||||||
|
case generate_and_append(product, catalog_product_id, variant_id, artwork_url) do
|
||||||
|
{:ok, count} ->
|
||||||
|
Logger.info("[MockupEnricher] Added #{count} extra angle(s) for #{product.title}")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, {429, _}} ->
|
||||||
|
Logger.info("[MockupEnricher] Rate limited for #{product.title}, snoozing 60s")
|
||||||
|
{:snooze, 60}
|
||||||
|
|
||||||
|
{:error, {400, _} = reason} ->
|
||||||
|
# Permanent API error (wrong placement, unsupported product, etc.)
|
||||||
|
Logger.info("[MockupEnricher] #{product.title} not supported: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("[MockupEnricher] Failed for #{product.title}: #{inspect(reason)}")
|
||||||
|
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_and_append(product, catalog_product_id, variant_id, artwork_url) do
|
||||||
|
{placement, area_width, area_height} =
|
||||||
|
Map.get(@product_configs, catalog_product_id, {"front", 4500, 5100})
|
||||||
|
|
||||||
|
body = %{
|
||||||
|
variant_ids: if(variant_id, do: [variant_id], else: []),
|
||||||
|
format: "jpg",
|
||||||
|
files: [
|
||||||
|
%{
|
||||||
|
placement: placement,
|
||||||
|
image_url: artwork_url,
|
||||||
|
position: %{
|
||||||
|
area_width: area_width,
|
||||||
|
area_height: area_height,
|
||||||
|
width: area_width,
|
||||||
|
height: area_height,
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, task_data} <- Client.create_mockup_generator_task(catalog_product_id, body),
|
||||||
|
task_key <- task_data["task_key"],
|
||||||
|
{:ok, result} <- poll_generator_task(task_key),
|
||||||
|
extra_images <- extract_extra_images(result) do
|
||||||
|
if extra_images == [] do
|
||||||
|
{:ok, 0}
|
||||||
|
else
|
||||||
|
append_images_to_product(product, extra_images)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp poll_generator_task(task_key), do: poll_generator_task(task_key, 0)
|
||||||
|
|
||||||
|
defp poll_generator_task(_task_key, attempt) when attempt >= @max_poll_attempts do
|
||||||
|
{:error, :timeout}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp poll_generator_task(task_key, attempt) do
|
||||||
|
Process.sleep(@poll_interval_ms)
|
||||||
|
|
||||||
|
case Client.get_mockup_generator_task(task_key) do
|
||||||
|
{:ok, %{"status" => "completed"} = result} ->
|
||||||
|
{:ok, result}
|
||||||
|
|
||||||
|
{:ok, %{"status" => "error", "error" => error}} ->
|
||||||
|
{:error, {:mockup_error, error}}
|
||||||
|
|
||||||
|
{:ok, %{"status" => _pending}} ->
|
||||||
|
poll_generator_task(task_key, attempt + 1)
|
||||||
|
|
||||||
|
{:error, {429, _}} ->
|
||||||
|
# Rate limited — back off and retry
|
||||||
|
Process.sleep(60_000)
|
||||||
|
poll_generator_task(task_key, attempt + 1)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Collect all extra angle URLs from the mockup generator response.
|
||||||
|
# The "extra" array contains alternate views (back, left, right, etc.)
|
||||||
|
defp extract_extra_images(result) do
|
||||||
|
(result["mockups"] || [])
|
||||||
|
|> Enum.flat_map(fn mockup ->
|
||||||
|
(mockup["extra"] || [])
|
||||||
|
|> Enum.map(fn extra ->
|
||||||
|
%{
|
||||||
|
src: extra["url"],
|
||||||
|
alt: extra["title"]
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> Enum.reject(&is_nil(&1.src))
|
||||||
|
|> Enum.uniq_by(& &1.src)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_images_to_product(product, extra_images) do
|
||||||
|
# Find the current max position so we append after existing images
|
||||||
|
existing_images = Products.list_product_images(product.id)
|
||||||
|
max_position = existing_images |> Enum.map(& &1.position) |> Enum.max(fn -> -1 end)
|
||||||
|
|
||||||
|
results =
|
||||||
|
extra_images
|
||||||
|
|> Enum.with_index(max_position + 1)
|
||||||
|
|> Enum.map(fn {img, position} ->
|
||||||
|
attrs = %{
|
||||||
|
product_id: product.id,
|
||||||
|
src: img.src,
|
||||||
|
alt: img.alt,
|
||||||
|
position: position
|
||||||
|
}
|
||||||
|
|
||||||
|
case Products.create_product_image(attrs) do
|
||||||
|
{:ok, product_image} ->
|
||||||
|
ImageDownloadWorker.enqueue(product_image.id)
|
||||||
|
{:ok, product_image}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("[MockupEnricher] Failed to create image: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
count = Enum.count(results, &match?({:ok, _}, &1))
|
||||||
|
{:ok, count}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if this product already has extra angle images from a prior enrichment
|
||||||
|
defp already_enriched?(product) do
|
||||||
|
images = Products.list_product_images(product.id)
|
||||||
|
Enum.any?(images, fn img -> img.alt in ["Front", "Back", "Left", "Right"] end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_credentials(conn) do
|
||||||
|
case ProviderConnection.get_api_key(conn) do
|
||||||
|
api_key when is_binary(api_key) ->
|
||||||
|
Process.put(:printful_api_key, api_key)
|
||||||
|
store_id = get_in(conn.config, ["store_id"])
|
||||||
|
if store_id, do: Process.put(:printful_store_id, store_id)
|
||||||
|
:ok
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
{:error, :no_api_key}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -21,6 +21,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
alias SimpleshopTheme.Products.ProviderConnection
|
alias SimpleshopTheme.Products.ProviderConnection
|
||||||
alias SimpleshopTheme.Providers.Provider
|
alias SimpleshopTheme.Providers.Provider
|
||||||
alias SimpleshopTheme.Sync.ImageDownloadWorker
|
alias SimpleshopTheme.Sync.ImageDownloadWorker
|
||||||
|
alias SimpleshopTheme.Sync.MockupEnricher
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@ -96,6 +97,11 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
|
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Enqueue mockup enrichment for Printful products (extra angle images)
|
||||||
|
if conn.provider_type == "printful" do
|
||||||
|
enqueue_mockup_enrichment(conn, results)
|
||||||
|
end
|
||||||
|
|
||||||
# Sync shipping rates (non-fatal — logged and skipped on failure)
|
# Sync shipping rates (non-fatal — logged and skipped on failure)
|
||||||
sync_shipping_rates(conn, provider, products)
|
sync_shipping_rates(conn, provider, products)
|
||||||
|
|
||||||
@ -228,4 +234,27 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
"Shipping rate sync crashed for #{conn.provider_type}: #{Exception.message(e)}"
|
"Shipping rate sync crashed for #{conn.provider_type}: #{Exception.message(e)}"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Enqueue MockupEnricher jobs for created/updated Printful products
|
||||||
|
defp enqueue_mockup_enrichment(conn, results) do
|
||||||
|
products_to_enrich =
|
||||||
|
results
|
||||||
|
|> Enum.filter(&match?({:ok, _, status} when status in [:created, :updated], &1))
|
||||||
|
|> Enum.map(fn {:ok, product, _status} -> product end)
|
||||||
|
|
||||||
|
if products_to_enrich != [] do
|
||||||
|
Logger.info(
|
||||||
|
"Enqueueing mockup enrichment for #{length(products_to_enrich)} Printful product(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
products_to_enrich
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.each(fn {product, index} ->
|
||||||
|
MockupEnricher.enqueue(conn.id, product.id, index)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
Logger.error("Mockup enrichment enqueue failed: #{Exception.message(e)}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user