diff --git a/src/AAXClean/Chunks/ChunkReader.cs b/src/AAXClean/Chunks/ChunkReader.cs index ec1c0d6..9ef4a96 100644 --- a/src/AAXClean/Chunks/ChunkReader.cs +++ b/src/AAXClean/Chunks/ChunkReader.cs @@ -15,12 +15,13 @@ public interface IChunkReader { Task RunAsync(CancellationTokenSource cancellationSource); Action? OnProgressUpdateDelegate { get; set; } - void AddTrack(TrakBox track, FrameFilterBase filter); + void AddTrack(TrakBox track, FrameFilterBase filter, TimeSpan lookback = default); } internal class ChunkReader : IChunkReader { - protected record TrackEntry(uint TrackId, uint Timescale, FrameFilterBase FirstFilter, TrakBox TrakBox); + protected record TrackEntry(uint TrackId, uint Timescale, FrameFilterBase FirstFilter, TrakBox TrakBox, + long DispatchStartSample, long DispatchEndSample); public Action? OnProgressUpdateDelegate { get; set; } protected Dictionary TrackEntries { get; } = new(); @@ -47,10 +48,9 @@ protected virtual IEnumerable EnumerateChunks() bool ChunkHasFrameInRange(ChunkEntry value) { - uint timeScale = GetTrackEntryFromId(value.TrackId).Timescale; - var minimumSample = StartTime.TotalSeconds * timeScale; - var maximumSample = EndTime.TotalSeconds * timeScale; - return value.FirstSample <= maximumSample && (value.FirstSample + value.FrameDurations.Sum(d => d)) >= minimumSample; + var trackEntry = GetTrackEntryFromId(value.TrackId); + return value.FirstSample <= trackEntry.DispatchEndSample + && (value.FirstSample + value.FrameDurations.Sum(d => d)) >= trackEntry.DispatchStartSample; } } @@ -58,9 +58,22 @@ protected TrackEntry GetTrackEntryFromId(uint trackId) => TrackEntries.TryGetValue(trackId, out var trackEntry) ? trackEntry : throw new ArgumentOutOfRangeException(nameof(trackId), $"Track ID {trackId} is not present in this {nameof(ChunkReader)} instance."); - public virtual void AddTrack(TrakBox track, FrameFilterBase filter) + public virtual void AddTrack(TrakBox track, FrameFilterBase filter, TimeSpan lookback = default) { - var trackEntry = new TrackEntry(track.Tkhd.TrackID, track.Mdia.Mdhd.Timescale, filter, track); + uint timescale = track.Mdia.Mdhd.Timescale; + + //StartTime/EndTime are presentation times. A track with an edit list presents + //media starting at the edit's media_time; map the bounds into this track's media + //timeline so preroll before the presentation window isn't mistaken for content. + //The optional lookback starts dispatch early so downstream filters can begin + //output at the sync frame preceding the window (its exact position is only + //knowable post-decrypt, so the reader over-dispatches and the filter trims). + long mediaOffset = track.Edts?.Elst?.SingleEdit?.MediaTime ?? 0; + long start = Math.Max(0, (long)(StartTime.TotalSeconds * timescale) + mediaOffset - (long)(lookback.TotalSeconds * timescale)); + long end = EndTime == TimeSpan.MaxValue ? long.MaxValue + : (long)(EndTime.TotalSeconds * timescale) + mediaOffset; + + var trackEntry = new TrackEntry(track.Tkhd.TrackID, timescale, filter, track, start, end); TrackEntries.Add(track.Tkhd.TrackID, trackEntry); } @@ -114,8 +127,8 @@ private async Task DispatchChunk(ChunkEntry chunk, Memory chunkData, Cance var trackEntry = GetTrackEntryFromId(chunk.TrackId); - long startSample = (long)(StartTime.TotalSeconds * trackEntry.Timescale); - long endSample = (long)(EndTime.TotalSeconds * trackEntry.Timescale); + long startSample = trackEntry.DispatchStartSample; + long endSample = trackEntry.DispatchEndSample; uint frameDelta; for (int start = 0, f = 0; f < chunk.FrameSizes.Length; start += chunk.FrameSizes[f], f++, sampleIndex += frameDelta) diff --git a/src/AAXClean/Chunks/DashChunkReader.cs b/src/AAXClean/Chunks/DashChunkReader.cs index e04a75d..0e2d30f 100644 --- a/src/AAXClean/Chunks/DashChunkReader.cs +++ b/src/AAXClean/Chunks/DashChunkReader.cs @@ -31,11 +31,11 @@ protected override FrameEntry CreateFrameEntry(ChunkEntry chunk, int frameInChun return entry; } - public override void AddTrack(TrakBox track, FrameFilterBase filter) + public override void AddTrack(TrakBox track, FrameFilterBase filter, TimeSpan lookback = default) { if (TrackEntries.Count > 0) throw new InvalidOperationException($"The {nameof(DashChunkReader)} currently only supports a single track."); - base.AddTrack(track, filter); + base.AddTrack(track, filter, lookback); } protected override IEnumerable EnumerateChunks() @@ -43,8 +43,8 @@ protected override IEnumerable EnumerateChunks() //Currently support only a single DASH track var singleTrack = TrackEntries.Values.Single(); - long minimumSample = (long)(StartTime.TotalSeconds * singleTrack.Timescale); - long maximumSample = (long)(EndTime.TotalSeconds * singleTrack.Timescale); + long minimumSample = singleTrack.DispatchStartSample; + long maximumSample = singleTrack.DispatchEndSample; return new DashChunkEntries(InputStream, singleTrack.TrackId, Dash.Sidx, Dash.FirstMoof, Dash.FirstMdat, minimumSample, maximumSample); } diff --git a/src/AAXClean/DashFile.cs b/src/AAXClean/DashFile.cs index 5d3edb8..0cc9cd4 100644 --- a/src/AAXClean/DashFile.cs +++ b/src/AAXClean/DashFile.cs @@ -18,6 +18,10 @@ public class DashFile : Mp4File public override TimeSpan Duration => TimeSpan.FromSeconds((double)Moov.GetChildOrThrow().GetChildOrThrow().FragmentDuration / TimeScale); + //Fragmented sources keep their duration in mvex/mehd and leave mdhd at zero, and they + //never carry an edit list, so the presented duration is simply the fragment duration. + public override TimeSpan PresentedDuration => Duration; + private new MdatBox Mdat => base.Mdat; public TencBox? Tenc { get; } diff --git a/src/AAXClean/FrameFilters/Audio/LosslessFilter.cs b/src/AAXClean/FrameFilters/Audio/LosslessFilter.cs index 79cd429..8437ca5 100644 --- a/src/AAXClean/FrameFilters/Audio/LosslessFilter.cs +++ b/src/AAXClean/FrameFilters/Audio/LosslessFilter.cs @@ -1,4 +1,6 @@ -using System.IO; +using System; +using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; namespace AAXClean.FrameFilters.Audio @@ -12,10 +14,29 @@ internal class LosslessFilter : FrameFinalBase public readonly Mp4aWriter Mp4aWriter; private readonly ChapterQueue ChapterQueue; + private readonly long windowStart; + private readonly long windowEnd; + private readonly bool trimming; + private readonly SyncPrerollQueue preroll = new(); + private bool insideWindow; + private long currentSample; + public LosslessFilter(Stream outputStream, Mp4File mp4Audio, ChapterQueue chapterQueue) + : this(outputStream, mp4Audio, chapterQueue, 0, long.MaxValue) { } + + public LosslessFilter(Stream outputStream, Mp4File mp4Audio, ChapterQueue chapterQueue, + long windowStartSample, long windowEndSample) { Mp4aWriter = new Mp4aWriter(outputStream, mp4Audio.Ftyp, mp4Audio.Moov); ChapterQueue = chapterQueue; + + long mediaDuration = (long)mp4Audio.Moov.AudioTrack.Mdia.Mdhd.Duration; + windowStart = windowStartSample; + windowEnd = Math.Min(windowEndSample, mediaDuration); + //An untrimmed conversion must stay byte-identical to previous releases: no elst, + //no preroll bookkeeping. Trimming exists only when the window excludes media. + trimming = windowStart > 0 || windowEnd < mediaDuration; + insideWindow = !trimming; } protected override Task FlushAsync() @@ -29,6 +50,54 @@ protected override Task FlushAsync() } protected override Task PerformFilteringAsync(FrameEntry input) + { + if (!trimming) + { + WriteFrame(input); + return Task.CompletedTask; + } + + //Exact media position when the reader provides it; the accumulator otherwise. + currentSample = input.StartSample ?? currentSample; + + if (!insideWindow) + { + if (input.Chunk is not null && currentSample + input.SamplesInFrame <= windowStart) + { + //Pre-window frame: remember it (a later frame may need its sync run) but + //write nothing yet. + preroll.Push(input, currentSample, input.IsSyncSample ?? true); + currentSample += input.SamplesInFrame; + return Task.CompletedTask; + } + + //First frame overlapping the window: open the output at the most recent sync + //frame at or before the window start so decoders have a valid entry point, + //and trim playback to the exact window with an edit list. + insideWindow = true; + var partFrames = new List<(FrameEntry frame, long start)>(preroll.Frames) { (input, currentSample) }; + Mp4aWriter.SetEditList( + mediaTime: Math.Max(0, windowStart - partFrames[0].start), + presentedSamples: windowEnd - windowStart); + foreach ((FrameEntry frame, long _) in partFrames) + WriteFrame(frame); + currentSample += input.SamplesInFrame; + return Task.CompletedTask; + } + + if (input.Chunk is not null && currentSample >= windowEnd) + { + //Reader over-dispatch past the window (rounding slop): ignore. + currentSample += input.SamplesInFrame; + return Task.CompletedTask; + } + + WriteFrame(input); + currentSample += input.SamplesInFrame; + return Task.CompletedTask; + } + + private void WriteFrame(FrameEntry input) { var chunkIndex = input.Chunk?.ChunkIndex ?? lastChunkIndex; bool newChunk = chunkIndex > lastChunkIndex; @@ -42,7 +111,6 @@ protected override Task PerformFilteringAsync(FrameEntry input) Mp4aWriter.AddFrame(input.FrameData.Span, newChunk, input.SamplesInFrame, input.IsSyncSample); lastChunkIndex = chunkIndex; - return Task.CompletedTask; } private void CloseWriter() diff --git a/src/AAXClean/FrameFilters/Audio/LosslessMultipartFilter.cs b/src/AAXClean/FrameFilters/Audio/LosslessMultipartFilter.cs index fb7e008..e915c1c 100644 --- a/src/AAXClean/FrameFilters/Audio/LosslessMultipartFilter.cs +++ b/src/AAXClean/FrameFilters/Audio/LosslessMultipartFilter.cs @@ -16,7 +16,9 @@ internal sealed class LosslessMultipartFilter : MultipartFilterBase newFileCallback; public LosslessMultipartFilter(ChapterInfo splitChapters, FtypBox ftyp, MoovBox moov, Action newFileCallback) - : base(splitChapters, (SampleRate)moov.AudioTrack.Mdia.Mdhd.Timescale, moov.AudioTrack.Mdia.Minf.Stbl.Stsd.AudioSampleEntry?.ChannelCount == 2) + : base(splitChapters, (SampleRate)moov.AudioTrack.Mdia.Mdhd.Timescale, + moov.AudioTrack.Mdia.Minf.Stbl.Stsd.AudioSampleEntry?.ChannelCount == 2, + moov.AudioTrack.Edts?.Elst?.SingleEdit?.MediaTime ?? 0) { this.ftyp = ftyp; this.moov = moov; diff --git a/src/AAXClean/FrameFilters/Audio/Mp4aWriter.cs b/src/AAXClean/FrameFilters/Audio/Mp4aWriter.cs index 5a3d79e..2dcf195 100644 --- a/src/AAXClean/FrameFilters/Audio/Mp4aWriter.cs +++ b/src/AAXClean/FrameFilters/Audio/Mp4aWriter.cs @@ -210,6 +210,21 @@ public void Close() //With an edit list, the track and movie durations are the presented duration. Moov.AudioTrack.Tkhd.Duration = segmentDuration; Moov.Mvhd.Duration = segmentDuration; + + if (Moov.TextTrack is not null) + { + //Chapter samples are written on the presentation timeline (SetDuration assumed + //media == presentation, which trimming breaks): give the text track the presented + //durations and a matching identity edit so both tracks present the same window. + Moov.TextTrack.Mdia.Mdhd.Duration = (ulong)presentedSamples; + Moov.TextTrack.Tkhd.Duration = segmentDuration; + + EdtsBox textEdts = Moov.TextTrack.Edts ?? EdtsBox.CreateBlank(Moov.TextTrack); + ElstBox textElst = textEdts.Elst ?? ElstBox.CreateBlank(textEdts); + textElst.Entries.Clear(); + textElst.Entries.Add(new ElstBox.EditEntry(segmentDuration, 0)); + textElst.UpdateVersion(); + } } (uint maxBitRate, uint avgBitrate) diff --git a/src/AAXClean/FrameFilters/Audio/MultipartFilterBase.cs b/src/AAXClean/FrameFilters/Audio/MultipartFilterBase.cs index 6426be0..9e4e544 100644 --- a/src/AAXClean/FrameFilters/Audio/MultipartFilterBase.cs +++ b/src/AAXClean/FrameFilters/Audio/MultipartFilterBase.cs @@ -17,13 +17,9 @@ public abstract class MultipartFilterBase : FrameFinalBase prerollQueue = new(); - private const int MaxPrerollFrames = 4096; + private readonly SyncPrerollQueue prerollQueue = new(); /// /// When true, each new part begins at the most recent sync frame at or before the @@ -45,13 +41,14 @@ public abstract class MultipartFilterBase : FrameFinalBase protected virtual void OnPartOpened(long editMediaTime, long presentedSamples) { } - public MultipartFilterBase(ChapterInfo splitChapters, SampleRate inputSampleRate, bool inputStereo) + public MultipartFilterBase(ChapterInfo splitChapters, SampleRate inputSampleRate, bool inputStereo, long mediaTimeOffset = 0) { if (splitChapters is null || splitChapters.Count == 0) throw new ArgumentException($"{nameof(splitChapters)} must contain at least one chapter."); InputSampleRate = inputSampleRate; InputStereo = inputStereo; + this.mediaTimeOffset = mediaTimeOffset; startSample = currentSample = timeToSample(splitChapters.StartOffset); this.splitChapters = splitChapters.GetEnumerator(); } @@ -81,10 +78,28 @@ protected override Task PerformFilteringAsync(TInput input) if (currentSample > endSample) { CloseCurrentWriter(); + writerOpen = false; - if (GetNextChapter()) + if (!GetNextChapter()) + { + //No more chapters: nothing past this point is written, and the sentinels + //keep both the re-fire and the deferred open below permanently false. + startSample = endSample = long.MaxValue; + } + } + + if (!writerOpen) + { + //The chapter window may begin after the current frame (the reader dispatches + //early: sync-frame lookback, or an edit-list input whose window starts + //mid-media). Open the part only at the first frame that overlaps the window, + //so its media is the contiguous run from the preroll's sync frame — opening + //eagerly would write the sync frame, then drop the pre-window frames after + //it, leaving a hole in the part's bitstream. + if (currentSample + input.SamplesInFrame > startSample) { CreateNewWriter(TCallback.Create(splitChapters.Current)); + writerOpen = true; //The preroll queue holds the frames since (and including) the most //recent sync frame, all of which start at or before the chapter @@ -92,7 +107,8 @@ protected override Task PerformFilteringAsync(TInput input) //point; the current frame follows them. var partFrames = new List<(TInput frame, long start)>(); if (StartPartAtSyncFrame) - partFrames.AddRange(prerollQueue); + foreach ((FrameEntry frame, long start) in prerollQueue.Frames) + partFrames.Add(((TInput)frame, start)); partFrames.Add((input, currentSample)); OnPartOpened(editMediaTime: Math.Max(0, startSample - partFrames[0].start), @@ -118,11 +134,7 @@ protected override Task PerformFilteringAsync(TInput input) WriteFrameToFile(input, newChunk); } - if (IsSyncFrame(input)) - prerollQueue.Clear(); - if (prerollQueue.Count == MaxPrerollFrames) - prerollQueue.Dequeue(); - prerollQueue.Enqueue((input, currentSample)); + prerollQueue.Push(input, currentSample, IsSyncFrame(input)); currentSample += input.SamplesInFrame; @@ -140,7 +152,10 @@ private bool GetNextChapter() return true; } - private long timeToSample(TimeSpan time) => (long)Math.Round(time.TotalSeconds * (int)InputSampleRate); + //Chapter offsets are presentation times; frame positions are media times. The offset + //is the input edit list's media_time (0 without one). + private readonly long mediaTimeOffset; + private long timeToSample(TimeSpan time) => (long)Math.Round(time.TotalSeconds * (int)InputSampleRate) + mediaTimeOffset; protected override void Dispose(bool disposing) { diff --git a/src/AAXClean/FrameFilters/Audio/SyncPrerollQueue.cs b/src/AAXClean/FrameFilters/Audio/SyncPrerollQueue.cs new file mode 100644 index 0000000..0e20564 --- /dev/null +++ b/src/AAXClean/FrameFilters/Audio/SyncPrerollQueue.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace AAXClean.FrameFilters.Audio +{ + /// + /// The frames since (and including) the most recent sync frame, oldest first, with + /// each frame's exact media start position — so output can begin at an independently + /// decodable entry point at or before a requested media position. Bounded: sync + /// frames occur about once per second in every supported codec, and for codecs where + /// every frame is sync the queue holds exactly one frame. + /// + public sealed class SyncPrerollQueue + { + private readonly Queue<(FrameEntry frame, long start)> queue = new(); + private const int MaxFrames = 4096; + + public IReadOnlyCollection<(FrameEntry frame, long start)> Frames => queue; + + /// Record a processed frame. A sync frame restarts the preroll at itself. + public void Push(FrameEntry frame, long startSample, bool isSync) + { + if (isSync) + queue.Clear(); + if (queue.Count == MaxFrames) + queue.Dequeue(); + queue.Enqueue((frame, startSample)); + } + } +} diff --git a/src/AAXClean/Mp4File.cs b/src/AAXClean/Mp4File.cs index aa4a7f9..e61bf14 100644 --- a/src/AAXClean/Mp4File.cs +++ b/src/AAXClean/Mp4File.cs @@ -79,6 +79,11 @@ internal bool AudioTrackIsUsac => Moov.AudioTrack.Mdia.Minf.Stbl.Stsd.AudioSampleEntry? .Esds?.ES_Descriptor.DecoderConfig.AudioSpecificConfig.AudioObjectType == 42; + //Audible USAC flags ~2 sync frames per 40 (~0.93 s apart), so 2 s of lookback always + //contains at least one decode entry point. All-sync codecs need none. Applied to the + //audio track by ProcessAudio; harmless at presentation start 0 (AddTrack clamps). + internal TimeSpan AudioLookback => AudioTrackIsUsac ? TimeSpan.FromSeconds(2) : TimeSpan.Zero; + public static Mp4Operation RelocateMoovAsync(string mp4FilePath) { ProgressTracker tracker = new(); @@ -116,8 +121,19 @@ public Mp4Operation ConvertToMp4aAsync(Stream outputStream, ChapterInfo? userCha chapterQueue.AddRange(userChapters); } + //The requested window in media samples: presentation times mapped through the + //input's edit list (same rounding as the multipart path). For an untrimmed + //elst-free source this is exactly (0, media duration) and no trimming occurs; + //for an elst input with no user chapters it is the input's own window, so a + //re-remux round-trips the presentation. + uint mdhdTimescale = Moov.AudioTrack.Mdia.Mdhd.Timescale; + long windowStart = PresentationStartSample + (long)Math.Round(start.TotalSeconds * mdhdTimescale); + long windowEnd = end == TimeSpan.MaxValue + ? PresentationStartSample + PresentedDurationSamples + : PresentationStartSample + (long)Math.Round(end.TotalSeconds * mdhdTimescale); + FrameTransformBase filter1 = GetAudioFrameFilter(); - LosslessFilter filter2 = new(outputStream, this, chapterQueue); + LosslessFilter filter2 = new(outputStream, this, chapterQueue, windowStart, windowEnd); filter1.LinkTo(filter2); if (Moov.TextTrack is not null && userChapters is null) @@ -195,10 +211,10 @@ protected virtual IChunkReader CreateChunkReader(Stream inputStream, TimeSpan st private static TimeSpan Min(TimeSpan t1, TimeSpan t2) => t1 > t2 ? t2 : t1; public virtual Mp4Operation ProcessAudio(TimeSpan startTime, TimeSpan endTime, Action continuation, params (TrakBox track, FrameFilterBase filter)[] filters) { - IChunkReader reader = CreateChunkReader(InputStream, startTime, Min(Duration, endTime)); + IChunkReader reader = CreateChunkReader(InputStream, startTime, Min(PresentedDuration, endTime)); foreach ((TrakBox track, FrameFilterBase filter) in filters) - reader.AddTrack(track, filter); + reader.AddTrack(track, filter, track == Moov.AudioTrack ? AudioLookback : default); var operation = new Mp4Operation(reader.RunAsync, this, continuation); reader.OnProgressUpdateDelegate = operation.OnProgressUpdate; @@ -207,10 +223,10 @@ public virtual Mp4Operation ProcessAudio(TimeSpan startTime, TimeSpan endTime, A public Mp4Operation ProcessAudio(TimeSpan startTime, TimeSpan endTime, Func continuation, params (TrakBox track, FrameFilterBase filter)[] filters) { - IChunkReader reader = CreateChunkReader(InputStream, startTime, Min(Duration, endTime)); + IChunkReader reader = CreateChunkReader(InputStream, startTime, Min(PresentedDuration, endTime)); foreach ((TrakBox track, FrameFilterBase filter) in filters) - reader.AddTrack(track, filter); + reader.AddTrack(track, filter, track == Moov.AudioTrack ? AudioLookback : default); var operation = new Mp4Operation(reader.RunAsync, this, continuation); reader.OnProgressUpdateDelegate = operation.OnProgressUpdate; diff --git a/src/Mpeg4Lib/Boxes/ElstBox.cs b/src/Mpeg4Lib/Boxes/ElstBox.cs index 7ed8788..8897168 100644 --- a/src/Mpeg4Lib/Boxes/ElstBox.cs +++ b/src/Mpeg4Lib/Boxes/ElstBox.cs @@ -16,6 +16,20 @@ public class ElstBox : FullBox public List Entries { get; } = new List(); + /// + /// The single non-empty rate-1 edit — the only edit-list form this library writes and + /// honors: is the presentation start within the media + /// (media timescale) and the presented duration + /// (movie timescale). Null when the list is empty, has multiple entries, an empty edit + /// (media_time -1), or a non-unity rate; callers treat those as "no edit list". + /// + public EditEntry? SingleEdit + => Entries.Count == 1 + && Entries[0].MediaTime >= 0 + && Entries[0].MediaRateInteger == 1 + && Entries[0].MediaRateFraction == 0 + ? Entries[0] : null; + public static ElstBox CreateBlank(IBox parent) { int size = 4 + 12 /* empty FullBox size*/; diff --git a/src/Mpeg4Lib/Mpeg4File.cs b/src/Mpeg4Lib/Mpeg4File.cs index f052c88..d8b8daf 100644 --- a/src/Mpeg4Lib/Mpeg4File.cs +++ b/src/Mpeg4Lib/Mpeg4File.cs @@ -21,6 +21,29 @@ public class Mpeg4File : IDisposable private readonly Lazy lazyMetadataItems; public virtual TimeSpan Duration => TimeSpan.FromSeconds((double)Moov.AudioTrack.Mdia.Mdhd.Duration / TimeScale); + + /// + /// Start of the presentation window within the audio media, in media (mdhd) timescale + /// units. Non-zero only for inputs carrying the single-edit elst form this library + /// writes (e.g. its own chapter-split parts), whose media begins with sync-frame + /// preroll before the presented window. + /// + public long PresentationStartSample + => Moov.AudioTrack.Edts?.Elst?.SingleEdit?.MediaTime ?? 0; + + /// + /// Presented duration of the audio track in media (mdhd) timescale units: the elst + /// segment_duration converted from movie (mvhd) timescale, or the full media duration + /// when there is no edit list. + /// + public long PresentedDurationSamples + => Moov.AudioTrack.Edts?.Elst?.SingleEdit is ElstBox.EditEntry edit + ? (long)Math.Round((decimal)edit.SegmentDuration * Moov.AudioTrack.Mdia.Mdhd.Timescale / Moov.Mvhd.Timescale) + : (long)Moov.AudioTrack.Mdia.Mdhd.Duration; + + /// Presented duration of the audio track ( as time). + public virtual TimeSpan PresentedDuration + => TimeSpan.FromSeconds((double)PresentedDurationSamples / Moov.AudioTrack.Mdia.Mdhd.Timescale); public int MaxBitrate => (int)(AudioSampleEntry.Esds?.ES_Descriptor.DecoderConfig.MaxBitrate ?? 0); public AudioSampleEntry AudioSampleEntry { get; } public List TopLevelBoxes { get; } diff --git a/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/ElstBoxTests.cs b/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/ElstBoxTests.cs index 12f3b05..0232c77 100644 --- a/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/ElstBoxTests.cs +++ b/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/ElstBoxTests.cs @@ -85,6 +85,34 @@ public void CreateBlank_LargeValues_UseVersion1AndRoundTrip() Assert.AreEqual(12345L, entry.MediaTime); } + private static ElstBox CreateElst(params ElstBox.EditEntry[] entries) + { + var elst = BoxFactory.CreateBox(new MemoryStream(MakeBox("elst", UInt32sBE(0, 0))), parent: null); + foreach (var e in entries) + elst.Entries.Add(e); + return elst; + } + + [TestMethod] + public void SingleEdit_ReturnsEntry_ForSingleNonEmptyRate1Edit() + { + var elst = CreateElst(new ElstBox.EditEntry(SegmentDuration: 1000, MediaTime: 448)); + Assert.IsNotNull(elst.SingleEdit); + Assert.AreEqual(448L, elst.SingleEdit.Value.MediaTime); + Assert.AreEqual(1000ul, elst.SingleEdit.Value.SegmentDuration); + } + + [TestMethod] + public void SingleEdit_IsNull_ForEmptyEditOrMultipleEntriesOrRate() + { + Assert.IsNull(CreateElst().SingleEdit); //no entries + Assert.IsNull(CreateElst(new ElstBox.EditEntry(1000, -1)).SingleEdit); //empty edit + Assert.IsNull(CreateElst(new ElstBox.EditEntry(1000, 0, MediaRateInteger: 0)).SingleEdit); + Assert.IsNull(CreateElst(new ElstBox.EditEntry(1000, 0, MediaRateFraction: 1)).SingleEdit); + Assert.IsNull(CreateElst( + new ElstBox.EditEntry(1000, 0), new ElstBox.EditEntry(1000, 5000)).SingleEdit); //two entries + } + [TestMethod] public void UpdateVersion_SmallValues_StaysVersion0() { diff --git a/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/Mpeg4Lib.Test.csproj b/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/Mpeg4Lib.Test.csproj index 577117c..aa59de6 100644 --- a/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/Mpeg4Lib.Test.csproj +++ b/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/Mpeg4Lib.Test.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/PrerollQueueTests.cs b/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/PrerollQueueTests.cs new file mode 100644 index 0000000..e99d77d --- /dev/null +++ b/tests/Mpeg4Lib.Test/Mpeg4Lib.Test/PrerollQueueTests.cs @@ -0,0 +1,43 @@ +using AAXClean.FrameFilters; +using AAXClean.FrameFilters.Audio; + +namespace Mpeg4Lib.Test; + +[TestClass] +public class PrerollQueueTests +{ + private static FrameEntry Frame() => new() { SamplesInFrame = 1024, FrameData = new byte[1] }; + + [TestMethod] + public void Queue_HoldsFramesSinceMostRecentSync_InclusiveOldestFirst() + { + SyncPrerollQueue q = new(); + FrameEntry sync1 = Frame(), dep1 = Frame(), sync2 = Frame(), dep2 = Frame(); + q.Push(sync1, 0, isSync: true); + q.Push(dep1, 1024, isSync: false); + q.Push(sync2, 2048, isSync: true); //restarts the preroll at itself + q.Push(dep2, 3072, isSync: false); + CollectionAssert.AreEqual(new[] { sync2, dep2 }, q.Frames.Select(f => f.frame).ToArray()); + Assert.AreEqual(2048L, q.Frames.First().start); + } + + [TestMethod] + public void Queue_AllSyncCodec_HoldsExactlyTheLastFrame() + { + SyncPrerollQueue q = new(); + for (int i = 0; i < 5; i++) + q.Push(Frame(), i * 1024, isSync: true); + Assert.HasCount(1, q.Frames); + Assert.AreEqual(4096L, q.Frames.First().start); + } + + [TestMethod] + public void Queue_IsBounded() + { + SyncPrerollQueue q = new(); + q.Push(Frame(), 0, isSync: true); + for (int i = 1; i <= 5000; i++) + q.Push(Frame(), i * 1024L, isSync: false); + Assert.HasCount(4096, q.Frames); + } +}