diff --git a/lib/mix/tasks/generate_mockups.ex b/lib/mix/tasks/generate_mockups.ex index 2443e97..f6424e8 100644 --- a/lib/mix/tasks/generate_mockups.ex +++ b/lib/mix/tasks/generate_mockups.ex @@ -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 diff --git a/lib/simpleshop_theme/clients/printful.ex b/lib/simpleshop_theme/clients/printful.ex index f8626c6..e9cedd4 100644 --- a/lib/simpleshop_theme/clients/printful.ex +++ b/lib/simpleshop_theme/clients/printful.ex @@ -172,6 +172,31 @@ defmodule SimpleshopTheme.Clients.Printful do get("/store/products/#{product_id}") 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) # ============================================================================= @@ -242,6 +267,28 @@ defmodule SimpleshopTheme.Clients.Printful do get(path) 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) # ============================================================================= diff --git a/lib/simpleshop_theme/mockups/printful_generator.ex b/lib/simpleshop_theme/mockups/printful_generator.ex new file mode 100644 index 0000000..8f52e0f --- /dev/null +++ b/lib/simpleshop_theme/mockups/printful_generator.ex @@ -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 diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex index 6d12288..34c9d04 100644 --- a/lib/simpleshop_theme/products.ex +++ b/lib/simpleshop_theme/products.ex @@ -352,9 +352,23 @@ defmodule SimpleshopTheme.Products do error -> error end - _ -> - # Not found or belongs to different connection - insert new + nil -> + # Not found at all - insert new 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 @@ -421,6 +435,14 @@ defmodule SimpleshopTheme.Products do Repo.get(ProductImage, id) 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 """ Links a product image to a Media.Image by setting its image_id. """ diff --git a/lib/simpleshop_theme/providers/printful.ex b/lib/simpleshop_theme/providers/printful.ex index 353fbf4..2060193 100644 --- a/lib/simpleshop_theme/providers/printful.ex +++ b/lib/simpleshop_theme/providers/printful.ex @@ -314,6 +314,7 @@ defmodule SimpleshopTheme.Providers.Printful do blueprint_id: catalog_product_id, print_provider_id: 0, thumbnail_url: sync_product["thumbnail_url"], + artwork_url: extract_artwork_url(sync_variants), options: build_option_types(sync_variants), raw: %{sync_product: sync_product} } @@ -345,7 +346,7 @@ defmodule SimpleshopTheme.Providers.Printful do opts 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 sync_variants |> Enum.flat_map(fn sv -> @@ -354,18 +355,28 @@ defmodule SimpleshopTheme.Providers.Printful do |> Enum.map(fn file -> %{ src: file["preview_url"] || file["thumbnail_url"], - color: sv["color"] + color: sv["color"], + name: sv["name"] } end) end) - |> Enum.uniq_by(& &1.color) + |> Enum.uniq_by(& &1.src) |> Enum.with_index() |> Enum.map(fn {img, index} -> - %{ - src: img.src, - position: index, - alt: img.color - } + alt = if img.color not in [nil, ""], do: img.color, else: img.name + %{src: img.src, position: index, alt: alt} + end) + 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 @@ -423,10 +434,10 @@ defmodule SimpleshopTheme.Providers.Printful do cond do 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[mug cup blanket pillow cushion]) -> "Homewares" has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery" - has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories" true -> "Apparel" end end diff --git a/lib/simpleshop_theme/sync/mockup_enricher.ex b/lib/simpleshop_theme/sync/mockup_enricher.ex new file mode 100644 index 0000000..958034a --- /dev/null +++ b/lib/simpleshop_theme/sync/mockup_enricher.ex @@ -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 diff --git a/lib/simpleshop_theme/sync/product_sync_worker.ex b/lib/simpleshop_theme/sync/product_sync_worker.ex index fddc394..8fa0662 100644 --- a/lib/simpleshop_theme/sync/product_sync_worker.ex +++ b/lib/simpleshop_theme/sync/product_sync_worker.ex @@ -21,6 +21,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do alias SimpleshopTheme.Products.ProviderConnection alias SimpleshopTheme.Providers.Provider alias SimpleshopTheme.Sync.ImageDownloadWorker + alias SimpleshopTheme.Sync.MockupEnricher require Logger @@ -96,6 +97,11 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do "#{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(conn, provider, products) @@ -228,4 +234,27 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do "Shipping rate sync crashed for #{conn.provider_type}: #{Exception.message(e)}" ) 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