Tutorial 121: Video Playback with VideoPlayer

CNA Tutorials  ·  Media

What you’ll learn

  • Loading and playing a video, and drawing its frames with SpriteBatch.
  • That the video’s own audio track plays, and how to choose between tracks.
  • The two platform facts that decide whether video exists in your build at all.
  • How to recognise an unsupported pixel format on sight.

Before you startTutorial 21: SpriteBatch in Depth covers the drawing call you will use for every frame.

CNA’s VideoPlayer is a real decoder, not a placeholder. It opens the file through FFmpeg, decodes video frames and audio, paces playback against presentation timestamps rather than a fixed frame counter, plays the file’s own audio track, and lets you switch between tracks in a multi-track file. What it is not is universally available — and that is the first thing to settle, because it is a build-time question, not a runtime one.

Start here: is video in your build?

Two hard platform facts.

  1. On Linux and macOS, FFmpeg is a required dependency. There is no CMake option to turn it off. A configure without the development packages for libavcodec, libavformat, libavutil and libswresample fails outright.
  2. On Windows, Emscripten and Android, video is excluded from the build. The three video translation units are filtered out of the source list. The headers are still there, so code calling Video or VideoPlayer compiles — and then fails to link, with undefined symbols.

The second point is the one that catches people. There is no runtime “is video available” query to branch on, because the symbols simply are not in the binary. Guard at build time with your own macro:

#if !defined(_WIN32) && !defined(__EMSCRIPTEN__) && !defined(__ANDROID__)
    #define GAME_HAS_VIDEO 1
#endif

On Debian and Ubuntu the prerequisites are:

sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswresample-dev

See Tutorial 124: WebAssembly gotchas for the web side of this, alongside the two other things that behave differently in the browser.

Loading a video

There are two routes. Through the content manager, if the file lives in your content root:

// Content/videos/intro.mp4
Media::Video intro = Content.Load<Media::Video>("videos/intro");

The loose-file reader recognises .mp4, .ogv, .webm, .mkv, .avi and .mov, tried in that order when you omit the extension. Note that the reader is registered only on the platforms where video is compiled in.

Or construct one directly from a path, which is often more convenient for a video that is not a shipped asset:

CNAEXT Video(std::string fileName, Graphics::GraphicsDevice* device);

This constructor probes the file immediately and throws System::IO::FileNotFoundException if there is nothing there. A second, seven-argument constructor exists for the XNB path, where duration, dimensions, frame rate and soundtrack type come from the asset metadata rather than the file.

Playing and drawing

VideoPlayer hands you a Texture2D* for the current frame. Call GetTexture() every frame you draw — it is the call that advances decoding, so skipping it stalls playback.

#include "Microsoft/Xna/Framework/Media/Video/Video.hpp"
#include "Microsoft/Xna/Framework/Media/Video/VideoPlayer.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Media;

class IntroScreen
{
    Video       intro_;
    VideoPlayer player_;

    void Start()
    {
        player_.setIsLoopedProperty(false);
        player_.setVolumeProperty(1.0f);
        player_.Play(&intro_);
    }

    void Draw(Graphics::GraphicsDevice& device, Graphics::SpriteBatch& spriteBatch)
    {
        Graphics::Texture2D* frame = player_.GetTexture();
        if (frame == nullptr)
        {
            return;   // nothing decoded yet
        }

        const auto& viewport = device.getViewportProperty();
        const Rectangle destination(0, 0,
                                    viewport.getWidthProperty(),
                                    viewport.getHeightProperty());

        spriteBatch.Begin();
        spriteBatch.Draw(*frame, destination, Color::White);
        spriteBatch.End();
    }
};

GetTexture() returns nullptr before you have ever called Play(), so the null check above is not defensive padding — it is the documented first-frame state. Note also that SpriteBatch::Draw takes a const Texture2D&, hence the dereference.

Watch for the end of playback in your update, not your draw:

if (player_.getStateProperty() == MediaState::Stopped)
{
    GoToMainMenu();
}

MediaState has three values: Stopped, Playing and Paused.

How pacing works

Playback is driven by a wall clock, not by how often you happen to call GetTexture(). Each call compares the elapsed play time against the presentation timestamp of the last decoded frame, and decodes forward until it has caught up. A frame that arrives late is skipped rather than shown late, so a stutter in your render loop costs you frames rather than desynchronising the audio.

Consequently, calling GetTexture() more often than the video’s frame rate is free — you simply get the same texture back — and calling it less often makes the player skip. Paused and stopped players return the existing texture without decoding anything.

The video’s own audio

This is genuinely implemented, and it is worth stating plainly because it is the part most often stubbed out elsewhere: the audio stream inside your video file is decoded, resampled and played, in sync with the frames.

player_.setVolumeProperty(0.7f);   // documented range [0, 1]
player_.setIsMutedProperty(true);  // mutes without stopping

Pausing the player pauses the audio device stream too, so there is no drift on resume. At the end of a non-looping video the player waits for the queued audio to drain before it reports Stopped, which is why the transition can lag the last visible frame by a moment.

Multi-track selection (CNAEXT)

Files with several audio streams — a localised release with one track per language, or a commentary track — are addressable by index:

CNAEXT void SetAudioTrackEXT(SharpRuntime::intcs track);
CNAEXT void SetVideoTrackEXT(SharpRuntime::intcs track);
player_.SetAudioTrackEXT(1);   // second audio stream
player_.Play(&intro_);

Set the track before Play() and the preference is applied as the decoder opens. Set it during playback and the player switches, reconfiguring only the output that actually changed. Video carries the same two methods and forwards them to its player.

These are extensions — real XNA has no track selection — so they will not compile under -DCNA_STRICT_XNA_API=ON.

Magenta means “pixel format not handled”

If a video plays as solid magenta, the decoder produced a pixel format CNA’s converter does not handle. It does not throw and it does not log — it fills the frame with magenta so the failure is impossible to miss.

RGBA, RGB24, NV12 and the common planar 8-, 10- and 12-bit YUV layouts convert normally. Anything else lands on the fallback. The fix is on the encoding side: re-encode to a mainstream format, most simply H.264 in yuv420p.

ffmpeg -i exotic.mkv -pix_fmt yuv420p -c:v libx264 -c:a aac intro.mp4

The rest of the surface

MemberNotes
Play(Video*)Also throws System::InvalidOperationException if the Video’s declared width, height or frame rate disagrees with what the file actually reports.
Stop(), Pause(), Resume()All throw System::ObjectDisposedException after Dispose().
getPlayPositionProperty()Read-only — there is no seek. You cannot set the play position.
get/setIsLoopedPropertyLoops by seeking back to the start at EOF.
get/setIsMutedProperty, get/setVolumePropertyApplied to the audio device stream directly.
getVideoProperty()The Video* currently loaded, or none.
getStateProperty()MediaState.
getIsDisposedProperty(), Dispose()Standard disposal.

On Video itself: getWidthProperty(), getHeightProperty(), getFramesPerSecondProperty(), getDurationProperty() and getVideoSoundtrackTypeProperty(), which returns one of Music, Dialog or MusicAndDialog.

No seeking. There is no way to jump to a timestamp — only play, pause, resume, stop and loop. Design your cutscenes as linear playback with a skip that stops the player, rather than as a scrubbable timeline.

Before you ship a video

  • Is every VideoPlayer reference behind a build-time guard, so Windows and web builds still link?
  • Does the video actually play, rather than showing magenta?
  • Can the player skip it, and does the skip stop the player rather than just hiding it?
  • Does your build machine have the four FFmpeg dev packages, and does your CI?
  • Have you tested what happens on a machine with no audio device?

Where to go next