feat: add product image download pipeline for PageSpeed 100%

Downloads Printify CDN images via ImageDownloadWorker, processes
through Media pipeline (WebP conversion, AVIF/WebP variant generation),
and links to ProductImage via new image_id FK.

- Add image_id to product_images table
- ImageDownloadWorker downloads and processes external images
- sync_product_images preserves image_id when URL unchanged
- PreviewData uses local images for responsive <picture> elements
- VariantCache enqueues pending downloads on startup
- mix simpleshop.download_images backfill task

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-01 00:26:19 +00:00
parent c818d0399c
commit 1b49b470f2
12 changed files with 381 additions and 33 deletions

View File

@ -43,6 +43,11 @@
- Disk cache for variants (regenerable from DB)
- `mix optimize_images` task for mockups
- On-demand JPEG fallback generation
- Product image download pipeline (downloads Printify CDN images, processes through Media pipeline)
- ImageDownloadWorker downloads and links images to ProductImage
- PreviewData uses local images for responsive `<picture>` elements
- Startup recovery re-enqueues pending downloads
- `mix simpleshop.download_images` backfill task
See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for implementation details
@ -64,6 +69,12 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im
- [ ] Add variant selector component (~2hr)
#### Recently Completed
- [x] Product image download pipeline
- Downloads Printify CDN images via ImageDownloadWorker
- Processes through Media pipeline (WebP conversion, AVIF/WebP variants)
- PreviewData uses local images for responsive `<picture>` elements
- sync_product_images preserves image_id when URL unchanged
- Startup recovery and `mix simpleshop.download_images` backfill
- [x] Wire shop LiveViews to Products context
- PreviewData now uses real products when available
- Fixed Printify image sync (position was string, not integer)

View File

@ -0,0 +1,41 @@
defmodule Mix.Tasks.Simpleshop.DownloadImages do
@moduledoc """
Enqueues download jobs for product images that haven't been processed yet.
## Usage
mix simpleshop.download_images
This task finds all product images with a `src` URL but no linked `image_id`
and enqueues them for download via the ImageDownloadWorker.
Use this to backfill existing products after enabling the image download pipeline.
"""
use Mix.Task
@shortdoc "Enqueue product image downloads"
@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")
alias SimpleshopTheme.Products
alias SimpleshopTheme.Sync.ImageDownloadWorker
pending = Products.list_pending_downloads(limit: 10_000)
count = length(pending)
if count == 0 do
Mix.shell().info("No pending product images to download.")
else
Mix.shell().info("Enqueueing #{count} product images for download...")
Enum.each(pending, fn image ->
ImageDownloadWorker.enqueue(image.id)
end)
Mix.shell().info("Done. Images will be processed by Oban workers.")
end
end
end

View File

@ -15,6 +15,8 @@ defmodule SimpleshopTheme.Images.VariantCache do
alias SimpleshopTheme.Repo
alias SimpleshopTheme.Media.Image, as: ImageSchema
alias SimpleshopTheme.Images.{Optimizer, OptimizeWorker}
alias SimpleshopTheme.Products
alias SimpleshopTheme.Sync.ImageDownloadWorker
import Ecto.Query
@mockup_dir "priv/static/mockups"
@ -35,6 +37,7 @@ defmodule SimpleshopTheme.Images.VariantCache do
ensure_database_image_variants()
ensure_mockup_variants()
ensure_product_image_downloads()
end
defp ensure_database_image_variants do
@ -100,4 +103,15 @@ defmodule SimpleshopTheme.Images.VariantCache do
dir = Path.dirname(source_path)
File.exists?(Path.join(dir, "#{basename}-800.webp"))
end
defp ensure_product_image_downloads do
pending = Products.list_pending_downloads(limit: 500)
if pending == [] do
Logger.info("[VariantCache] All product images downloaded")
else
Logger.info("[VariantCache] Enqueueing #{length(pending)} product images for download")
Enum.each(pending, fn image -> ImageDownloadWorker.enqueue(image.id) end)
end
end
end

View File

@ -294,6 +294,40 @@ defmodule SimpleshopTheme.Products do
|> Repo.insert()
end
@doc """
Gets a single product image by ID.
"""
def get_product_image(id) do
Repo.get(ProductImage, id)
end
@doc """
Links a product image to a Media.Image by setting its image_id.
"""
def link_product_image(%ProductImage{} = product_image, image_id) do
product_image
|> ProductImage.changeset(%{image_id: image_id})
|> Repo.update()
end
@doc """
Lists product images that need downloading (have src but no image_id).
## Options
* `:limit` - maximum number of images to return (default: 100)
"""
def list_pending_downloads(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
from(i in ProductImage,
where: not is_nil(i.src) and is_nil(i.image_id),
order_by: [asc: i.inserted_at],
limit: ^limit
)
|> Repo.all()
end
@doc """
Deletes all images for a product.
"""
@ -305,20 +339,60 @@ defmodule SimpleshopTheme.Products do
@doc """
Syncs product images from a list of image data.
Deletes existing images and inserts new ones.
Preserves existing image_id references when the URL hasn't changed.
Returns a list of {:ok, image} tuples for images that need downloading.
"""
def sync_product_images(%Product{id: product_id} = product, images) when is_list(images) do
delete_product_images(product)
def sync_product_images(%Product{id: product_id}, images) when is_list(images) do
# Build map of existing images by position
existing_by_position =
from(i in ProductImage, where: i.product_id == ^product_id)
|> Repo.all()
|> Map.new(&{&1.position, &1})
incoming_positions =
images
|> Enum.with_index()
|> Enum.map(fn {image_data, index} -> image_data[:position] || index end)
|> MapSet.new()
# Delete orphaned positions (images no longer in the list)
orphaned_ids =
existing_by_position
|> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end)
|> Enum.map(fn {_position, img} -> img.id end)
if orphaned_ids != [] do
from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all()
end
# Upsert incoming images
images
|> Enum.with_index()
|> Enum.map(fn {image_data, index} ->
position = image_data[:position] || index
src = image_data[:src]
existing = Map.get(existing_by_position, position)
cond do
# Same URL at position - keep existing (preserve image_id)
existing && existing.src == src ->
{:ok, existing}
# Different URL at position - update src, clear image_id (triggers re-download)
existing ->
existing
|> ProductImage.changeset(%{src: src, alt: image_data[:alt], image_id: nil})
|> Repo.update()
# New position - create new
true ->
attrs =
image_data
|> Map.put(:product_id, product_id)
|> Map.put_new(:position, index)
|> Map.put(:position, position)
create_product_image(attrs)
end
end)
end

View File

@ -15,8 +15,10 @@ defmodule SimpleshopTheme.Products.ProductImage do
field :src, :string
field :position, :integer, default: 0
field :alt, :string
field :image_id, :binary_id
belongs_to :product, SimpleshopTheme.Products.Product
belongs_to :image, SimpleshopTheme.Media.Image, define_field: false
timestamps(type: :utc_datetime)
end
@ -26,8 +28,9 @@ defmodule SimpleshopTheme.Products.ProductImage do
"""
def changeset(product_image, attrs) do
product_image
|> cast(attrs, [:product_id, :src, :position, :alt])
|> cast(attrs, [:product_id, :src, :position, :alt, :image_id])
|> validate_required([:product_id, :src])
|> foreign_key_constraint(:product_id)
|> foreign_key_constraint(:image_id)
end
end

View File

@ -0,0 +1,139 @@
defmodule SimpleshopTheme.Sync.ImageDownloadWorker do
@moduledoc """
Oban worker for downloading product images from external URLs.
Downloads images from Printify CDN, processes through the Media pipeline
(WebP conversion, AVIF/WebP variant generation), and links to ProductImage.
## Usage
# Enqueue a download for a product image
ImageDownloadWorker.enqueue(product_image_id)
## Job Args
* `product_image_id` - The ID of the ProductImage to download
"""
use Oban.Worker, queue: :images, max_attempts: 3
alias SimpleshopTheme.Products
alias SimpleshopTheme.Media
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"product_image_id" => product_image_id}}) do
case Products.get_product_image(product_image_id) do
nil ->
{:cancel, :product_image_not_found}
%{image_id: image_id} when not is_nil(image_id) ->
# Already has a linked image, skip
:ok
product_image ->
download_and_link(product_image)
end
end
@doc """
Enqueue an image download for a product image.
"""
def enqueue(product_image_id) do
%{product_image_id: product_image_id}
|> new()
|> Oban.insert()
end
defp download_and_link(product_image) do
case download_image(product_image.src) do
{:ok, data, content_type} ->
upload_and_link(product_image, data, content_type)
{:error, reason} ->
Logger.warning(
"[ImageDownloadWorker] Failed to download #{product_image.src}: #{inspect(reason)}"
)
{:error, reason}
end
end
defp download_image(url) do
case Req.get(url, receive_timeout: 30_000) do
{:ok, %Req.Response{status: 200, body: body, headers: headers}} ->
content_type = get_content_type(headers)
{:ok, body, content_type}
{:ok, %Req.Response{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, reason}
end
end
defp get_content_type(headers) do
headers
|> Enum.find(fn {k, _v} -> String.downcase(k) == "content-type" end)
|> case do
{_, value} when is_binary(value) -> value |> String.split(";") |> hd() |> String.trim()
{_, [value | _]} -> value |> String.split(";") |> hd() |> String.trim()
_ -> "image/jpeg"
end
end
defp upload_and_link(product_image, data, content_type) do
filename = extract_filename(product_image.src, content_type)
attrs = %{
image_type: "product",
filename: filename,
content_type: content_type,
file_size: byte_size(data),
data: data
}
case Media.upload_image(attrs) do
{:ok, image} ->
case Products.link_product_image(product_image, image.id) do
{:ok, _} ->
Logger.info(
"[ImageDownloadWorker] Downloaded and linked image for #{product_image.id}"
)
:ok
{:error, reason} ->
Logger.error("[ImageDownloadWorker] Failed to link image: #{inspect(reason)}")
{:error, reason}
end
{:error, reason} ->
Logger.error("[ImageDownloadWorker] Failed to upload image: #{inspect(reason)}")
{:error, reason}
end
end
defp extract_filename(url, content_type) do
# Extract filename from URL path, fall back to generated name
uri = URI.parse(url)
path_parts = String.split(uri.path || "", "/")
basename = List.last(path_parts) || "image"
# Ensure it has an extension
if Path.extname(basename) == "" do
ext = extension_for_content_type(content_type)
"#{basename}#{ext}"
else
basename
end
end
defp extension_for_content_type("image/jpeg"), do: ".jpg"
defp extension_for_content_type("image/png"), do: ".png"
defp extension_for_content_type("image/webp"), do: ".webp"
defp extension_for_content_type("image/gif"), do: ".gif"
defp extension_for_content_type(_), do: ".jpg"
end

View File

@ -20,6 +20,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Providers.Provider
alias SimpleshopTheme.Sync.ImageDownloadWorker
require Logger
@ -157,7 +158,13 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
}
end)
Products.sync_product_images(product, images)
image_results = Products.sync_product_images(product, images)
# Enqueue downloads for images without image_id
Enum.each(image_results, fn
{:ok, %{image_id: nil, id: id}} -> ImageDownloadWorker.enqueue(id)
_ -> :ok
end)
# Sync variants
variants =

View File

@ -185,7 +185,11 @@ defmodule SimpleshopTheme.Theme.PreviewData do
end
defp get_real_products do
Products.list_products(visible: true, status: "active", preload: [:images, :variants])
Products.list_products(
visible: true,
status: "active",
preload: [images: :image, variants: []]
)
|> Enum.map(&product_to_map/1)
end
@ -228,16 +232,22 @@ defmodule SimpleshopTheme.Theme.PreviewData do
in_stock = Enum.any?(available_variants)
on_sale = Enum.any?(product.variants, &SimpleshopTheme.Products.ProductVariant.on_sale?/1)
# Use local image if available, fall back to CDN URL
{image_url, image_id, source_width} = image_attrs(first_image)
{hover_image_url, hover_image_id, hover_source_width} = image_attrs(second_image)
%{
id: product.slug,
name: product.title,
description: product.description,
price: if(cheapest_variant, do: cheapest_variant.price, else: 0),
compare_at_price: if(cheapest_variant, do: cheapest_variant.compare_at_price, else: nil),
image_url: if(first_image, do: first_image.src, else: nil),
hover_image_url: if(second_image, do: second_image.src, else: nil),
source_width: nil,
hover_source_width: nil,
image_url: image_url,
image_id: image_id,
hover_image_url: hover_image_url,
hover_image_id: hover_image_id,
source_width: source_width,
hover_source_width: hover_source_width,
category: product.category,
slug: product.slug,
in_stock: in_stock,
@ -246,6 +256,20 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}
end
# Extract image attributes, preferring local Media.Image when available
defp image_attrs(nil), do: {nil, nil, nil}
defp image_attrs(%{image_id: image_id, image: %{source_width: source_width}})
when not is_nil(image_id) do
# Local image available - use image_id for responsive <picture> element
{nil, image_id, source_width}
end
defp image_attrs(%{src: src}) do
# Fall back to CDN URL
{src, nil, nil}
end
# Default source width for mockup variants (max generated size)
@mockup_source_width 1200

View File

@ -19,16 +19,24 @@
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<.breadcrumb
items={[
%{label: "Home", page: "home", href: "/"},
items={
[
%{label: "Home", page: "home", href: "/"}
] ++
if @product.category do
[
%{
label: @product.category,
page: "collection",
href:
"/collections/#{@product.category |> String.downcase() |> String.replace(" ", "-")}"
},
%{label: @product.name, current: true}
]}
}
]
else
[]
end ++
[%{label: @product.name, current: true}]
}
mode={@mode}
/>

View File

@ -1404,7 +1404,8 @@ defmodule SimpleshopThemeWeb.ShopComponents do
src =
if image_id do
"/images/#{image_id}/variant"
# Trailing slash so build_srcset produces /images/{id}/variant/800.webp
"/images/#{image_id}/variant/"
else
image_url
end
@ -4012,10 +4013,15 @@ defmodule SimpleshopThemeWeb.ShopComponents do
available = Optimizer.applicable_widths(assigns.source_width)
default_width = Enum.max(available)
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(assigns.src, "/"), do: "", else: "-"
assigns =
assigns
|> assign(:available_widths, available)
|> assign(:default_width, default_width)
|> assign(:separator, separator)
~H"""
<picture>
@ -4030,7 +4036,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
sizes={@sizes}
/>
<img
src={"#{@src}-#{@default_width}.jpg"}
src={"#{@src}#{@separator}#{@default_width}.jpg"}
srcset={build_srcset(@src, @available_widths, "jpg")}
sizes={@sizes}
alt={@alt}
@ -4046,9 +4052,13 @@ defmodule SimpleshopThemeWeb.ShopComponents do
end
defp build_srcset(base, widths, format) do
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(base, "/"), do: "", else: "-"
widths
|> Enum.sort()
|> Enum.map(&"#{base}-#{&1}.#{format} #{&1}w")
|> Enum.map(&"#{base}#{separator}#{&1}.#{format} #{&1}w")
|> Enum.join(", ")
end
end

View File

@ -34,13 +34,11 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|> Enum.reject(fn p -> p.id == product.id end)
|> Enum.take(4)
# Build gallery images (filter out nils)
# Build gallery images from local image_id or external URL
gallery_images =
[
product.image_url,
product.hover_image_url,
product.image_url,
product.hover_image_url
image_src(product[:image_id], product[:image_url]),
image_src(product[:hover_image_id], product[:hover_image_url])
]
|> Enum.reject(&is_nil/1)
@ -70,6 +68,14 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
List.first(products)
end
# Build image source URL - prefer local image_id, fall back to external URL
defp image_src(image_id, _url) when is_binary(image_id) do
"/images/#{image_id}/variant/1200.webp"
end
defp image_src(_, url) when is_binary(url), do: url
defp image_src(_, _), do: nil
@impl true
def render(assigns) do
~H"""

View File

@ -0,0 +1,11 @@
defmodule SimpleshopTheme.Repo.Migrations.AddImageIdToProductImages do
use Ecto.Migration
def change do
alter table(:product_images) do
add :image_id, references(:images, type: :binary_id, on_delete: :nilify_all)
end
create index(:product_images, [:image_id])
end
end