Standardized artwork kinds for GameStream app entries #1053
Replies: 3 comments 7 replies
|
Depends on LizardByte/roadmap#153 and LizardByte/roadmap#87 |
|
I actually had the same kind of idea and even investigated how the original GameStream server worked, but I couldn't determine for sure the logic used by the original software. GeForce Experience's server being offline probably doesn't help analyze how the software is supposed to run, and my reverse engineering efforts with no experience didn't yeild any results. |
|
Haha yeah the length of the first post kinda scared me off from replying initially. Small disclaimer: I did not read it entirely still so I hope this was not covered in your post :p. My stance from Moonshine's perspective has always been that I feel like a guest using someone else's API. Being mentioned here as "one of the implementations" feels both weird and cool at the same time 🤷 That said though, I do feel that the interface of Moonlight feels outdated (no offense intended). If there would be an initiative to make it more "Steam Big Picture"-like I would support that. Part of that would be to retrieve more media, ie hero + box cover + others. I do wonder if retrieving that media should be the responsibility of the server or the client. In the current API the full responsibility is with the server, but the client has better knowledge over what it needs and in what shape. I'd hope there's some game DB, where we can send an ID from that DB to the client and the client is responsible for retrieving media. Obviously there's a big flaw with that approach. If there's a game which has no ID in that DB you would need to set some media manually on each client. This is not a complete review, more of a random thought that I wanted to share :) |
Uh oh!
There was an error while loading. Please reload this page.
Select Topic Area
Feature Request
Body
Summary
GameStream carries one artwork slot per app. Because there is only one, a host holding
good artwork has to damage it to fit. Moonshine pillarboxes square desktop icons onto
a portrait canvas and its own source comment says they get distorted. It also decodes
Steam's JPEG capsules and re-encodes them to PNG on every request. Its fix for missing
artwork had to funnel a 600x900 Steam library capsule and a square freedesktop icon
into the same config field, because only one field exists. Sunshine returns a
placeholder image rather than an error, which is why moonlight-qt inspects pixel
dimensions to guess whether it received real art at all.
None of that is a bug in any of those projects. It is what one artwork slot forces.
This proposal standardizes five artwork kinds (capsule, wide capsule, hero, logo,
icon) so a host can serve the artwork it already holds without reshaping it, and so
clients can use it. A kind names what an image is, not what shape it has: the host
declares each asset's real pixel dimensions and the client decides how to use them.
Today's box art request does not change, since it becomes the capsule kind, so
existing hosts and existing clients are unaffected.
Four independent host implementations (Sunshine, Apollo, Wolf and Moonshine) have all
converged on one PNG per app with the artwork type parameter ignored. That is what
makes this a gap in the protocol rather than a shortcoming in any one project.
The whole wire surface
Everything the extension adds, in one place. All of it is optional and additive, and a
host that implements none of it behaves exactly as it does today.
/serverinfoArtworkKinds/serverinfoArtworkScaling/serverinfoArtworkRequestLimit/applistArtworkwithAssetchildren/appassetartworkKind/appassetmaxWidth,maxHeightNo existing parameter changes meaning and nothing is removed. One existing parameter
gains a use:
AssetIdx, hardcoded to 0 by every client for years, selects between anapp's heroes.
Three things are deliberately absent because they are client decisions rather than
protocol: where a logo sits over a backdrop, what a client stores on disk, and what
happens when a user has already assigned artwork locally.
What this costs each side
Worth stating plainly, because the split is uneven. A host declares which kinds it
has, per app and per asset, returns the requested kind, sets a
Content-Typethatmatches the bytes, and returns 404 instead of a placeholder when an asset is absent.
Optionally it emits
ETag, relaxes PNG-only validation, and scales on request. Thatis nine obligations, and it is most of the work in this document.
A client asks only for assets an app has declared, sends an
Acceptheader, respectsthe host's concurrency limit, includes any requested size in its cache key, and
ignores kinds it does not recognize. That is roughly an afternoon per client.
So hosts carry the cost. The reason it is still worth their while is the first
paragraph of this document: the artwork being reshaped, re-encoded and conflated today
is artwork hosts already have and already lose. A host that adopts this stops guessing
which grid cell some client is using, stops pillarboxing square icons, and can serve a
JPEG as a JPEG.
Where the design comes from
Nothing here is invented. Four existing artwork systems were read before drafting, and
three of them agree with each other:
ImageTypecovers Primary, Art, Backdrop, Banner, Logo, Thumb,Disc, Box, Screenshot, Menu, Chapter, BoxRear and Profile, and which carries
ImageType,Width,Height,ImageTagandBlurHashper image. Its imageendpoint accepts
maxWidth,maxHeight,fillWidth,fillHeight,formatandquality, which is the model behind optional host side scaling here.thumb and spine.
aspect_ratio,widthandheight.Header at 920x430 and Library Hero at 3840x1240.
Every name in the first three is a role, not a shape, and Jellyfin and TMDB both
declare real dimensions rather than requiring them. Steam is the exception, and it is
also the only one of the four that ships the sole client reading its artwork. Moonlight
is in the other group, with five clients and four hosts and no coordination between
them, which is why this proposal names roles and declares dimensions. See
How other artwork systems model this.
The five kinds follow SteamGridDB's taxonomy, read from
decky-steamgriddb, becausethat is where the curated artwork and the tooling to apply it already exist.
The current state described below was read from source in all four host
implementations (Sunshine, Apollo, Wolf and Moonshine) and all three maintained
clients (moonlight-qt, moonlight-android and moonlight-ios). Every file and line
reference in this document points at that reading rather than at documentation.
Motivation
With one slot, a host has to guess the client's layout and reshape images to match.
Moonshine resizes and pillarboxes every asset onto a 600x801 canvas chosen to fit one
specific client's grid cell, and its own source comment notes that square desktop
icons get distorted as a result. See
Hosts compensating for client layout. A host
cannot avoid this, because nothing in the protocol lets it say what an image is, and
nothing lets a client say what it wants.
The same slot caps what clients can build. Every Moonlight client renders a grid of
portrait tiles, because a portrait tile is the only thing the protocol can express. TV
clients such as tvOS and Android TV normally place a wide backdrop behind the focused
item with a transparent logo over it. Compact list layouts and "recently played" rows
want a square icon. Detail views want a wide image filling the header. None of those
are possible today at any client's discretion, because the artwork does not exist to
ask for.
The artwork itself is not the obstacle. A large curated corpus for exactly these kinds
already exists at SteamGridDB, hosts already scan Steam, Heroic, Lutris and
freedesktop entries that contain several kinds, and host side tooling to apply artwork
already exists. What is missing is any way for GameStream to say which kind it is
serving.
How this surfaced
This proposal came out of building a SwiftUI tvOS client, aiming for a library that
behaves like Steam Big Picture: a backdrop behind the focused item, a title treatment
composited over it, and shelf rows that are not portrait tiles.
Each of those layouts turned out to be blocked at the protocol rather than in the
client. Backdrops could not be populated at all, since no wide artwork exists to
request and upscaling a portrait capsule to fill a 16:9 area is not viable. Titles had
to be drawn as text, because no transparent logo is available to composite. Shelf and
list rows had to stay portrait, because cropping a capsule to 16:9 throws away most of
the image.
Since the only artwork available is a single portrait capsule, the ceiling on any
Moonlight client's library UI is set by the protocol. That is what prompted reading
the wire format, which in turn produced the finding below: the artwork type parameter
has been sent by every client, and ignored by every host, all along.
One slot, six names
The single artwork slot is named differently in every implementation, because there
is no shared concept for what it holds:
image-path/ctx_t::image_pathimage_path(inherited from Sunshine)icon_png_pathboxartboxArtgrid_p("Capsule")Wolf's name refers to what this proposal calls a capsule, while
iconhere meanssomething else entirely. Moonshine has a config field documented as "whether to
resolve desktop entry icons into Moonshine boxart paths"
(
app_scanner/desktop.rs:30-32), collapsing both concepts into a single sentence.Standardizing the taxonomy settles this drift as a side effect.
Current state
The unused artwork type parameter
All three actively maintained clients issue an identical request, with
AssetTypeandAssetIdxhardcoded:app/backend/nvhttp.cpp:392-404(NvHTTP::getBoxArt)app/src/main/java/com/limelight/nvstream/http/NvHTTP.java:720-723(getBoxArt)Limelight/Network/HttpManager.m:298-305(newAppAssetRequestWithAppId:)What each host does
No host reads either parameter:
appassethandlerAssetType?src/nvhttp.cpp:1237-1248image/pngsrc/nvhttp.cpp:1495-1524image/pngsrc/moonlight-server/rest/endpoints.hpp:340-369image/pngmoonshine-core/src/webserver/mod.rs:451-539image/pngSunshine and Apollo both reduce to a single lookup:
Wolf reduces to one optional field on
moonlight::App(
src/moonlight-protocol/moonlight/data-structures.hpp:17-22):AssetTypeis therefore a pre-existing, already-transmitted, currently inertextension point, which means this proposal does not have to invent a new request
channel.
No artwork metadata in applist
Sunshine emits three fields per app (
src/nvhttp.cpp:973-981):Wolf builds its list from the same
moonlight::Appshape, and Moonshine'sapp_listlikewise carries no artwork fields, so a client cannot know what artwork exists
without speculatively requesting it.
PNG on the wire
Sunshine's
validate_app_image_path(src/process.cpp:530-560) rejects any extensionother than
.pngand quietly substitutesDEFAULT_APP_IMAGE_PATH. Wolf's field isnamed
icon_png_path.Moonshine is the interesting case. It decodes any format via
image::load_from_memory, then unconditionally re-encodes to PNG(
webserver/mod.rs:528,ImageFormat::Png), so its PNG constraint applies to thewire rather than to the source file.
This matters because a 1920x620 hero as PNG is far heavier than the same image as
WebP or JPEG, and hero is the kind most likely to be large. Moonshine already runs a
full image pipeline inside the request path, so for that host emitting WebP is close
to a one line change.
Hosts compensating for client layout
All four hosts face the same constraint: with a single artwork kind, a host cannot
know what shape the client needs, so any source image that is not already portrait
has to be forced into a portrait slot.
Moonshine is cited here because it is the only implementation that wrote that
constraint down in code, which makes it the clearest available evidence, not because
it behaves worse than the others. Its
appassethandler normalizes every imageserver side (
webserver/mod.rs:522-525):fit_to_boxart(webserver/mod.rs:1055-1078) scales to fitBOXART_WIDTH = 600byBOXART_HEIGHT = 801and centres the result on a transparent canvas. Given oneartwork kind this is the right call, since it prevents exactly the stretching the
comment describes. What it also makes visible is the cost.
A host is obliged to encode a specific client's layout: 200x267 is moonlight-qt's
image size (
app/gui/AppView.qml:108-109), and other clients use other sizes, so thehost is compensating for a layout it cannot query and cannot negotiate. Two artwork
kinds are demonstrably in play already, because the comment itself distinguishes box
art from square desktop icons, which are the capsule and icon kinds of this proposal
sharing one slot. Non portrait sources also lose usable resolution: Moonshine's
scanners ingest Steam, Heroic, Lutris and freedesktop
.desktopentries, andresolve_icons(app_scanner/desktop.rs:30-32) routes square desktop icons into theboxartfield, where they end up pillarboxed onto a portrait canvas.Given an icon kind, a host could serve a square icon as a square icon and leave the
layout decision to the client, which is the only place it can be made correctly.
Caching without invalidation
Clients cache one file per app, keyed by host UUID, and fetch only when the file is
absent:
<boxart>/<uuid>/<appId>.png(boxartmanager.cpp,getFilePathForBoxArt)<Caches>/<uuid>/<appid>.png(AppAssetManager.m:22-35)AppAssetManager.m:49)There is no ETag, no
Last-Modifiedand no version field, so changing artwork on thehost does not propagate to clients.
Because the four hosts disagree about how to signal absence (a placeholder image, a
404, or a 400, per What each host does), moonlight-qt resorts
to inspecting image dimensions to detect placeholders
(
app/gui/AppView.qml:91-106):This is a symptom of "artwork absent" not being expressible, and it is already a
latent bug.
isAppCollectorGameis a GFE concept, so against Sunshine, Wolf, Apolloand Moonshine the guard is false and the size check always runs; any genuine artwork
that happens to be exactly 628x888, 130x180 or 200x266 is silently classified as
missing. In
games-on-whales/wolf#140a user authoring test artwork foricon_png_pathproduced it at exactly 628x888, one of the three sizes checked.Adding four more kinds without addressing this would multiply the problem by five.
The four request cap
Both clients cap artwork fetches at four concurrent requests, explicitly to protect
the host. In moonlight-qt,
boxartmanager.cppsetsm_ThreadPool.setMaxThreadCount(4)with the comment "4 is a good balance betweenfast loading for large app grids and not crushing GFE with tons of requests". In
moonlight-ios,
AppAssetManager.m:20setsMAX_REQUEST_COUNT = 4.Any proposal that turns one request per app into five will be rejected on load
grounds. Two things address that: an app declares which assets it has, so clients do
not probe, and the host states the concurrency it will accept rather than having a
client's number written into the specification. See
Per app artwork assets and
Request concurrency.
Related reports
A search of all
moonlight-streamrepositories,LizardByte/Sunshine,games-on-whales/wolfandhgaiser/moonshinefound no existing issue, pull requestor discussion proposing additional artwork kinds. No repository in the
moonlight-streamorganization has GitHub Discussions enabled and none has aCONTRIBUTING.md, so this proposal is filed as an issue.The trackers do contain the downstream symptoms.
hgaiser/moonshine#40, "Fix missingboxart for Steam games and manual applications", does two things at once: it reads
Steam's
library_600x900.jpgwith a fallback tolibrary_capsule.jpg, and itresolves XDG icon directory icons into the same
boxartfield for apps that lackone. A 600x900 Steam library capsule and a square freedesktop icon are different
kinds, and the fix has to funnel both into one field because only one field exists.
Note also that
library_600x900is exactly SteamGridDB's canonicalgrid_pdimension, and that it is a JPEG which Moonshine then re-encodes to PNG on every
request.
games-on-whales/wolf#140, "Game icons", is a user unable to work out whaticon_png_pathexpects, who supplied a 628x888 portrait image for a field named"icon". The naming drift described in One slot, six names is
producing real confusion.
To be explicit, neither project is doing anything wrong and nothing above is a
criticism of either implementation. Both are working around a protocol that gives
them one slot and no vocabulary. The reason for citing them is that the workarounds
are converging independently, which is what a missing protocol primitive tends to
look like.
Prior art
How other artwork systems model this
Four systems already solve this problem, and three of them agree with each other.
ImageType,Width,Height,ImageTag,BlurHash,Sizeaspect_ratio,width,height,file_pathEvery name in the first three columns is a role. Not one is a shape. Jellyfin says
Primary, notPortrait, andBackdrop, notWide. Jellyfin and TMDB additionallydeclare each image's real dimensions to the client, so a client can choose without
decoding anything first.
Steam is the exception that proves the rule. It mandates exact sizes:
Steam can do that because Steam ships the only client that consumes them. Moonlight
is in the other group, with five clients and four hosts and no coordination between
them.
Jellyfin also lets the client ask for the size it wants, and its image endpoint
accepts
width,height,maxWidth,maxHeight,fillWidth,fillHeight,quality,format,blur,backgroundColor,tagandimageIndex. That is themodel behind Optional host side scaling.
The SteamGridDB taxonomy
From
decky-steamgriddb/src/constants.ts(ASSET_TYPE,DIMENSIONS,MIMES):grid_pgrid_lherologoiconSteamGridDB also tags artwork with styles (
alternate,white_logo,no_logo,blurredandmaterialfor grids and heroes;official,white,blackandcustomfor logos). That is a concern for whatever populates a host, not for serving,so it stays out of scope here.
Why kinds name roles, not shapes
The first draft of this proposal defined each kind by a nominal aspect ratio. That
does not survive the evidence, because the ratios in the wild do not agree:
grid_p512x512 (option)no_app_image200x266grid_p342x482 and 660x930There are two clusters. Authoring standards sit between 1:1.409 and 1:1.500, while
Moonlight's own rendering sits at 1:1.335. Mandating any single figure would put
either the entire existing GameStream artwork corpus or two current host
implementations out of spec, and would force hosts to reshape or reject most of what
SteamGridDB serves, including its square capsule options.
So a kind names a role and the host declares each asset's real dimensions.
Variation in dimensions does reach the client under this model. That is already true
today, and is currently handled by stretching: moonlight-qt sets its draw area to
200x267 regardless of the source image and declares no
fillMode, so QML falls backto
Image.Stretch. Declaring dimensions does not introduce the variation, it tells aclient about variation it already receives, so it can letterbox or crop deliberately
and reserve layout space before an image arrives.
Proposal
Artwork kinds
A kind describes the artwork itself. The example uses are illustrative rather than
binding, because where an image is drawn is a client decision and the five Moonlight
clients want different answers.
capsulegrid_pwide-capsulegrid_lheroherologologoiconiconcapsulemust behave identically to the currentAssetType=2&AssetIdx=0response. Itis named here only to complete the taxonomy.
Capability negotiation
A host implementing this proposal adds up to three elements to
/serverinfo:If
ArtworkKindsis absent the host is legacy, and clients must not sendartworkKindrequests. Clients must ignore kinds they do not recognize, so the listcan grow later.
That element carries real weight rather than being a convenience. A legacy host given
a hero request silently returns the portrait capsule (see
What each host does), which a client would then draw into a
wide slot. Since the response carries no indication of which kind it satisfies,
negotiation is the only way to avoid that.
Per app artwork assets
Each app lists the assets it actually has, so a client never probes for artwork that
does not exist:
widthandheightare the dimensions of the stored asset, before any scaling thehost may apply on request.
tagis an opaque token that changes when that assetchanges; hosts may compute it from file modification time and size. An app with no
artwork omits the
Artworkelement entirely, and the common case of one capsulecosts the single request it costs today.
Only
heromay appear more than once, since rotating or per scene backdrops are theone case where several images of the same kind are genuinely useful. Multiple heroes
carry an
index, and every other kind appears at most once.This one list replaces the separate availability and revision fields an earlier draft
carried: availability is the presence of an
Assetentry, and versioning is theper asset
tag.Requesting an asset
The existing endpoint gains one parameter:
AssetType=2is kept verbatim so the request stays valid against legacy hosts and noclient needs a second code path. An absent
artworkKindmeanscapsule, matchingtoday's behaviour exactly. An unknown
artworkKindon a supporting host must produceHTTP 400, never a substituted image of some other kind.
AssetIdxfinally has a meaning: it selects among an app's heroes, matching theindexfrom the asset list. For every other kind it must remain 0.Optional host side scaling
A host that advertises
ArtworkScalingaccepts a requested bound and returns theasset scaled to fit it:
Aspect ratio is preserved and assets are never scaled up. A host that does not
advertise scaling ignores these parameters and returns the stored asset, which is what
Sunshine and Wolf do today by streaming a file straight off disk. Moonshine already
decodes and re-encodes on every request, so for it this is nearly free.
Clients caching a scaled response must include the requested bound in their cache key,
since the same
tagdescribes different bytes at different sizes.Content types
Hosts must set a
Content-Typereflecting the bytes they actually serve, replacingthe hardcoded
image/pngdescribed in What each host does.Clients should send an
Acceptheader:Permitted types per kind follow the table in
The SteamGridDB taxonomy. Hosts should relax PNG-only
validation to match, which is where the bandwidth saving for heroes comes from.
Absence semantics
A supporting host must return HTTP 404 when the requested asset does not exist, and
must not substitute a placeholder image.
This settles a genuine three way split. The four hosts currently signal absence as a
placeholder image (Sunshine and Apollo), a 404 (Wolf), or a 400 (Moonshine). Wolf
already matches this rule. Moonshine's 400 is close, though 404 is the more precise
signal, since a missing asset is not a malformed request. Sunshine and Apollo need a
behaviour change, but only on the path that carries
artworkKind, so their existingplaceholder behaviour stays intact for older clients.
Once a host advertises support, clients can retire the dimension inspection described
in Caching without invalidation.
Caching and revalidation
Hosts should emit
ETagandLast-Modifiedon/appassetresponses, and shouldhonour
If-None-MatchandIf-Modified-Sincewith304 Not Modified.An asset's
tagin the asset list changes whenever that asset changes. That is thewhole of its meaning; what a client stores, evicts or re-fetches on the strength of it
is the client's own business.
Request concurrency
A host may advertise
ArtworkRequestLimit, the number of concurrent artwork requestsit will accept. Clients should not exceed it. If the element is absent, clients should
assume 4, which is what moonlight-qt and moonlight-ios already use.
An earlier draft required clients to cap requests at four. That number belonged to two
specific clients rather than to the protocol, and the constraint is the host's to
describe, so it now travels in the direction of the party being protected.
All reactions