Choosing the Right Image: A Practical Guide to PromoStandards Media Content
A client asked us a question last week that sounds like it should have a one-line answer: for this color variant, which image do I show?
It doesn’t have a one-line answer. Media Content is one of the PromoStandards services where suppliers, all of them technically compliant, model the same thing in genuinely different ways. If you write your image picker against one supplier’s data, it will break on the next one. Not with an error, which would at least be honest, but by silently showing a navy shirt on the red variant.
This is the logic we actually run in production, why each piece of it exists, and where the code lives.
The root of the problem: what is an image attached to?
A MediaContent record carries both a productId and an optional partId, plus a color field. Which of those a supplier populates is where the divergence starts.
Some suppliers key images by color. SanMar and S&S Activewear send many images per color, and a garment typically has a part for every size. If they attached media to every partId, they would have to repeat the same six photos for XS, S, M, L, XL, and 2XL, which is the same image set six times over for no gain. So they lean on the color field instead.
Some suppliers key images by part. HIT gives you images tied to a partId and leaves color blank. Color matching against their data returns nothing, because there is nothing to match against.
And some only attach images to the product, with partId null entirely. These are your group shots and collages: the whole product family in one frame, not any particular variant.
None of this is a violation of the standard. The standard permits all three. Your code has to survive all three.
The fallback chain
Because no single key works everywhere, you cascade from most specific to least:
partId. The most precise match available. If a supplier tells you this exact image belongs to this exact part, believe them.- The color field. If no image carries that part, match on color instead.
- The other color field. A part exposes both
colorNameandstandardColorName. Suppliers are inconsistent about which one lines up with thecoloron the media record, so if your primary choice misses, try the other one.
In our Shopify sync app, that cascade is literally this:
base = get_primary_base_for_part(part_id) # 1. partId
if not base:
color = product.get_variant_color(variant, self.color_field)
base = get_primary_base_for_color(color) # 2. colorName
if not base:
color = product.get_variant_color(variant, self.fallback_color_field)
base = get_primary_base_for_color(color) # 3. standardColorNameWe default to colorName with standardColorName as the fallback, but it’s a per-supplier setting, because for some the standardized name is the one that matches.
Two details that took us real production incidents to learn:
Normalize before you compare colors. Supplier color strings are not clean. "Royal / White", "ROYAL-WHITE", and "royal white" all mean the same thing. We strip spaces, hyphens, slashes, and #, then lowercase, before comparing. A naive .lower() comparison misses matches that are plainly the same color to a human.
For HIT, skip the color steps entirely. Their color field is blank, so the color lookups can only produce noise. We register a part-only strategy for them and stop after step 1. It’s worth having a per-supplier hook here rather than pretending one cascade fits everyone.
Filtering is only half the job. You still have to rank.
Suppose the cascade hands you eleven images for Royal. Which one is the image?
We sort with a comparator that applies four rules in strict priority order:
- Single-part images beat group shots. A
singlePartimage shows one product. A group shot shows five, and it makes a terrible variant thumbnail. - Primary (or blank) beats everything else. Which of these you want depends on the job, so it’s a setting. If you’re building a catalog, the
primaryshot is the marketing image the supplier chose. If you’re building a decoration preview, you want theblankshot, an undecorated product you can render artwork onto. Shipping a decorated sample as your blank canvas is a bad day. - Front view beats other angles. A rear view is a fine second image and a poor first one.
- Closest to the target height wins. Not the biggest. The closest.
That last rule surprises people, so it’s worth dwelling on. We don’t sort descending by resolution, because “biggest” is rarely what you want. We pick a target height for the context and choose the image nearest to it:
| Context | Target height |
|---|---|
| Thumbnail | 50px |
| Small | 470px |
| Base (the default) | 1100px |
| Best | 1900px |
A 4000px hero is not a better thumbnail than a 50px one. It’s a slower one.
There’s also a hard ceiling on what we hand back. Anything over 25MB or wider than 2000px gets routed through a resize service rather than served raw, because a 40MB TIFF is not something you want your storefront pulling on every page view.
Throwing out what should never be shown
Before any of the above runs, the pool gets cleaned:
- Swatches are always excluded. A swatch (class type 1004) is a flat color chip, not a product photo. It is never the right featured image, and it will happily win a naive “closest to target size” sort.
- Non-displayable files are excluded. Media Content returns
.psdand.pdffiles. They’re real assets. They are not<img>sources. - Class types you don’t want are excluded by config. Internal-use images (900) and art templates (4004) are real categories that shouldn’t reach a storefront.
- Broken links are dropped. We
HEADevery unique URL before import and discard the dead ones. Suppliers do serve URLs that 404. (Treat403and405as alive, not dead. Plenty of CDNs reject aHEADfrom a bot while serving the image perfectly well to a browser.)
The SanMar trap, and why the obvious fix breaks HIT
Here is the most useful thing in this post.
SanMar sends the same low-resolution primary image for every color. It is 300x450, and it’s wrong for most of the colors it’s attached to. They also send a good, high-resolution model shot per color. If your picker prefers the “primary” class type, it will confidently choose the wrong image for nearly every variant.
The fix looks obvious: set a minimum resolution and let the bad primary fall out. That’s exactly what we do, with a floor of 800px of height by default. The 300x450 primary is below the floor, never enters the pool, and the good per-color shot wins without any supplier-specific hack.
Then we shipped that, and HIT products started importing with zero images.
HIT publishes no dimensions at all. height is null. Our filter had been treating unknown as zero, so every HIT image tested as “below the minimum” and got discarded. The product imported clean, with no media, and no error.
The correct rule is to fail open on unknown:
mc.is_high_res or mc.height is None or mc.height >= self.minimum_resolutionDrop an image only when it explicitly reports a height below the floor. When the supplier didn’t tell you how big it is, keep it and let the later ranking rules sort it out. Absence of data is not evidence of a small image.
If you take one thing from this post, take that shape: a filter that treats missing data as a failing value will quietly delete an entire supplier’s catalog, and it will do it without raising anything you’d notice.
What we don’t do yet
We do not detect that a supplier is reusing one image across every color. Nothing counts how many colors share a URL and infers “this is probably generic.” If SanMar’s shared primary happened to be 1200px tall, it would still land on all forty variants today, and the resolution filter wouldn’t save us.
Dedup is exact-URL only, and only within a scope: we won’t list the same URL twice in one gallery, but we have no perceptual hashing and no cross-color heuristics. That’s a real gap, and it’s the direction we’d extend this next.
The code
All of this lives in psdomain , our open-source Python model for PromoStandards. The image logic specifically is in services/media_content.py, with the class-type constants and the comparators in model/media_content.py.
It’s Python because that’s what we write, but there’s nothing Python-specific about the approach. The cascade, the four-rule ranking, and the fail-open filter port to any language in an afternoon.
To pull the media in the first place, see the Media Content guide in the docs. If you’re wiring this up and hitting a supplier that behaves in a way this post doesn’t cover, tell us . That’s usually how we find out about the next quirk.