Standard Solution Can't Play Video From Stream

A lot of people are wondering whether it's possible to play a video from some encrypted source without decoding the source to a disk. It doesn't matter which video format is in use; apparently, MediaElement (if we are talking about C# / WPF) and WindowsMediaPlayer ActiveX (if we are talking about Windows Forms) do not allow playing a stream directly.

You can download sample with source code (look at samples\C#\Sample8_PlayEncryptedVideo):

How Can BoxedApp Help?

BoxedApp SDK to the rescue; it can create virtual files. Such files have some path, but they are not on a disk. When an application attempts to read from a virtual file path, it reads from a virtual file. Usually, virtual files reside in the memory, so one could just create a virtual video file in the memory. But what if the video is huge? Unpacking gigabytes of video content into memory would be a nightmare! Other than that, the job would take a long while.

Wouldn't it be better to read only the portion of the video file that the player needs right now? This is how a player works in the real world: It just reads and seeks from a file on a disk.

The good news is that BoxedApp SDK can provide a virtual file with an IStream as backend.

Wait, what is IStream? You may say, "I know the .Net Stream class, but not aware of IStream." Actually, IStream is a very old standard COM interface. As BoxedApp SDK is available for a ton of different programming languages and environments (from VB6 to C++), the decision to use IStream seems to be reasonable.

When an application reads from a file based on IStream, the appropriate IStream method is called. When the pointer is being changed, IStream.Seek is called. When data is being read, IStream.Read is called. And the third important method is IStream.Clone; we will talk about it a bit later.

So, now the job is to create an IStream implementation that outputs our data properly.

Step 1. Video Encryption.

To play an encrypted video, we need to first encrypt some video. Let's split the original video into chunks and encrypt each of them separately. Also, the original chunk size and the encrypted chunk size for each chunk are saved to the output file. This will help us decrypt the chunk.

We will use the standard C# encryption stuff; here is the helper to encrypt the data:

static class Program
{
    // To generate these arrays, please use the KeyGen application included in this sample
    public static byte[] Key = { 0x9b, 0x67, 0x14, 0xc, 0xb8, 0x7e, 0xf0, 0x4b, 0x6e, 0xd, 0x88, 0x7a, 0xf1, 0xbb, 0x33, 0xc1, 0xc1, 0x12, 0xa3, 0x1f, 0xca, 0x2d, 0xdc, 0x54 };
    public static byte[] IV = { 0x3d, 0x12, 0xe9, 0x8c, 0xea, 0x24, 0x61, 0xf0 };

    // As it's impossible to move the pointer of CryptoStream to any position, we split the input data into chunks
    // and encrypt each chunk
    public static int ChunkSize = 1024 * 1024;

    public static byte[] Encrypt(byte[] input, int size)
    {
        byte[] output;

        TripleDESCryptoServiceProvider TDES = new TripleDESCryptoServiceProvider();

        using (MemoryStream EncryptedDataStream = new MemoryStream())
        {
            using (CryptoStream CryptoStream = new CryptoStream(EncryptedDataStream, TDES.CreateEncryptor(Program.Key, Program.IV), CryptoStreamMode.Write))
            {
                CryptoStream.Write(input, 0, size);
                CryptoStream.FlushFinalBlock();

                output = new byte[EncryptedDataStream.Length];
                EncryptedDataStream.Position = 0;
                EncryptedDataStream.Read(output, 0, output.Length);
            }
        }

        return output;
    }
}
			

Let's encrypt chunk by chunk and save the metadata (original chunk size and encrypted chunk size), where _InputStream is the original file, and _OutputStream is the encrypted file:

using (BinaryWriter Writer = new BinaryWriter(_OutputStream))
{ 
    byte[] Buf = new byte[Program.ChunkSize];

    List<int> SourceChunkSizeList = new List<int>();
    List<int> EncryptedChunkSizeList = new List<int>();

    int ReadBytes;
    long nTotalReadBytes = 0;
    while ((ReadBytes = _InputStream.Read(Buf, 0, Buf.Length)) > 0)
    {
        if (backgroundWorkerEncryption.CancellationPending)
            break;

        byte[] EncryptedData = Program.Encrypt(Buf, ReadBytes);
        _OutputStream.Write(EncryptedData, 0, EncryptedData.Length);

        SourceChunkSizeList.Add(ReadBytes);
        EncryptedChunkSizeList.Add(EncryptedData.Length);

        nTotalReadBytes += ReadBytes;
        backgroundWorkerEncryption.ReportProgress((int)(nTotalReadBytes * 100 / _InputStream.Length));
    }

    foreach (int SourceChunkSize in SourceChunkSizeList)
    {
        Writer.Write(SourceChunkSize);
    }

    foreach (int EncryptedChunkSize in EncryptedChunkSizeList)
    {
        Writer.Write(EncryptedChunkSize);
    }

    Writer.Write((int)EncryptedChunkSizeList.Count);
}
			

Step 2. IStream Implementation.

IStream resides in System.Runtime.InteropServices.ComTypes. So, just specify it:

using System.Runtime.InteropServices.ComTypes;
...
namespace Sample8_PlayEncryptedVideo
{
    class VirtualFileStream : IStream
    {
        private string _EncryptedVideoFilePath;
        private Stream _EncryptedVideoFile;
        private int[] _EncryptedChunkLength;
        private long[] _EncryptedChunkPosition;
        private int[] _SourceChunkLength;
        private int _ChunkCount;
        private byte[] _CurrentChunk = new byte[Program.ChunkSize];
        private long _CurrentChunkIndex = -1;
        private long _Position = 0;
        private long _Length;
        private Object _Lock = new Object();
			

There are three methods that must be implemented: Read, Seek, and Clone.

First of all, let's calculate the original video size and save the offset of each encrypted chunk and the size of the decrypted (i.e. original) chunk. With this data at hand, we will be able to easily find the data in the encrypted chunk that corresponds with the position within the "decrypted" file:

public VirtualFileStream(string EncryptedVideoFilePath)
{
    _EncryptedVideoFilePath = EncryptedVideoFilePath;
    _EncryptedVideoFile = File.Open(EncryptedVideoFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);

    // Read chunk data
    using (Stream EncryptedVideoFileStream = File.Open(EncryptedVideoFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))
    {
        using (BinaryReader Reader = new BinaryReader(EncryptedVideoFileStream))
        {
            _Length = 0;

            EncryptedVideoFileStream.Position = _EncryptedVideoFile.Length - sizeof(int);
            _ChunkCount = Reader.ReadInt32();

            EncryptedVideoFileStream.Position = _EncryptedVideoFile.Length - sizeof(int) - _ChunkCount * sizeof(int) - _ChunkCount * sizeof(int);

            _EncryptedChunkLength = new int[_ChunkCount];
            _SourceChunkLength = new int[_ChunkCount];
            _EncryptedChunkPosition = new long[_ChunkCount];

            for (int i = 0; i < _ChunkCount; i++)
            {
                _SourceChunkLength[i] = Reader.ReadInt32();
                _Length += _SourceChunkLength[i];
            }

            long Offset = 0;

            for (int i = 0; i < _ChunkCount; i++)
            {
                _EncryptedChunkLength[i] = Reader.ReadInt32();
                _EncryptedChunkPosition[i] = Offset;

                Offset += _EncryptedChunkLength[i];
            }
        }
    }
}
			

The Seek method is quite simple, just change position as requested. Notice that it's absolutely fine to have the negative position or the position that points beyond the file:

public void Seek(long dlibMove, int dwOrigin, IntPtr plibNewPosition)
{
    SeekOrigin Origin = (SeekOrigin)dwOrigin;

    // Let's protect _Position: _Position might be changed by Read()
    lock (_Lock)
    {
        switch (Origin)
        {
            case SeekOrigin.Begin:
                {
                    _Position = dlibMove;
                    break;
                }
            case SeekOrigin.Current:
                {
                    _Position += dlibMove;
                    break;
                }
            case SeekOrigin.End:
                {
                    _Position = _Length + dlibMove;
                    break;
                }
        }
    }

    if (IntPtr.Zero != plibNewPosition)
        Marshal.WriteInt64(plibNewPosition, _Position);
}
			

The most important method is Read. This method provides the decrypted data to the caller.

When Read is called, it comes with some position. That position is a position within the decrypted "file". So, with the position at hand, we find the encrypted chunk, decrypt it, and output the decrypted data. In some cases, the requested length is quite large, and we have to provide the decrypted data from a set of chunks. It's also a good idea to cache decrypted data: in cases when a number of calls of Read read data from the same chunk, the code doesn't need to decrypt the data each time:

public void Read(byte[] pv, int cb, IntPtr pcbRead)
{
    int ReadBytes;

    if (_Position < 0 || _Position > _Length)
    {
        ReadBytes = 0;
    }
    else
    {
        // Let's protect _Position: _Position might be changed by another Read() or Seek()
        lock (_Lock)
        {
            int TotalReadBytes = 0;
            int RestBytesToCopy = cb;

            int OffsetInOutput = 0;

            // Let's move chunk by chunk until all requested data is read or end of file is reached
            while (RestBytesToCopy > 0 && _Position < _Length)
            { 
                // Original data is split into chunks, so let's find the chunk number that corresponds
                // with current position
                long RequiredChunkIndex = _Position / Program.ChunkSize;

                // We do cache decrypted data, so let's update the cache if it's not initialized
                // or the cached chunk has another index
                if (-1 == _CurrentChunkIndex || _CurrentChunkIndex != RequiredChunkIndex)
                {
                    _CurrentChunkIndex = RequiredChunkIndex;

                    _EncryptedVideoFile.Position = _EncryptedChunkPosition[_CurrentChunkIndex];

                    byte[] data = new byte[_EncryptedChunkLength[_CurrentChunkIndex]];
                    _EncryptedVideoFile.Read(data, 0, data.Length);

                    _CurrentChunk = Program.Decrypt(data, data.Length);
                }

                // So far, we have the decrypted data available, now let's get the starting point within the chunk
                // and find out how many bytes we can read from the chunk (chunks may have different lengths)
                int OffsetInChunk = (int)(_Position - (_CurrentChunkIndex * Program.ChunkSize));
                int RestInChunk = (int)(_SourceChunkLength[_CurrentChunkIndex] - OffsetInChunk);

                int BytesToCopy;
                if (RestInChunk < RestBytesToCopy)
                    BytesToCopy = RestInChunk;
                else
                    BytesToCopy = RestBytesToCopy;

                // Copy the data...
                Array.Copy(_CurrentChunk, OffsetInChunk, pv, OffsetInOutput, BytesToCopy);

                // ...and move forward
                RestBytesToCopy -= BytesToCopy;
                TotalReadBytes += BytesToCopy;
                OffsetInOutput += BytesToCopy;
                _Position += BytesToCopy;
            }

            ReadBytes = TotalReadBytes;
        }
    }

    if (IntPtr.Zero != pcbRead)
        Marshal.WriteIntPtr(pcbRead, new IntPtr(ReadBytes));
}
			

Step 3. Playing.

Now we are ready to create a virtual file that is based on the VirtualFileStream just implemented and pass its path to the player:

private void buttonPlay_Click(object sender, EventArgs e)
{
    OpenFileDialog dlg = new OpenFileDialog();

    if (dlg.ShowDialog() == DialogResult.OK)
    {
        using (SafeFileHandle fileHandle =
            new SafeFileHandle(
                BoxedAppSDK.NativeMethods.BoxedAppSDK_CreateVirtualFileBasedOnIStream(
                    @"1.avi", // name of the pseudo file
                    BoxedAppSDK.NativeMethods.EFileAccess.GenericWrite,
                    BoxedAppSDK.NativeMethods.EFileShare.Read,
                    IntPtr.Zero,
                    BoxedAppSDK.NativeMethods.ECreationDisposition.New,
                    BoxedAppSDK.NativeMethods.EFileAttributes.Normal,
                    IntPtr.Zero,
                    new VirtualFileStream(dlg.FileName)
                ),
                true
            )
        )
        {
            // We use "using" to close the allocated handle
            // The virtual file will still exist
        }

        axWindowsMediaPlayer1.URL = @"1.avi";
    }
}