// This function may modify trackSamples in a destructive way, to save memory. public static void WriteMkv(string mkvPath, IList<TrackEntry> trackEntries, IMediaDataSource mediaDataSource, ulong maxTrackEndTimeHint, ulong timeScale, bool isDeterministic, byte[] oldMuxState, MuxStateWriter muxStateWriter) { if (trackEntries.Count != mediaDataSource.GetTrackCount()) { throw new Exception("ASSERT: trackEntries vs mediaDataSource length mismatch."); } bool doParseOldMuxState = oldMuxState != null && oldMuxState.Length > 0; FileMode fileMode = doParseOldMuxState ? FileMode.OpenOrCreate : FileMode.Create; using (FileStream fileStream = new FileStream(mkvPath, fileMode)) { ulong oldSize = doParseOldMuxState ? (ulong)fileStream.Length : 0uL; int videoTrackIndex = GetVideoTrackIndex(trackEntries, 0); bool isComplete = false; bool isContinuable = false; ulong lastOutOfs = 0; ulong segmentOffset = 0; // Will be overwritten below. IList<CuePoint> cuePoints = null; ulong minStartTime = 0; ulong timePosition = ulong.MaxValue; byte[] prefix = null; // Well be overwritten below. if (doParseOldMuxState && oldSize > 0) { Console.WriteLine("Trying to use the old mux state to continue downloading."); prefix = new byte[4096]; int prefixSize = fileStream.Read(prefix, 0, prefix.Length); ParsedMuxState parsedMuxState = ParseMuxState( oldMuxState, oldSize, prefix, prefixSize, videoTrackIndex, trackEntries.Count); if (parsedMuxState.isComplete) { Console.WriteLine("The .mkv file is already fully downloaded."); isComplete = true; // TODO: Don't even temporarily modify the .muxstate file. muxStateWriter.WriteRaw(oldMuxState, 0, oldMuxState.Length); } else if (parsedMuxState.isContinuable) { Console.WriteLine("Continuing the .mkv file download."); lastOutOfs = parsedMuxState.lastOutOfs; segmentOffset = parsedMuxState.vS; cuePoints = parsedMuxState.cuePoints; minStartTime = parsedMuxState.vA; timePosition = parsedMuxState.lastC; // We may save memory after this by trucating prefix to durationOffset + 4 -- but we don't care. muxStateWriter.WriteRaw(oldMuxState, 0, parsedMuxState.endOffset); mediaDataSource.StartChunks(new StateChunkStartTimeReceiver( muxStateWriter, parsedMuxState.trackChunkStartTimes)); for (int i = 0; i < trackEntries.Count; ++i) { // Skip downloading most of the chunk files already in the .mkv (up to lastOutOfs). mediaDataSource.ConsumeBlocksUntil(i, parsedMuxState.trackLastStartTimes[i]); } isContinuable = true; } else { Console.WriteLine("Could not use old mux state: " + parsedMuxState); } } if (!isComplete) { fileStream.SetLength((long)lastOutOfs); fileStream.Seek((long)lastOutOfs, 0); if (!isContinuable) { // Not continuing from previous state, writing an .mkv from scratch. // EBML: http://matroska.org/technical/specs/rfc/index.html // http://matroska.org/technical/specs/index.html prefix = GetEbmlHeaderBytes(); segmentOffset = (ulong)prefix.Length + 12; muxStateWriter.WriteUlong('X', MUX_STATE_VERSION); // Unique ID and version number. muxStateWriter.WriteUlong('S', segmentOffset); // About 52. prefix = Utils.CombineBytes(prefix, GetSegmentBytes( /*duration:*/maxTrackEndTimeHint, INITIAL_MEDIA_END_OFFSET_MS, INITIAL_SEEK_HEAD_OFFSET_MS, INITIAL_CUES_OFFSET_MS, timeScale, trackEntries, isDeterministic)); fileStream.Write(prefix, 0, prefix.Length); // Write the MKV header. fileStream.Flush(); muxStateWriter.WriteBytes('H', prefix); // About 405 bytes long. muxStateWriter.WriteUlong('V', (ulong)videoTrackIndex); cuePoints = new List<CuePoint>(); mediaDataSource.StartChunks(new StateChunkStartTimeReceiver( muxStateWriter, new IList<ulong>[trackEntries.Count])); } ulong seekHeadOffsetMS; // Will be set by WriteClustersAndCues below. ulong cuesOffsetMS; // Will be set by WriteClustersAndCues below. WriteClustersAndCues( fileStream, segmentOffset, videoTrackIndex, GetIsAmsCodecs(trackEntries), mediaDataSource, muxStateWriter, cuePoints, ref minStartTime, timePosition, out seekHeadOffsetMS, out cuesOffsetMS); fileStream.Flush(); // Update the MKV header with the file size. ulong mediaEndOffset = (ulong)fileStream.Position; muxStateWriter.WriteUlong('M', mediaEndOffset); // Usually this seek position is 45. ulong maxTrackEndTime = 0; // TODO: mkvmerge calculates this differently (<0.5s -- rounding?) for (int i = 0; i < mediaDataSource.GetTrackCount(); ++i) { ulong trackEndTime = mediaDataSource.GetTrackEndTime(i); if (maxTrackEndTime < trackEndTime) maxTrackEndTime = trackEndTime; } // Update the ID.Segment size and ID.Duration with their final values. int seekOffset = (int)segmentOffset - 7; // We update the final duration and some offsets in the .mkv header so mplayer (and possibly other // media players) will be able to seek in the file without additional tricks. More specifically: // // play-before play-after seek-before seek-after // mplayer yes yes no yes // mplayer -idx yes yes yes yes // mplayer2 yes yes yes yes // mplayer2 -idx yes yes yes yes // VLC 1.0.x no no no no // VLC 1.1.x no no no no // VLC 2.0.x ? yes ? yes // SMPlayer 0.6.9 ? yes ? yes // // Legend: // // * mplayer: MPlayer SVN-r1.0~rc3+svn20090426-4.4.3 on Ubuntu Lucid // * mplayer2: MPlayer2 2.0 from http://ftp.mplayer2.org/pub/release/ , mtime 2011-03-26 // * -idx; The -idx command-line flag of mplayer and mplayer2. // * play: playing the video sequentially from beginning to end // * seek: jumping back and forth within the video upon user keypress (e.g. the <Up> key), // including jumping to regions of the .mkv which haven't been downloaded when playback started // * before: before running UpdatePrefix below, i.e. while the .mkv is being downloaded // * after: after running UpdatePrefix below // // VLC 1.0.x and VLC 1.1.x problems: audio is fine, but the video is jumping back and forth fraction of // a second. int updateOffset = UpdatePrefix( prefix, prefix.Length, segmentOffset, mediaEndOffset - segmentOffset, /*seekHeadOffsetMS:*/seekHeadOffsetMS, /*cuesOffsetMS:*/cuesOffsetMS, /*duration:*/maxTrackEndTime - minStartTime, timeScale); fileStream.Seek(seekOffset, 0); fileStream.Write(prefix, seekOffset, updateOffset - seekOffset); fileStream.Flush(); muxStateWriter.WriteUlong('Z', 1); muxStateWriter.Flush(); } } }
// Calls fileStream.Position and fileStream.Write only. // // Usually trackSamples has 2 elements: a video track and an audio track. // // Uses trackEntries and trackSamples only as a read-only argument, doesn't modify their contents. // // Starts with the initial cue points specified in cuePoints, and appends subsequent cue points in place. private static void WriteClustersAndCues(FileStream fileStream, ulong segmentOffset, int videoTrackIndex, IList<bool> isAmsCodecs, IMediaDataSource mediaDataSource, MuxStateWriter muxStateWriter, IList<CuePoint> cuePoints, ref ulong minStartTime, ulong timePosition, out ulong seekHeadOffsetMS, out ulong cuesOffsetMS) { int trackCount = mediaDataSource.GetTrackCount(); if (isAmsCodecs.Count != trackCount) { throw new Exception("ASSERT: isAmsCodecs vs mediaDataSource length mismatch."); } if (trackCount > 13) { // 13 is because a..m and n..z in MuxStateWriter checkpointing. throw new Exception("Too many tracks to mux."); } // For each track, contains the data bytes of a media sample ungot (i.e. pushed back) after reading. // Initializes items to null (good). MediaDataBlock[] ungetBlocks = new MediaDataBlock[trackCount]; ulong minStartTime0 = minStartTime; if (timePosition == ulong.MaxValue) { timePosition = 0; ulong maxStartTime = ulong.MaxValue; for (int i = 0; i < trackCount; ++i) { if ((ungetBlocks[i] = mediaDataSource.PeekBlock(i)) != null) { if (maxStartTime == ulong.MaxValue || maxStartTime < ungetBlocks[i].StartTime) { maxStartTime = ungetBlocks[i].StartTime; } mediaDataSource.ConsumeBlock(i); // Since it was moved to ungetBlocks[i]. } } for (int i = 0; i < trackCount; ++i) { MediaDataBlock block = mediaDataSource.PeekBlock(i); while (block != null && block.StartTime <= maxStartTime) { ungetBlocks[i] = block; // Takes ownership. mediaDataSource.ConsumeBlock(i); } // We'll start each track (in ungetMediaSample[i]) from the furthest sample within maxStartTime. } int trackIndex2; if ((trackIndex2 = GetNextTrackIndex(mediaDataSource, ungetBlocks)) < 0) { throw new Exception("ASSERT: Empty media file, no samples."); } minStartTime = minStartTime0 = ungetBlocks[trackIndex2] != null ? ungetBlocks[trackIndex2].StartTime : mediaDataSource.PeekBlock(trackIndex2).StartTime; muxStateWriter.WriteUlong('A', minStartTime0); } List<ArraySegment<byte>> output = new List<ArraySegment<byte>>(); ulong[] lastOutputStartTimes = new ulong[trackCount]; // Items initialized to zero. int trackIndex; // timePosition is the beginning StartTime of the last output block written by fileStream.Write. while ((trackIndex = GetNextTrackIndex(mediaDataSource, ungetBlocks)) >= 0) { ulong timeCode; // Will be set below. bool isKeyFrame; // Will be set below. MediaDataBlock block0; // Will be set below. MediaDataBlock block1 = null; // May be set below. int mediaDataBlockTotalSize; // Will be set below. { if ((block0 = ungetBlocks[trackIndex]) == null && (block0 = mediaDataSource.PeekBlock(trackIndex)) == null) { throw new Exception("ASSERT: Reading from a track already at EOF."); } // Some kind of time delta for this sample. timeCode = block0.StartTime - timePosition - minStartTime0; if (block0.StartTime < timePosition + minStartTime0) { throw new Exception("Bad start times: block0.StartTime=" + block0.StartTime + " timePosition=" + timePosition + " minStartTime=" + minStartTime0); } isKeyFrame = block0.IsKeyFrame; mediaDataBlockTotalSize = block0.Bytes.Count; if (ungetBlocks[trackIndex] != null) { ungetBlocks[trackIndex] = null; } else { mediaDataSource.ConsumeBlock(trackIndex); } } if (timeCode > 327670000uL) { throw new Exception("timeCode too large: " + timeCode); // Maybe that's not fatal? } if (isAmsCodecs[trackIndex]) { // Copy one more MediaSample if available. // TODO: Test this. block1 = ungetBlocks[trackIndex]; if (block1 != null) { mediaDataBlockTotalSize += block1.Bytes.Count; ungetBlocks[trackIndex] = null; } else if ((block1 = mediaDataSource.PeekBlock(trackIndex)) != null) { mediaDataBlockTotalSize += block1.Bytes.Count; mediaDataSource.ConsumeBlock(trackIndex); } } // TODO: How can be timeCode so large at this point? if ((output.Count != 0 && trackIndex == videoTrackIndex && isKeyFrame) || timeCode > 327670000uL) { ulong outputOffset = (ulong)fileStream.Position - segmentOffset; cuePoints.Add(new CuePoint(timePosition / 10000uL, (ulong)(videoTrackIndex + 1), outputOffset)); muxStateWriter.WriteUlong('C', timePosition); muxStateWriter.WriteUlong('D', outputOffset); int totalSize = 0; for (int i = 0; i < output.Count; ++i) { totalSize += output[i].Count; } // We do a single copy of the media stream data bytes here. That copy is inevitable, because it's // faster to save to file that way. byte[] bytes = Utils.CombineByteArraysAndArraySegments( new byte[][]{GetDataSizeBytes((ulong)ID.Cluster), GetDataSizeBytes((ulong)totalSize)}, output); output.Clear(); // The average bytes.Length is 286834 bytes here, that's large enough (>8 kB), and it doesn't warrant a // a buffered output stream for speedup. fileStream.Write(bytes, 0, bytes.Length); fileStream.Flush(); for (int i = 0; i < trackCount; ++i) { muxStateWriter.WriteUlong((char)('a' + i), lastOutputStartTimes[i]); } muxStateWriter.WriteUlong('P', (ulong)bytes.Length); muxStateWriter.Flush(); } if (output.Count == 0) { timePosition += timeCode; timeCode = 0uL; output.Add(new ArraySegment<byte>( GetEEBytes(ID.Timecode, GetVintBytes(timePosition / 10000uL)))); } output.Add(new ArraySegment<byte>(GetSimpleBlockBytes( (ulong)(trackIndex + 1), (short)(timeCode / 10000uL), isKeyFrame, isAmsCodecs[trackIndex], mediaDataBlockTotalSize))); output.Add(block0.Bytes); if (block1 != null) output.Add(block1.Bytes); lastOutputStartTimes[trackIndex] = block1 != null ? block1.StartTime : block0.StartTime; } // Write remaining samples (from output to fileStream), and write cuePoints. { ulong outputOffset = (ulong)fileStream.Position - segmentOffset; cuePoints.Add(new CuePoint(timePosition / 10000uL, (ulong)(videoTrackIndex + 1), outputOffset)); muxStateWriter.WriteUlong('C', timePosition); muxStateWriter.WriteUlong('D', outputOffset); if (output.Count == 0) { throw new Exception("ASSERT: Expecting non-empty output at end of mixing."); } int totalSize = 0; for (int i = 0; i < output.Count; ++i) { totalSize += output[i].Count; } byte[] bytes = Utils.CombineByteArraysAndArraySegments( new byte[][]{GetDataSizeBytes((ulong)ID.Cluster), GetDataSizeBytes((ulong)totalSize)}, output); output.Clear(); // Save memory. cuesOffsetMS = outputOffset + (ulong)bytes.Length; byte[] bytes2 = GetCueBytes(cuePoints); // cues are about 1024 bytes per 2 minutes. seekHeadOffsetMS = cuesOffsetMS + (ulong)bytes2.Length; SeekBlock[] seekBlocks = new SeekBlock[cuePoints.Count]; for (int i = 0; i < cuePoints.Count; ++i) { seekBlocks[i] = new SeekBlock(ID.Cluster, cuePoints[i].CueClusterPosition); } byte[] bytes3 = GetSeekBytes(seekBlocks, -1); bytes = Utils.CombineBytes(bytes, bytes2, bytes3); fileStream.Write(bytes, 0, bytes.Length); } }
// Takes ownership of trackChunkStartTimes (and will append to its items). public StateChunkStartTimeReceiver(MuxStateWriter muxStateWriter, IList<ulong>[] trackChunkStartTimes) { this.MuxStateWriter = muxStateWriter; this.TrackChunkStartTimes = trackChunkStartTimes; this.TrackChunkWrittenCounts = new int[trackChunkStartTimes.Length]; // Initializes items to 0. for (int trackIndex = 0; trackIndex < trackChunkStartTimes.Length; ++trackIndex) { IList<ulong> chunkStartTimes = trackChunkStartTimes[trackIndex]; if (chunkStartTimes == null) { trackChunkStartTimes[trackIndex] = chunkStartTimes = new List<ulong>(); } else { int chunkCount = chunkStartTimes.Count; for (int chunkIndex = 1; chunkIndex < chunkCount; ++chunkIndex) { if (chunkStartTimes[chunkIndex - 1] >= chunkStartTimes[chunkIndex]) { throw new Exception(string.Concat(new object[] { "Chunk StartTimes not increasing: track=", trackIndex, " chuunk=", chunkIndex })); } } } this.TrackChunkWrittenCounts[trackIndex] = trackChunkStartTimes[trackIndex].Count; } }
// Exactly one of manifestUri and manifestPath must be set. public static void DownloadAndMux(Uri manifestUri, string manifestPath, string mkvPath, bool isDeterministic, TimeSpan stopAfter, SetupStop setupStop, DisplayDuration displayDuration) { string manifestParentPath = null; // A null indicates a remote manifest file. ManifestInfo manifestInfo; if (manifestPath != null) { manifestParentPath = Path.GetDirectoryName(manifestPath); Console.WriteLine("Parsing local manifest file: " + manifestPath); using (FileStream manifestStream = new FileStream(manifestPath, FileMode.Open)) { manifestInfo = ManifestInfo.ParseManifest(manifestStream, /*manifestUri:*/new Uri(LOCAL_URL_PREFIX)); } } else { Console.WriteLine("Downloading and parsing manifest: " + manifestUri); WebClient webClient = new WebClient(); using (Stream manifestStream = webClient.OpenRead(manifestUri)) { manifestInfo = ManifestInfo.ParseManifest(manifestStream, manifestUri); } } Console.Write(manifestInfo.GetDescription()); IList<Track> tracks = new List<Track>(); foreach (StreamInfo streamInfo in manifestInfo.SelectedStreams) { foreach (TrackInfo trackInfo in streamInfo.SelectedTracks) { tracks.Add(new Track(trackInfo)); } } IList<TrackEntry> trackEntries = new List<TrackEntry>(); IList<IList<MediaSample>> trackSamples = new List<IList<MediaSample>>(); for (int i = 0; i < tracks.Count; ++i) { trackEntries.Add(tracks[i].TrackInfo.TrackEntry); trackEntries[i].TrackNumber = (ulong)(i + 1); trackSamples.Add(new List<MediaSample>()); } for (int i = 0; i < tracks.Count; i++) { // TODO: Add a facility to start live streams from a later chunk (it was chunkIndex=10 previously). // Our design allows for an empty ChunkList, in case live streams are growing. tracks[i].NextStartTime = tracks[i].TrackInfo.Stream.ChunkList.Count == 0 ? 0 : tracks[i].TrackInfo.Stream.ChunkList[0].StartTime; } // TODO: Test for live streams (see the StackOverflow question). Console.WriteLine("Also muxing selected tracks to MKV: " + mkvPath); try { if (Directory.GetParent(mkvPath) != null && !Directory.GetParent(mkvPath).Exists) Directory.GetParent(mkvPath).Create(); } catch (IOException) { // TODO: Add nicer error reporting, without a stack trace. throw new Exception("Cannot not create the directory of .mkv: " + mkvPath); } ulong maxTrackEndTimeHint = manifestInfo.Duration; for (int i = 0; i < tracks.Count; ++i) { IList<ChunkInfo> chunkInfos = tracks[i].TrackInfo.Stream.ChunkList; int j = chunkInfos.Count - 1; if (j >= 0) { // Our design allows for an empty ChunkList. ulong trackDuration = chunkInfos[j].StartTime + chunkInfos[j].Duration; if (maxTrackEndTimeHint < trackDuration) maxTrackEndTimeHint = trackDuration; } } // The .muxstate file is approximately 1/5441.43 of the size of the .mkv. // The .muxstate file is around 28.088 bytes per second. TODO: Update this after n. // Sometimes totalDuration of video is 1156420602, audio is 1156818141 (larger), so we just take the maximum. string muxStatePath = Path.ChangeExtension(mkvPath, "muxstate"); string muxStateOldPath = muxStatePath + ".old"; byte[] oldMuxState = null; if (File.Exists(muxStatePath)) { // False for directories. using (FileStream fileStream = new FileStream(muxStatePath, FileMode.Open)) { oldMuxState = ReadFileStream(fileStream); } if (oldMuxState.Length > 0) { // File.Move fails with IOException if the destination already exists. // C# and .NET SUXX: There is no atomic overwrite-move. try { File.Move(muxStatePath, muxStateOldPath); } catch (IOException) { File.Replace(muxStatePath, muxStateOldPath, null, true); } } } DownloadingMediaDataSource source = new DownloadingMediaDataSource( tracks, manifestParentPath, manifestInfo.TimeScale, manifestInfo.IsLive, (ulong)stopAfter.Ticks, manifestInfo.TotalTicks, displayDuration); setupStop(manifestInfo.IsLive, source); MuxStateWriter muxStateWriter = new MuxStateWriter(new FileStream(muxStatePath, FileMode.Create)); try { MkvUtils.WriteMkv(mkvPath, trackEntries, source, maxTrackEndTimeHint, manifestInfo.TimeScale, isDeterministic, oldMuxState, muxStateWriter); } finally { muxStateWriter.Close(); } File.Delete(muxStatePath); if (File.Exists(muxStateOldPath)) { File.Delete(muxStateOldPath); } }