docs/plans/5.4-6.0-product-media-system.md
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
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).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).Spree::Asset → Spree::Media in 6.0 — aligns model/table/prefix with API naming. Coincides with master removal cleanup.POST /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).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
| 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)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
spree_variant_media table + Spree::VariantMedia modelVariant#variant_media, Variant#associated_media associationsVariant#gallery_media to prefer associated_media (join table) over direct images, falling back to images when the join is empty[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'variant_ids array — no dedicated link/unlink routes:
PATCH /api/v3/admin/products/:product_id/media/:asset_id { variant_ids: ["variant_xyz", ...] }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 keysPhase 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_images → assign_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_mediavariants/edit.html.erb) keeps viewable_type: 'Spree::Variant' — variant-level direct uploads work unchangedPhase 4: Admin SPA UI
use-product-media hook)PATCH /products/:id/media/:asset_id { variant_ids: [...current, this_variant] }@spree/admin-sdk (pnpm build from root) so the SPA picks up the new endpointsPhase 5: Data migration rake (opt-in)
rake spree:migrate_master_images_to_product_mediaviewable_type: 'Spree::Product' when master has no line items, copy them when master has line itemsmedia_count counter caches and primary_media_idBy 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)
viewable_type: 'Spree::Variant', viewable.is_master: truePhase 2: Video support (can be deferred)
media_type='external_video' URL validation polishPhase 3: Rename Asset → Media
spree_assets → spree_mediaSpree::Asset → Spree::Mediamedia_xxx since 5.4 — no change neededSpree::Image subclassPhase 4: Cleanup
Product#variant_images and Product#variant_images_without_master (callers use Product#media / Product#gallery_media)viewable_id/viewable_type from Admin API responsesProduct#master_images aliasexpand=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 Variant — never master.images, never variant_images directlyviewable_type: 'Spree::Product' — do not pin to @product.master in any new admin codeviewable_type: 'Spree::Variant'. Prefer VariantMedia rows linking to product-level assets when sharing a blob across variantsmedia_type is never null — defaults to 'image', always validate inclusionSpree::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:
viewable association.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.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.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.
media_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