Back to Spree

Product Media System

docs/plans/5.4-6.0-product-media-system.md

5.5.014.3 KB
Original Source

Product Media System

Status: 5.5 data model shipped (PR #14000 — spree_variant_media, media_type, focal_point_x/y, external_video_url, column renames); admin UIs in progress; 6.0 cleanup pending Target: Spree 5.4 (foundation) + Spree 5.5 (data model + admin UIs ✓ data model) + Spree 6.0 (cleanup with master removal) Depends on: None Author: Damian + Claude Last updated: 2026-05-20

Summary

Spree's image system ties each Spree::Image 1:1 to a Spree::Variant. Sharing an image across variants requires re-uploading it, duplicating Active Storage blobs. There is no product-level media gallery, no video support, and no focal point for smart cropping.

This plan moves to a product-level media gallery (the industry standard) where variants reference product media via a join table. Blobs are never duplicated. The Store API renames imagesmedia with a media_type discriminator.

Key Decisions (do not deviate without discussion)

  1. Rename imagesmedia throughout the Store API. Clean break — no deprecated alias.
  2. Drop viewable_id/viewable_type from Store API. Admin API keeps them.
  3. Add product_id to the media serializer.
  4. Single Spree::Asset classSpree::Image logic merged into Spree::Asset. Spree::Image kept as thin subclass (sets media_type: 'image'). No STI.
  5. media_type defaults to 'image' — always present, never null.
  6. No dedicated Store media endpoint — media is served via expand=media on products/variants (industry standard).
  7. VariantMedia join table lands in 5.5 — enables variants to reference product-level assets without duplicating blobs. Direct variant images association stays for backward compatibility. The join is internal plumbing, not an API surface: callers manage variant assignment via PATCH /products/:id/media/:asset_id { variant_ids: [...] } (the Spree::Asset#variant_ids= setter resolves prefixed IDs and enforces same-product). Named VariantMedia (table spree_variant_media) — matches the industry convention (Shopify, Vendure, BigCommerce all expose a variant_media or productVariantMedia join concept).
  8. Rename Spree::AssetSpree::Media in 6.0 — aligns model/table/prefix with API naming. Coincides with master removal cleanup.
  9. Product is the default upload target in 5.5POST /api/v3/admin/products/:id/media and the legacy admin product form both attach to Spree::Product directly. Master-pinned images keep displaying via gallery_media until merchants opt into the migration rake (or 6.0 sweeps them up).

Design Details

Data Model

Spree::Asset (single class, replaces Spree::Image)
  viewable_type: 'Spree::Product' (new) or 'Spree::Variant' (legacy)
  media_type:    'image' | 'video' | 'external_video' (default: 'image')
  focal_point_x, focal_point_y (decimal, nullable)
  external_video_url (string, nullable)

Spree::Image < Spree::Asset  (thin backward-compat subclass)

Spree::Product
  has_many :media, as: :viewable           ← product-level gallery
  has_many :variant_images, through: ...   ← kept for backward compat
  #gallery_media → media.any? ? media : variant_images

Spree::Variant
  has_many :images, as: :viewable          ← kept (direct variant uploads)
  has_many :variant_media                  ← 5.5: links to product-level Assets
  has_many :associated_media, through: :variant_media, source: :asset
  #gallery_media → associated_media (if any) else images

Spree::VariantMedia (5.5, table: spree_variant_media)
  belongs_to :variant
  belongs_to :asset (FK: media_id, points at spree_assets in 5.5 / spree_media in 6.0)
  unique index on [variant_id, media_id]
  no position column — gallery order comes from the asset's product-level position

Store API (5.4)

FieldTypeNotes
idstringPrefixed ID (media_xxx)
product_idstring | nullProduct prefixed ID
media_typestring'image', 'video', 'external_video'
positionnumberSort order
altstring | nullAlt text
focal_point_xnumber | null0.0–1.0
focal_point_ynumber | null0.0–1.0
external_video_urlstring | nullYouTube/Vimeo URL
original_urlstring | nullFull-size image URL
mini_url ... xlarge_urlstring | nullNamed variant URLs

Removed from Store API: viewable_id, viewable_type Admin API extends Store with: viewable_id, viewable_type

Breaking Changes (5.4)

ChangeBeforeAfter
Expansion paramexpand=imagesexpand=media
Response key"images": [...]"media": [...]
viewable_idPresentRemoved (Store API)
viewable_typePresentRemoved (Store API)
TS typeImage / StoreImageMedia / StoreMedia
SerializerImageSerializerMediaSerializer (deleted, no alias)

Migration Path

5.4 (PR #13778) — DONE ✓

  • New columns on spree_assets: media_type, focal_point_x/y, external_video_url
  • Backfill existing records: media_type = 'image'
  • Merge Spree::Image into Spree::Asset
  • Store API: MediaSerializer replaces ImageSerializer, expand=media
  • Admin API: Admin::MediaSerializer extends Store (adds viewable_id/type)
  • SDK bumped to 0.11.0, Next bumped to 0.11.0
  • TypeScript types, Zod schemas, OpenAPI spec regenerated
  • Docs updated throughout

5.5 — TODO

5.5 delivers the data model, API, and both admin UIs for product-level media as a parallel path. Variant-level media keeps working unchanged. The data-migration rake ships as opt-in so existing merchants choose when to re-home master-pinned images. Master removal stays in 6.0.

Why before master removal? Asset#viewable is already polymorphic across Spree::Variant and Spree::Product. Both polymorphic targets coexist cleanly in 5.5 — old master-pinned assets keep displaying via gallery_media's fallback, new uploads go to Product. 6.0 then re-homes the remaining master-pinned assets as part of master removal Phase 2.

Phase 1: VariantMedia join table

  • Create spree_variant_media table + Spree::VariantMedia model
  • Variant#variant_media, Variant#associated_media associations
  • Update Variant#gallery_media to prefer associated_media (join table) over direct images, falling back to images when the join is empty
  • Unique index on [variant_id, media_id]. No per-variant ordering — gallery order is inherited from the asset's product-level position so merchants reorder once on the product gallery and every variant follows.

Phase 2: Admin API — Product is the default parent

  • POST /api/v3/admin/products/:product_id/media defaults to viewable_type: 'Spree::Product' (was master variant). Existing ?variant_id=... path keeps creating viewable_type: 'Spree::Variant'
  • New endpoints for variant↔product-asset linking:
    • Variant assignment travels on the existing media endpoint as a variant_ids array — no dedicated link/unlink routes:
      • PATCH /api/v3/admin/products/:product_id/media/:asset_id { variant_ids: ["variant_xyz", ...] }
      • The Spree::Asset#variant_ids= setter resolves prefixed IDs to internal IDs scoped to the asset's product (anything else is silently dropped) and uses Rails' has_many :through setter under the hood. Empty array clears all links; omitting the field leaves them untouched.
  • Asset#should_touch_product_variants? gains a Spree::Product branch so reordering product-level media still busts variant cache keys
  • Per the admin-sdk-unreleased policy, this ships as a clean break — no deprecation alias for the master-default behavior

Phase 3: Legacy Rails admin — flip product upload to Product-typed

  • _form.html.erb: viewable: @product, viewable_type: 'Spree::Product' (was @product.master, 'Spree::Variant')
  • products_controller.rb: rename assign_master_imagesassign_session_uploaded_assets, retarget to viewable_id: @product.id, viewable_type: 'Spree::Product'. Session bucket key changes to 'Spree::Product'
  • load_variants_data: variant thumbnail map falls back to @product.primary_media when a variant has no primary_media
  • Variant edit page (variants/edit.html.erb) keeps viewable_type: 'Spree::Variant' — variant-level direct uploads work unchanged
  • Skip building a "select from product gallery" picker in legacy admin — that UX is SPA-only

Phase 4: Admin SPA UI

  • Product detail page → Media tab: drag-and-drop upload to Product, drag-to-reorder, focal-point picker, alt text editor (consumes existing use-product-media hook)
  • Variant detail tab → Media section: "Select from product gallery" multi-select dialog + drag-to-reorder. Each picked asset gets PATCH /products/:id/media/:asset_id { variant_ids: [...current, this_variant] }
  • Rebuild @spree/admin-sdk (pnpm build from root) so the SPA picks up the new endpoints

Phase 5: Data migration rake (opt-in)

  • rake spree:migrate_master_images_to_product_media
  • For each Product: move master-pinned images to viewable_type: 'Spree::Product' when master has no line items, copy them when master has line items
  • Recalculate media_count counter caches and primary_media_id
  • Idempotent — re-runs are no-ops
  • Not run automatically on upgrade. Documented as opt-in for merchants who want a clean state. Becomes mandatory in 6.0 (run by master-removal Phase 2)

6.0 — TODO

By 6.0, master variant is being removed (see 6.0-remove-master-variant.md). Master-removal Phase 2 absorbs the residual image re-homing so no asset is orphaned when its master viewable is deleted.

Phase 1: Master image re-homing (owned by master removal)

  • Master-removal Phase 2 runs the 5.5 rake's logic for any merchant who didn't run it themselves
  • After this step, no asset has viewable_type: 'Spree::Variant', viewable.is_master: true

Phase 2: Video support (can be deferred)

  • media_type='external_video' URL validation polish
  • Admin UI: "Add video" button for YouTube/Vimeo URLs (5.5 SPA can ship this opportunistically)

Phase 3: Rename Asset → Media

  • Rename table spree_assetsspree_media
  • Rename model Spree::AssetSpree::Media
  • Prefix ID already media_xxx since 5.4 — no change needed
  • Remove Spree::Image subclass

Phase 4: Cleanup

  • Remove Product#variant_images and Product#variant_images_without_master (callers use Product#media / Product#gallery_media)
  • Remove viewable_id/viewable_type from Admin API responses
  • Update Duplicator and CSV presenter to operate on product-level media + VariantMedia rows
  • Drop Product#master_images alias

Constraints on Current Work

  • Always use expand=media (not expand=images) in new Store API code and SDK examples
  • Always use Spree::Asset for new code; Spree::Image is for backward compat only
  • Use gallery_media as the unified accessor on Product and Variant — never master.images, never variant_images directly
  • Default new product-level uploads to viewable_type: 'Spree::Product' — do not pin to @product.master in any new admin code
  • Variant-level direct uploads stay valid in 5.5 via viewable_type: 'Spree::Variant'. Prefer VariantMedia rows linking to product-level assets when sharing a blob across variants
  • media_type is never null — defaults to 'image', always validate inclusion
  • Do not run the master-image migration rake automatically on upgrade — it must stay opt-in in 5.5 (becomes mandatory in 6.0 via master removal)

Scope Boundary: Merchandising Media, Not a DAM

Spree::Asset (renamed Spree::Media in 6.0) is merchandising media for commerce entities — product photography, variant shots, swatches, eventually category/brand hero images. It is intentionally not a general-purpose Digital Asset Management system.

Storytelling, editorial content, blog posts, landing-page copy, lifestyle imagery, marketing collateral — none of this is Spree's job in a headless world. Headless storefronts pair Spree with a CMS (Payload, Sanity, Strapi, Hygraph, etc.) and that CMS owns its own asset library. Spree's role is to expose CDN URLs the storefront consumes; everything upstream of that URL is somebody else's surface.

Implications:

  • No "Files" page in admin. No central library, no cross-entity picker, no tag taxonomy, no "where is this used" reverse lookup, no bulk replace. Each commerce entity owns its own gallery via the polymorphic viewable association.
  • Active Storage already deduplicates blobs by checksum — uploading the same file twice creates two cheap Asset rows pointing at one S3 object. The economic case for a shared library is weak; the UX case in pure-commerce admin doesn't materialize until file counts get huge, at which point the merchant should be on a real DAM (Cloudinary MAM, Bynder, Brandfolder) feeding URLs into Spree.
  • Polymorphism stays narrow. viewable_type extends to commerce entities only — Product, Variant, eventually Taxon if a real customer ask lands. Adding viewable_type: 'Spree::Post' or 'Spree::Page' would imply Spree is a CMS; we don't go there.
  • Storage abstraction is the escape hatch. Merchants needing a real DAM configure a custom storage service (or a Spree.cdn_host) that points at their managed asset platform. The platform owns curation; Spree owns the URL contract.

If a future request asks for cross-entity sharing inside Spree (e.g., "this hero shot belongs to both a Product and the Taxon it's on"), revisit this boundary deliberately — don't drift into it. The default answer remains: no shared library, scope to commerce entities, defer broader media management to upstream tools.

Open Questions

  • Should we support hosted video uploads (not just external URLs)?
  • Should focal point have a visual picker UI or just numeric fields?
  • Should 3D model support (GLB/USDZ) be a media_type value or a separate system?

References

  • PR #13778: Product media system: core data model + Store API
  • Key files:
    • core/app/models/spree/asset.rb — single Asset class with all media logic
    • core/app/models/spree/image.rb — thin backward-compat subclass
    • api/app/serializers/spree/api/v3/media_serializer.rb — Store API
    • api/app/serializers/spree/api/v3/admin/media_serializer.rb — Admin API