using System.Buffers.Binary;
using System.Text;
namespace Titano.Video;
public enum VideoCodec
{
H264,
Hevc,
}
///
/// Multiplexer ISO Base Media File Format (MP4) scritto interamente in-house.
///
/// Struttura generata: ftyp → mdat (in streaming, con dimensione a 64 bit corretta a
/// posteriori) → moov. I campioni vengono scritti sul file di uscita mentre arrivano
/// dall'encoder: nessun file temporaneo, nessuna copia intermedia dell'intero flusso.
///
/// Il muxer accetta bitstream in formato Annex-B (quello prodotto dagli encoder di sistema),
/// ne estrae i parameter set per la configurazione del codec e converte le NAL nel formato
/// a lunghezza prefissata richiesto dal contenitore.
///
public sealed class Mp4Muxer : IDisposable
{
private readonly Stream _output;
private readonly VideoCodec _codec;
private readonly int _width;
private readonly int _height;
private readonly uint _timescale;
private readonly List _sampleSizes = [];
private readonly List _sampleDurations = [];
private readonly List _syncSamples = [];
private readonly List _vps = [];
private readonly List _sps = [];
private readonly List _pps = [];
private long _mdatHeaderPosition;
private long _mdatPayloadStart;
private long _mediaDuration;
private bool _finished;
///
/// Buffer di lavoro riusato per la conversione Annex-B → lunghezza prefissata.
/// Parte piccolo di proposito: così il percorso di crescita viene esercitato da
/// qualunque sequenza, verifica sintetica compresa, invece di restare latente
/// fino al primo fotogramma chiave di una ripresa ad alta risoluzione.
///
private byte[] _scratch = new byte[64 * 1024];
public int SampleCount => _sampleSizes.Count;
public long BytesWritten { get; private set; }
public bool HasParameterSets => _sps.Count > 0;
public Mp4Muxer(Stream output, VideoCodec codec, int width, int height, uint timescale)
{
if (!output.CanSeek) throw new ArgumentException("Il flusso di uscita deve essere posizionabile.", nameof(output));
_output = output;
_codec = codec;
_width = width;
_height = height;
_timescale = timescale == 0 ? 90000u : timescale;
WriteFileTypeBox();
BeginMediaData();
}
///
/// Registra parameter set forniti fuori banda dall'encoder (blob MF_MT_MPEG_SEQUENCE_HEADER).
///
public void AddParameterSets(ReadOnlySpan annexB)
{
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
{
ClassifyAndStore(annexB, nal);
}
}
///
/// Scrive un campione codificato. è espressa nella timescale
/// della traccia, così ogni fotogramma può avere durata propria (playback adattivo).
///
public void WriteSample(ReadOnlySpan annexB, uint duration)
{
ObjectDisposedException.ThrowIf(_finished, this);
int written = 0;
bool keyframe = false;
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
{
var payload = annexB.Slice(nal.Start, nal.Length);
if (payload.Length == 0) continue;
if (IsParameterSet(payload[0]))
{
ClassifyAndStore(annexB, nal);
continue; // i parameter set vivono in stsd, non in mdat
}
if (IsKeyframeNal(payload[0])) keyframe = true;
EnsureScratch(written + payload.Length + 4);
BinaryPrimitives.WriteUInt32BigEndian(_scratch.AsSpan(written), (uint)payload.Length);
payload.CopyTo(_scratch.AsSpan(written + 4));
written += payload.Length + 4;
}
if (written == 0) return;
_output.Write(_scratch, 0, written);
BytesWritten += written;
if (keyframe) _syncSamples.Add(_sampleSizes.Count + 1); // gli indici in stss partono da 1
_sampleSizes.Add((uint)written);
_sampleDurations.Add(duration);
_mediaDuration += duration;
}
/// Chiude mdat, scrive moov e finalizza il file.
public void Finish()
{
if (_finished) return;
_finished = true;
long mdatEnd = _output.Position;
long mdatSize = mdatEnd - _mdatHeaderPosition;
// Correzione della dimensione a 64 bit riservata all'apertura del box.
_output.Position = _mdatHeaderPosition + 8;
Span size = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(size, mdatSize);
_output.Write(size);
_output.Position = mdatEnd;
WriteMovieBox();
_output.Flush();
}
public void Dispose() => Finish();
// ------------------------------------------------------------------ box di apertura
private void WriteFileTypeBox()
{
using var box = new BoxWriter(_output, "ftyp");
box.WriteFourCc("isom");
box.WriteUInt32(0x200);
box.WriteFourCc("isom");
box.WriteFourCc("iso2");
box.WriteFourCc(_codec == VideoCodec.H264 ? "avc1" : "hvc1");
box.WriteFourCc("mp41");
}
private void BeginMediaData()
{
_mdatHeaderPosition = _output.Position;
Span header = stackalloc byte[16];
BinaryPrimitives.WriteUInt32BigEndian(header, 1); // size = 1 → largesize a 64 bit
Encoding.ASCII.GetBytes("mdat", header[4..]);
BinaryPrimitives.WriteInt64BigEndian(header[8..], 16); // valore provvisorio
_output.Write(header);
_mdatPayloadStart = _output.Position;
}
// ------------------------------------------------------------------ moov
private void WriteMovieBox()
{
uint movieTimescale = 1000;
long movieDuration = _timescale == 0 ? 0 : _mediaDuration * movieTimescale / _timescale;
using var moov = new BoxWriter(_output, "moov");
using (var mvhd = moov.Child("mvhd"))
{
mvhd.WriteFullBoxHeader(0, 0);
mvhd.WriteUInt32(0); // creation_time
mvhd.WriteUInt32(0); // modification_time
mvhd.WriteUInt32(movieTimescale);
mvhd.WriteUInt32((uint)movieDuration);
mvhd.WriteUInt32(0x00010000); // rate 1.0
mvhd.WriteUInt16(0x0100); // volume 1.0
mvhd.WriteUInt16(0); // reserved
mvhd.WriteUInt32(0);
mvhd.WriteUInt32(0);
mvhd.WriteMatrix();
for (int i = 0; i < 6; i++) mvhd.WriteUInt32(0); // pre_defined
mvhd.WriteUInt32(2); // next_track_ID
}
using (var trak = moov.Child("trak"))
{
using (var tkhd = trak.Child("tkhd"))
{
tkhd.WriteFullBoxHeader(0, 0x000007); // enabled | in movie | in preview
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(1); // track_ID
tkhd.WriteUInt32(0); // reserved
tkhd.WriteUInt32((uint)movieDuration);
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(0);
tkhd.WriteUInt16(0); // layer
tkhd.WriteUInt16(0); // alternate_group
tkhd.WriteUInt16(0); // volume (0 per il video)
tkhd.WriteUInt16(0);
tkhd.WriteMatrix();
tkhd.WriteUInt32((uint)_width << 16);
tkhd.WriteUInt32((uint)_height << 16);
}
using var mdia = trak.Child("mdia");
using (var mdhd = mdia.Child("mdhd"))
{
mdhd.WriteFullBoxHeader(0, 0);
mdhd.WriteUInt32(0);
mdhd.WriteUInt32(0);
mdhd.WriteUInt32(_timescale);
mdhd.WriteUInt32((uint)_mediaDuration);
mdhd.WriteUInt16(0x55C4); // lingua "und" impacchettata a 5 bit
mdhd.WriteUInt16(0);
}
using (var hdlr = mdia.Child("hdlr"))
{
hdlr.WriteFullBoxHeader(0, 0);
hdlr.WriteUInt32(0); // pre_defined
hdlr.WriteFourCc("vide");
hdlr.WriteUInt32(0);
hdlr.WriteUInt32(0);
hdlr.WriteUInt32(0);
hdlr.WriteCString("Titano Video Handler");
}
using var minf = mdia.Child("minf");
using (var vmhd = minf.Child("vmhd"))
{
vmhd.WriteFullBoxHeader(0, 1);
vmhd.WriteUInt16(0); // graphicsmode
vmhd.WriteUInt16(0); // opcolor
vmhd.WriteUInt16(0);
vmhd.WriteUInt16(0);
}
using (var dinf = minf.Child("dinf"))
using (var dref = dinf.Child("dref"))
{
dref.WriteFullBoxHeader(0, 0);
dref.WriteUInt32(1); // entry_count
using var url = dref.Child("url ");
url.WriteFullBoxHeader(0, 1); // flag 1 = dati nello stesso file
}
using var stbl = minf.Child("stbl");
WriteSampleDescription(stbl);
WriteTimeToSample(stbl);
WriteSyncSamples(stbl);
WriteSampleToChunk(stbl);
WriteSampleSizes(stbl);
WriteChunkOffsets(stbl);
}
}
private void WriteSampleDescription(BoxWriter stbl)
{
using var stsd = stbl.Child("stsd");
stsd.WriteFullBoxHeader(0, 0);
stsd.WriteUInt32(1); // entry_count
string entryName = _codec == VideoCodec.H264 ? "avc1" : "hvc1";
using var entry = stsd.Child(entryName);
for (int i = 0; i < 6; i++) entry.WriteByte(0); // reserved
entry.WriteUInt16(1); // data_reference_index
entry.WriteUInt16(0); // pre_defined
entry.WriteUInt16(0); // reserved
for (int i = 0; i < 3; i++) entry.WriteUInt32(0);
entry.WriteUInt16((ushort)_width);
entry.WriteUInt16((ushort)_height);
entry.WriteUInt32(0x00480000); // 72 dpi orizzontali
entry.WriteUInt32(0x00480000); // 72 dpi verticali
entry.WriteUInt32(0); // reserved
entry.WriteUInt16(1); // frame_count
entry.WritePascalString32("Titano"); // compressorname
entry.WriteUInt16(0x0018); // profondità 24 bit
entry.WriteUInt16(0xFFFF); // pre_defined = -1
if (_codec == VideoCodec.H264) WriteAvcConfiguration(entry);
else WriteHevcConfiguration(entry);
using (var colr = entry.Child("colr"))
{
colr.WriteFourCc("nclx");
colr.WriteUInt16(1); // primarie BT.709
colr.WriteUInt16(1); // funzione di trasferimento BT.709
colr.WriteUInt16(1); // matrice BT.709
colr.WriteByte(0); // range televisivo (16-235)
}
using var pasp = entry.Child("pasp");
pasp.WriteUInt32(1); // pixel quadrati
pasp.WriteUInt32(1);
}
private void WriteAvcConfiguration(BoxWriter entry)
{
using var avcc = entry.Child("avcC");
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
avcc.WriteByte(1); // configurationVersion
avcc.WriteByte(sps.Length > 1 ? sps[1] : (byte)0x64); // AVCProfileIndication
avcc.WriteByte(sps.Length > 2 ? sps[2] : (byte)0x00); // profile_compatibility
avcc.WriteByte(sps.Length > 3 ? sps[3] : (byte)0x28); // AVCLevelIndication
avcc.WriteByte(0xFF); // 6 bit riservati + lengthSizeMinusOne = 3
avcc.WriteByte((byte)(0xE0 | Math.Min(_sps.Count, 31))); // 3 bit riservati + numOfSPS
foreach (var set in _sps)
{
avcc.WriteUInt16((ushort)set.Length);
avcc.WriteBytes(set);
}
avcc.WriteByte((byte)Math.Min(_pps.Count, 255));
foreach (var set in _pps)
{
avcc.WriteUInt16((ushort)set.Length);
avcc.WriteBytes(set);
}
}
private void WriteHevcConfiguration(BoxWriter entry)
{
using var hvcc = entry.Child("hvcC");
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
// profile_tier_level occupa 12 byte subito dopo i due byte di header NAL e il byte
// che contiene sps_video_parameter_set_id / sps_max_sub_layers_minus1.
Span ptl = stackalloc byte[12];
if (sps.Length >= 15) sps.AsSpan(3, 12).CopyTo(ptl);
else ptl[0] = 0x01; // Main profile come ripiego
hvcc.WriteByte(1); // configurationVersion
hvcc.WriteByte(ptl[0]); // profile_space/tier/profile_idc
hvcc.WriteBytes(ptl[1..5]); // general_profile_compatibility_flags
hvcc.WriteBytes(ptl[5..11]); // general_constraint_indicator_flags
hvcc.WriteByte(ptl[11]); // general_level_idc
hvcc.WriteUInt16(0xF000); // min_spatial_segmentation = 0
hvcc.WriteByte(0xFC); // parallelismType sconosciuto
hvcc.WriteByte(0xFD); // chromaFormat 4:2:0
hvcc.WriteByte(0xF8); // bitDepthLumaMinus8 = 0
hvcc.WriteByte(0xF8); // bitDepthChromaMinus8 = 0
hvcc.WriteUInt16(0); // avgFrameRate (non dichiarato)
hvcc.WriteByte(0x0F); // costantFrameRate/temporalId + lengthSizeMinusOne
hvcc.WriteByte((byte)((_vps.Count > 0 ? 1 : 0) + (_sps.Count > 0 ? 1 : 0) + (_pps.Count > 0 ? 1 : 0)));
WriteHevcArray(hvcc, 32, _vps);
WriteHevcArray(hvcc, 33, _sps);
WriteHevcArray(hvcc, 34, _pps);
}
private static void WriteHevcArray(BoxWriter hvcc, byte nalType, List sets)
{
if (sets.Count == 0) return;
hvcc.WriteByte((byte)(0x80 | nalType)); // array_completeness + NAL_unit_type
hvcc.WriteUInt16((ushort)sets.Count);
foreach (var set in sets)
{
hvcc.WriteUInt16((ushort)set.Length);
hvcc.WriteBytes(set);
}
}
private void WriteTimeToSample(BoxWriter stbl)
{
// Codifica a corse: le durate uguali consecutive occupano una sola voce.
var runs = new List<(uint Count, uint Delta)>();
foreach (uint delta in _sampleDurations)
{
if (runs.Count > 0 && runs[^1].Delta == delta) runs[^1] = (runs[^1].Count + 1, delta);
else runs.Add((1, delta));
}
using var stts = stbl.Child("stts");
stts.WriteFullBoxHeader(0, 0);
stts.WriteUInt32((uint)runs.Count);
foreach (var (count, delta) in runs)
{
stts.WriteUInt32(count);
stts.WriteUInt32(delta);
}
}
private void WriteSyncSamples(BoxWriter stbl)
{
// Se ogni campione è un punto di sincronizzazione la tabella si omette per convenzione.
if (_syncSamples.Count == _sampleSizes.Count || _syncSamples.Count == 0) return;
using var stss = stbl.Child("stss");
stss.WriteFullBoxHeader(0, 0);
stss.WriteUInt32((uint)_syncSamples.Count);
foreach (int index in _syncSamples) stss.WriteUInt32((uint)index);
}
private void WriteSampleToChunk(BoxWriter stbl)
{
using var stsc = stbl.Child("stsc");
stsc.WriteFullBoxHeader(0, 0);
stsc.WriteUInt32(1); // una sola voce: tutti i campioni in un chunk
stsc.WriteUInt32(1); // first_chunk
stsc.WriteUInt32((uint)Math.Max(1, _sampleSizes.Count));
stsc.WriteUInt32(1); // sample_description_index
}
private void WriteSampleSizes(BoxWriter stbl)
{
using var stsz = stbl.Child("stsz");
stsz.WriteFullBoxHeader(0, 0);
stsz.WriteUInt32(0); // dimensione variabile
stsz.WriteUInt32((uint)_sampleSizes.Count);
foreach (uint size in _sampleSizes) stsz.WriteUInt32(size);
}
private void WriteChunkOffsets(BoxWriter stbl)
{
using var co64 = stbl.Child("co64");
co64.WriteFullBoxHeader(0, 0);
co64.WriteUInt32(1);
co64.WriteUInt64((ulong)_mdatPayloadStart);
}
// ------------------------------------------------------------------ NAL
private bool IsParameterSet(byte header)
{
if (_codec == VideoCodec.H264)
{
int type = header & 0x1F;
return type is 7 or 8; // SPS, PPS
}
int hevcType = (header >> 1) & 0x3F;
return hevcType is 32 or 33 or 34; // VPS, SPS, PPS
}
private bool IsKeyframeNal(byte header)
{
if (_codec == VideoCodec.H264) return (header & 0x1F) == 5; // IDR
int type = (header >> 1) & 0x3F;
return type is >= 16 and <= 21; // BLA/IDR/CRA
}
private void ClassifyAndStore(ReadOnlySpan source, AnnexBParser.NalRange nal)
{
if (nal.Length <= 0) return;
var payload = source.Slice(nal.Start, nal.Length);
byte header = payload[0];
List target;
if (_codec == VideoCodec.H264)
{
int type = header & 0x1F;
if (type == 7) target = _sps;
else if (type == 8) target = _pps;
else return;
}
else
{
int type = (header >> 1) & 0x3F;
if (type == 32) target = _vps;
else if (type == 33) target = _sps;
else if (type == 34) target = _pps;
else return;
}
var copy = payload.ToArray();
foreach (var existing in target)
{
if (existing.AsSpan().SequenceEqual(copy)) return;
}
target.Add(copy);
}
///
/// Allarga il buffer di conversione conservandone il contenuto.
///
/// La copia non è un dettaglio: il buffer accumula le NAL di un campione una dopo l'altra,
/// quindi rimpiazzarlo con un array nuovo azzererebbe quelle già scritte. Il campione
/// uscirebbe con un prefisso di lunghezza nullo in testa e nessun decodificatore lo
/// supererebbe. Il caso si presenta al primo fotogramma chiave, l'unico abbastanza
/// grande da superare la capacità iniziale.
///
private void EnsureScratch(int required)
{
if (_scratch.Length >= required) return;
long size = Math.Max(_scratch.Length, 1);
while (size < required) size *= 2;
Array.Resize(ref _scratch, (int)Math.Min(size, Array.MaxLength));
}
}