docs/plans/5.4-6.0-product-media-system.md
Status: In Progress Target: Spree 5.4 (foundation) + Spree 6.0 (full flip) Depends on: None Author: Damian + Claude Last updated: 2026-03-16
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 images → media with a media_type discriminator.
images → media throughout the Store API. Clean break — no deprecated alias.viewable_id/viewable_type from Store API. Admin API keeps them.product_id to the media serializer.Spree::Asset class — Spree::Image logic merged into Spree::Asset. Spree::Image kept as thin subclass (sets media_type: 'image'). No STI.media_type defaults to 'image' — always present, never null.expand=media on products/variants (industry standard).Spree::Asset → Spree::Media in 6.0 — aligns model/table/prefix with API naming.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]
| Field | Type | Notes |
|---|---|---|
id | string | Prefixed ID (media_xxx) |
product_id | string | null | Product prefixed ID |
media_type | string | 'image', 'video', 'external_video' |
position | number | Sort order |
alt | string | null | Alt text |
focal_point_x | number | null | 0.0–1.0 |
focal_point_y | number | null | 0.0–1.0 |
external_video_url | string | null | YouTube/Vimeo URL |
original_url | string | null | Full-size image URL |
mini_url ... xlarge_url | string | null | Named variant URLs |
Removed from Store API: viewable_id, viewable_type
Admin API extends Store with: viewable_id, viewable_type
| Change | Before | After |
|---|---|---|
| Expansion param | expand=images | expand=media |
| Response key | "images": [...] | "media": [...] |
viewable_id | Present | Removed (Store API) |
viewable_type | Present | Removed (Store API) |
| TS type | Image / StoreImage | Media / StoreMedia |
| Serializer | ImageSerializer | MediaSerializer (deleted, no alias) |
spree_assets: media_type, focal_point_x/y, external_video_urlmedia_type = 'image'Spree::Image into Spree::AssetMediaSerializer replaces ImageSerializer, expand=mediaAdmin::MediaSerializer extends Store (adds viewable_id/type)Phase 1: VariantMedia join table
spree_variant_media table + Spree::VariantMedia modelVariant#variant_media, Variant#associated_media associationsVariant#gallery_media to prefer associated_media over direct imagesPhase 2: Admin UI
Product (not master Variant)Phase 3: Data migration
spree:migrate_images_to_product_mediaPhase 4: Video support (can be deferred)
Spree::Video or media_type='external_video' with URL validationPhase 5: Rename Asset → Media
spree_assets → spree_mediaSpree::Asset → Spree::Mediamedia_xxx since 5.4 — no change neededSpree::Image subclassPhase 6: Cleanup
Product#variant_images (use Product#media)viewable_id/type from Admin APIexpand=media (not expand=images) in new Store API code and SDK examplesSpree::Asset for new code; Spree::Image is for backward compat onlygallery_media as the unified accessor on Product and VariantVariantMedia records — the join table doesn't exist yet in 5.4media_type is never null — defaults to 'image', always validate inclusionmedia_type value or a separate system?core/app/models/spree/asset.rb — single Asset class with all media logiccore/app/models/spree/image.rb — thin backward-compat subclassapi/app/serializers/spree/api/v3/media_serializer.rb — Store APIapi/app/serializers/spree/api/v3/admin/media_serializer.rb — Admin API