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