Loading media
MediaAddress, MediaLibrary and the source interface — how a reference becomes an asset, who owns what, and the release rule that has no automatic fallback.
All documentation — Reference
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
On this page
A step hands you a MediaRef. Turning that into a Sprite goes through three things: an address
that has been resolved, a library that refcounts what comes back, and a source that actually
loads it.
Everything here is in TaleShard.Unity.Media, and all of it is main thread only — nothing locks,
and Unity’s asset APIs are main-thread-only anyway.
The shape of it
MediaRef → MediaAddress.From(…) → MediaLibrary.Sprite(…) → IMediaSource → Sprite ↑ ↑ MediaCatalog refcounts, and (the index) releases nothing on its ownBootstrap, first
MediaCatalog holds the project’s address index. Install it before anything asks for a picture.
| Member | What it does |
|---|---|
static bool IsInstalled { get; } | |
static IMediaCatalog Current { get; } | Never null — with none installed it is an empty catalog that reports the missing bootstrap line the first time it is asked anything |
static void Install(IMediaCatalog catalog) | Last one wins |
static void Install(IReadOnlyDictionary<string,string> keyById, IReadOnlyDictionary<string,string> keyByCode, IReadOnlyDictionary<string,string> extensionByKey) | The generated maps directly — the one-line form |
static void Clear() | For tests, and for a project that swaps content at run time |
IMediaCatalog itself is three members: KeyOf(idOrCode), ExtensionOf(key), Count. It is an
interface rather than a direct reference because the generated MediaIndex lives in your
assembly, and the reference between assemblies runs one way — your game references the plugin, never
the reverse.
MediaAddress
readonly struct MediaAddress — a media reference that has been through the index.
The one place a media address is resolved, so a raw string can never reach a loader.
Resolution happens once, at construction. Afterwards a reference either carries an address or says why it does not.
Getting one
| Factory | Use it for |
|---|---|
From(MediaRef reference) | The ordinary path — what a step’s fields, audio and cast all carry |
From(MediaRef? reference) | The nullable form, which is what every runtime accessor returns |
From(string idOrCode) | An id or a code, whichever you have. Ids are tried first. |
FromCode(string code) | When you have a code from somewhere the engine does not reach |
Raw(string address) | An address you assert — the way to put your own assets through the same refcounted library |
Unresolved(string requested, string reason) | A reference that resolves to nothing, carrying why |
A bare From(null) is ambiguous between the overloads and will not compile. Cast the literal.
Reading one
| Member | Notes |
|---|---|
Requested | What the caller asked for, in the author’s own words. Kept even when resolution failed — it is the only thing a diagnostic can name. |
Key | The address: a relative path without its extension. Null when unresolved. |
IsResolved | |
UnresolvedReason | Why there is no Key. A default(MediaAddress) answers here too, naming itself. |
IsAsserted | Did you assert this with Raw, rather than the index deriving it? |
Extension | Including the dot, for a loader that needs the file on disk. Always empty for an asserted address. |
RelativePath | With the extension — the same string the generated registry holds |
ToString() reads as the address when resolved and as the failure when not, so a reference
interpolated into a log line can never look like a working one.
Two addresses are equal when they resolve to the same address, not when they were asked for the
same way. IsAsserted is not part of identity.
MediaLibrary
Where you ask for a picture, a sound or a video.
MediaLibrary.Shared is the one sources register themselves into. A library you construct yourself
starts empty.
| Member | Notes |
|---|---|
static MediaLibrary Shared { get; } | |
IReadOnlyList<IMediaSource> Sources { get; } | Highest priority first |
int HeldCount { get; } | Zero after a clean teardown |
void Register(IMediaSource source) | Ordered by priority immediately — registration order never decides anything |
bool Unregister(IMediaSource source) | Anything already loaded stays loaded |
IMediaLoad<Sprite> Sprite(MediaAddress reference) | |
IMediaLoad<AudioClip> Audio(MediaAddress reference) | |
IMediaLoad<VideoClip> Video(MediaAddress reference) | The clip only — playing it is a VideoPlayer’s job |
void Release<T>(MediaAddress reference) | |
void ReleaseAll() | |
bool TryGet<T>(MediaAddress reference, out T asset) | Peek without retaining |
int RefCount<T>(MediaAddress reference) | For a debug view |
string Describe(MediaAddress reference) |
A load is an IEnumerator, so a coroutine can yield return library.Sprite(reference); and then
read Result.
TryGet takes no refcount, which is what makes it safe from a UI rebuild. It answers about now
— false also means “still loading”.
Describe is the answer to “why is this picture blank”: which source would answer, and what the
others would say. Print it from a debug window.
Registering a source that is already present — same type, same name, same priority — is a warned no-op rather than a duplicate.
IMediaSource
Somewhere media can be loaded from: Addressables, Resources, an asset bundle, a download cache, a
test double.
| Member | Notes |
|---|---|
string Name { get; } | For logs and Describe. A configurable source must say how here — see below. |
int Priority { get; } | Higher answers first. Ties resolve by name, and are reported once as a configuration mistake. |
bool CanAnswer(MediaAddress reference) | Structural only — no file system, no network, no asset lookup |
string Describe(MediaAddress reference) | What it would do, in one line. Allowed to say it would fail, and saying so is the point. |
MediaLoad<Sprite> LoadSprite(MediaAddress reference) | Never returns null |
MediaLoad<AudioClip> LoadAudio(MediaAddress reference) | Never returns null |
MediaLoad<VideoClip> LoadVideo(MediaAddress reference) | Never returns null |
Three rules for writing one:
- Priority is the whole ordering. Never registration order.
CanAnsweris asked on every load, so a source that does I/O there turns one load into two.- Report, never return a silent null. Every failure path calls
Failwith a reason a developer can act on.
Name carries configuration because Register treats same type, name and priority as a duplicate —
which is why the built-in resources source names its folder, as Resources:Media.
A load in progress
IMediaLoad is the untyped half — IsDone, Error, Reference — and it is an IEnumerator so a
plain coroutine can wait on it.
IMediaLoad<T> adds Result, Ownership, and a Finished event.
Subscribing to an already-finished load invokes your handler immediately, so a caller never has to ask whether it missed the completion.
Check Error, not a null Result. “Not loaded yet”, “no such asset” and “the source is
misconfigured” are three different answers, and a bare null is all three.
MediaLoad<T> is the implementation a source uses: create one, hand it back, complete it when your
callback fires.
| Member | Notes |
|---|---|
MediaLoad(MediaAddress reference, string sourceName) | |
void Complete(T value, MediaOwnership ownership) | Exactly once |
void Complete(T value, MediaOwnership ownership, Action release) | With how to let go of it |
void Fail(string reason) | Exactly once |
static MediaLoad<T> Failed(reference, sourceName, reason) | For a source refusing before it starts |
Action ReleaseHandle { get; } | Run by the library at refcount zero |
A second Complete is refused and reported; the first answer stands.
MediaOwnership
Who owns the object, which decides whether releasing it destroys anything.
| Member | Meaning |
|---|---|
Borrowed | The object belongs to the source. Releasing means telling the source to let go. |
Created | This load made it. Releasing must destroy it. |
MediaPlan
What a thing needs, so you can preload it or report what is missing before a player sees a hole.
| Method | Returns |
|---|---|
Portraits(EnginePayload payload) | Every entity’s portrait, across every definition |
ForEntity(ResolvedEntity entity) | One entity’s portrait plus every media its fields hold |
ForStep(PlayStep step) | Fields, choices’ content, audio, and every cast member’s portrait |
ForBoard(EnginePayload payload, string boardId, string language = null) | Every reference on every node, the board’s voice-over clips in that one language, plus the portraits of the entities those nodes point at |
An unresolved reference is kept rather than dropped — that is the point, if you are auditing. Duplicates collapse by address, so one portrait shared by four characters is one load.
ForStep reads Fields rather than Presented, because a field with no text still carries a
picture.
ForBoard takes the language you will play in, because a voice-over clip is per language and a
board is planned before a session exists — pass the same value you passed to the player, which is a
LanguageOption.Code. Omitting it means the same thing as passing the master language’s own code:
both plan the master language’s clips, which is what an unlocalized build plays. It never plans
every language at once — that is a whole extra copy of the dialogue audio.
The two built-in sources
Both self-register at BeforeSceneLoad. To use your own instead, either match type, name and
priority — yours is then dropped as the duplicate — or unregister the built-in first.
ResourcesMediaSource
Loads through Resources.Load, and self-registers at FallbackPriority (−100) so a project always
has at least one source.
new ResourcesMediaSource() reads the root of Resources. For media in a subfolder, name the part
after it: new ResourcesMediaSource("Media") for Resources/Media.
CanAnswer is true for any resolved reference: it cannot know whether an asset is there without
loading it, and answering “no” would hide it from Describe.
AddressablesMediaSource
No configuration: the address Addressables knows an asset by is the address MediaAddress.Key
carries, because the plugin’s own postprocessor assigns it. Default priority 100, above the resources
source, so a project that installed the package is asked here first regardless of registration order.
Everything it loads is Borrowed, and every load hands back the callback that releases the handle.
Addressables refcounts internally too — a handle never released keeps its bundle resident for the
life of the process.