Back to Spree

Product Media System

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

5.4.26.5 KB
Original Source

Product Media System

Status: In Progress Target: Spree 5.4 (foundation) + Spree 6.0 (full flip) Depends on: None Author: Damian + Claude Last updated: 2026-03-16

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 deferred to 6.0 — not needed until data migration moves images from variants to products.
  8. Rename Spree::AssetSpree::Media in 6.0 — aligns model/table/prefix with API naming.

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 (legacy)
  #gallery_media → images                  ← 6.0: will use join table

Spree::VariantMedia (6.0)
  belongs_to :variant
  belongs_to :asset
  position (per-variant ordering)
  unique index on [variant_id, asset_id]

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

6.0 — TODO

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 over direct images
  • Admin API: media CRUD endpoints for products and variant media association

Phase 2: Admin UI

  • Product form uploads to Product (not master Variant)
  • Variant edit shows media picker (select from product gallery, drag-to-reorder)
  • Focal point UI on asset edit dialog

Phase 3: Data migration

  • Rake task spree:migrate_images_to_product_media
  • Move master variant images to product level
  • Move non-master variant images to product + create VariantMedia records
  • Deduplicate shared blobs
  • Recalculate counter caches and thumbnails

Phase 4: Video support (can be deferred)

  • Spree::Video or media_type='external_video' with URL validation
  • Admin UI: "Add video" button for YouTube/Vimeo URLs

Phase 5: 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 6: Cleanup

  • Remove Product#variant_images (use Product#media)
  • Remove viewable_id/type from Admin API
  • Update Duplicator and CSV presenter

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
  • Do not create VariantMedia records — the join table doesn't exist yet in 5.4
  • media_type is never null — defaults to 'image', always validate inclusion

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