Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions src/AAXClean/Chunks/ChunkReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ public interface IChunkReader
{
Task RunAsync(CancellationTokenSource cancellationSource);
Action<ConversionProgressEventArgs>? OnProgressUpdateDelegate { get; set; }
void AddTrack(TrakBox track, FrameFilterBase<FrameEntry> filter);
void AddTrack(TrakBox track, FrameFilterBase<FrameEntry> filter, TimeSpan lookback = default);
}

internal class ChunkReader : IChunkReader
{
protected record TrackEntry(uint TrackId, uint Timescale, FrameFilterBase<FrameEntry> FirstFilter, TrakBox TrakBox);
protected record TrackEntry(uint TrackId, uint Timescale, FrameFilterBase<FrameEntry> FirstFilter, TrakBox TrakBox,
long DispatchStartSample, long DispatchEndSample);

public Action<ConversionProgressEventArgs>? OnProgressUpdateDelegate { get; set; }
protected Dictionary<uint, TrackEntry> TrackEntries { get; } = new();
Expand All @@ -47,20 +48,32 @@ protected virtual IEnumerable<ChunkEntry> 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;
}
}

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<FrameEntry> filter)
public virtual void AddTrack(TrakBox track, FrameFilterBase<FrameEntry> 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);
}

Expand Down Expand Up @@ -114,8 +127,8 @@ private async Task DispatchChunk(ChunkEntry chunk, Memory<byte> 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)
Expand Down
8 changes: 4 additions & 4 deletions src/AAXClean/Chunks/DashChunkReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,20 @@ protected override FrameEntry CreateFrameEntry(ChunkEntry chunk, int frameInChun
return entry;
}

public override void AddTrack(TrakBox track, FrameFilterBase<FrameEntry> filter)
public override void AddTrack(TrakBox track, FrameFilterBase<FrameEntry> 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<ChunkEntry> 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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/AAXClean/DashFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public class DashFile : Mp4File

public override TimeSpan Duration => TimeSpan.FromSeconds((double)Moov.GetChildOrThrow<MvexBox>().GetChildOrThrow<MehdBox>().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; }
Expand Down
72 changes: 70 additions & 2 deletions src/AAXClean/FrameFilters/Audio/LosslessFilter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.IO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;

namespace AAXClean.FrameFilters.Audio
Expand All @@ -12,10 +14,29 @@ internal class LosslessFilter : FrameFinalBase<FrameEntry>
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()
Expand All @@ -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;
Expand All @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion src/AAXClean/FrameFilters/Audio/LosslessMultipartFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ internal sealed class LosslessMultipartFilter : MultipartFilterBase<FrameEntry,
private readonly Action<NewSplitCallback> newFileCallback;

public LosslessMultipartFilter(ChapterInfo splitChapters, FtypBox ftyp, MoovBox moov, Action<NewSplitCallback> 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;
Expand Down
15 changes: 15 additions & 0 deletions src/AAXClean/FrameFilters/Audio/Mp4aWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 30 additions & 15 deletions src/AAXClean/FrameFilters/Audio/MultipartFilterBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,9 @@ public abstract class MultipartFilterBase<TInput, TCallback> : FrameFinalBase<TI
private long endSample = -1;
private long lastChunkIndex = -1;
private long currentSample;
private bool writerOpen;

//Frames since (and including) the most recent sync frame, oldest first, with each
//frame's exact media start 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.
private readonly Queue<(TInput frame, long start)> prerollQueue = new();
private const int MaxPrerollFrames = 4096;
private readonly SyncPrerollQueue prerollQueue = new();

/// <summary>
/// When true, each new part begins at the most recent sync frame at or before the
Expand All @@ -45,13 +41,14 @@ public abstract class MultipartFilterBase<TInput, TCallback> : FrameFinalBase<TI
/// </summary>
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();
}
Expand Down Expand Up @@ -81,18 +78,37 @@ 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
//boundary. Starting the part there gives decoders a valid entry
//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),
Expand All @@ -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;

Expand All @@ -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)
{
Expand Down
29 changes: 29 additions & 0 deletions src/AAXClean/FrameFilters/Audio/SyncPrerollQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Collections.Generic;

namespace AAXClean.FrameFilters.Audio
{
/// <summary>
/// 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.
/// </summary>
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;

/// <summary>Record a processed frame. A sync frame restarts the preroll at itself.</summary>
public void Push(FrameEntry frame, long startSample, bool isSync)
{
if (isSync)
queue.Clear();
if (queue.Count == MaxFrames)
queue.Dequeue();
queue.Enqueue((frame, startSample));
}
}
}
Loading
Loading