diff --git a/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs b/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs new file mode 100644 index 000000000..bfafb718f --- /dev/null +++ b/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using Editors.Audio.Shared.Utilities; +using Shared.Core.Misc; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Editors.Audio.ContextMenu +{ + public class ExportCAVp8AsIvfCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand + { + public string GetDisplayName(TreeNode node) => "Export as IVF (without audio)"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; + public bool IsEnabled(TreeNode node) => node.Item != null && node.Item.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + + public void Execute(TreeNode selectedNode) + { + var packFile = selectedNode.Item; + if (packFile == null) + return; + + var dialogResult = standardDialogs.ShowSystemFolderBrowserDialog(); + if (!dialogResult.Result || string.IsNullOrWhiteSpace(dialogResult.FolderPath)) + return; + + DirectoryHelper.EnsureCreated(dialogResult.FolderPath); + + var ivfPath = Path.Combine(dialogResult.FolderPath, Path.ChangeExtension(packFile.Name, ".ivf")); + var ivfBytes = CAVp8Exporter.ExportToIvf(packFile); + fileSystemAccess.FileWriteAllBytes(ivfPath, ivfBytes); + } + } +} diff --git a/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs b/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs new file mode 100644 index 000000000..c899d6baa --- /dev/null +++ b/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using Editors.Audio.Shared.Storage; +using Editors.Audio.Shared.Utilities; +using Shared.Core.Misc; +using Shared.Core.PackFiles; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Editors.Audio.ContextMenu +{ + public class ExportCAVp8AsWebMCommand( + IPackFileService packFileService, + IAudioRepository audioRepository, + IStandardDialogs standardDialogs, + IFileSystemAccess fileSystemAccess) : IContextMenuCommand + { + public string GetDisplayName(TreeNode node) => "Export as WebM"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; + public bool IsEnabled(TreeNode node) => node.Item != null && node.Item.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + + public void Execute(TreeNode selectedNode) + { + var packFile = selectedNode.Item; + if (packFile == null) + return; + + var dialogResult = standardDialogs.ShowSystemFolderBrowserDialog(); + if (!dialogResult.Result || string.IsNullOrWhiteSpace(dialogResult.FolderPath)) + return; + + DirectoryHelper.EnsureCreated(dialogResult.FolderPath); + + var webMPath = Path.Combine(dialogResult.FolderPath, Path.ChangeExtension(packFile.Name, ".webm")); + var webMBytes = CAVp8Exporter.ExportToWebM(packFile, packFileService, audioRepository); + fileSystemAccess.FileWriteAllBytes(webMPath, webMBytes); + } + } +} diff --git a/Editors/Audio/DependencyInjectionContainer.cs b/Editors/Audio/DependencyInjectionContainer.cs index 1805e53c5..7b05df57b 100644 --- a/Editors/Audio/DependencyInjectionContainer.cs +++ b/Editors/Audio/DependencyInjectionContainer.cs @@ -18,6 +18,7 @@ using Editors.Audio.AudioExplorer; using Editors.Audio.AudioProjectConverter; using Editors.Audio.AudioProjectMerger; +using Editors.Audio.ContextMenu; using Editors.Audio.DialogueEventMerger; using Editors.Audio.Shared.AudioProject; using Editors.Audio.Shared.AudioProject.Compiler; @@ -31,6 +32,7 @@ using Shared.Core.DependencyInjection; using Shared.Core.DevConfig; using Shared.Core.ToolCreation; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; namespace Editors.Audio { @@ -132,6 +134,11 @@ public override void Register(IServiceCollection serviceCollection) serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + // Context menu + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddSingleton(); + RegisterAllAsInterface(serviceCollection, ServiceLifetime.Transient); } @@ -148,4 +155,13 @@ public override void RegisterTools(IEditorDatabase factory) .Build(factory); } } + + public class AudioPackFileContextMenuRegistration : IPackFileContextMenuRegistration + { + public void Register(PackFileContextMenuRegistry registry) + { + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Export", priority: 20, ContextMenuCluster.Export); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Export", priority: 30, ContextMenuCluster.Export); + } + } } diff --git a/Editors/Audio/Shared/Utilities/CAVp8Exporter.cs b/Editors/Audio/Shared/Utilities/CAVp8Exporter.cs new file mode 100644 index 000000000..1016c5bde --- /dev/null +++ b/Editors/Audio/Shared/Utilities/CAVp8Exporter.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using Editors.Audio.Shared.GameInformation.Warhammer3; +using Editors.Audio.Shared.Storage; +using Editors.Audio.Shared.Wwise.HircExploration; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.Audio.Codecs; +using Shared.GameFormats.Audio.Wav; +using Shared.GameFormats.Video; +using Shared.GameFormats.Wwise; +using Shared.GameFormats.Wwise.Hirc; +using Shared.GameFormats.Wwise.Wem.V132; +using Shared.GameFormats.Wwise.Wem.V132.Decoding; +using Shared.GameFormats.Wwise.Wem.V132.Encoding; + +namespace Editors.Audio.Shared.Utilities +{ + public static class CAVp8Exporter + { + public static byte[] ExportToWebM(PackFile packFile, IPackFileService packFileService, IAudioRepository audioRepository) + { + var caVp8File = new CAVp8File(packFile.DataSource.ReadData()); + var vorbisAudio = GetVorbisAudio(packFile, packFileService, audioRepository); + + var webMFile = new WebMFile + { + Width = caVp8File.Width, + Height = caVp8File.Height, + Framerate = caVp8File.Framerate, + FrameTable = caVp8File.FrameTable, + FrameData = caVp8File.FrameData, + VorbisCodecPrivate = vorbisAudio.VorbisCodecPrivateData, + VorbisAudioPackets = vorbisAudio.Packets, + VorbisSampleRate = checked((int)vorbisAudio.SampleRate), + VorbisChannels = vorbisAudio.Channels, + }; + + return webMFile.WriteData(); + } + + public static byte[] ExportToWav(PackFile packFile, IPackFileService packFileService, IAudioRepository audioRepository) + { + var vorbisAudio = GetVorbisAudio(packFile, packFileService, audioRepository); + var pcmAudio = vorbisAudio.ToPcm(); + var wavFile = new WavFile { Audio = pcmAudio }; + return wavFile.WriteData(); + } + + public static byte[] ExportToWem(PackFile packFile, IPackFileService packFileService, IAudioRepository audioRepository) + { + var wavBytes = ExportToWav(packFile, packFileService, audioRepository); + var wemFile = WemFile.CreateFromWavBytes(wavBytes); + return wemFile.WriteData(); + } + + public static byte[] ExportToIvf(PackFile packFile) + { + var caVp8File = new CAVp8File(packFile.DataSource.ReadData()); + var ivfFile = new IvfFile(caVp8File); + return ivfFile.WriteData(); + } + + private static VorbisAudio GetVorbisAudio(PackFile packFile, IPackFileService packFileService, IAudioRepository audioRepository) + { + var wemBytes = GetWem(packFile, packFileService, audioRepository); + var wemFile = WemFile.CreateFromBytes(wemBytes); + var codebookLibrary = new WwiseCodebookLibrary(); + var decoder = new WemVorbisDecoder(codebookLibrary); + return decoder.Decode(wemFile); + } + + private static byte[] GetWem(PackFile packFile, IPackFileService packFileService, IAudioRepository audioRepository) + { + audioRepository.Load(Wh3LanguageInformation.GetAllLanguages()); + + var movieFilePath = packFileService.GetFullPath(packFile); + var actionEventName = Wh3ActionEventInformation.GetMovieActionEventName(movieFilePath); + var actionEventId = WwiseHash.Compute(actionEventName); + + var actionEventHircs = audioRepository.GetHircs(actionEventId); + if (actionEventHircs.Count == 0) + throw new Exception($"Cannot find Action Event: {actionEventName}."); + + var hircTreeChildrenParser = new HircTreeChildrenParser(audioRepository); + var nodes = hircTreeChildrenParser.BuildHierarchyAsFlatList(actionEventHircs.First()); + + var soundNode = nodes.FirstOrDefault(node => node.Hirc is ICAkSound); + if (soundNode == null) + throw new Exception($"Cannot find a Sound node for Action Event: {actionEventName}."); + + var sourceId = ((ICAkSound)soundNode.Hirc).GetSourceId(); + var wemFile = audioRepository.FindWem(sourceId.ToString()); + if (wemFile == null) + throw new Exception($"Cannot find {sourceId}.wem"); + + return wemFile.DataSource.ReadData(); + } + } +} diff --git a/Shared/GameFiles/Video/CAVp8File.cs b/Shared/GameFiles/Video/CAVp8File.cs new file mode 100644 index 000000000..ce8b4faac --- /dev/null +++ b/Shared/GameFiles/Video/CAVp8File.cs @@ -0,0 +1,167 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Video +{ + public class CAVp8ExtraHeaderData + { + public byte UnknownByte { get; set; } + public uint UnknownUInt32First { get; set; } + public uint UnknownUInt32Second { get; set; } + } + + public class CAVp8File + { + private const string Signature = "CAMV"; + private const ushort HeaderLengthV0 = 40; + private const ushort HeaderLengthV1 = 40; + + public ushort Version { get; set; } + public string CodecFourCC { get; set; } = "VP80"; + public ushort Width { get; set; } + public ushort Height { get; set; } + public uint NumberOfFrames { get; set; } + public float Framerate { get; set; } + public CAVp8ExtraHeaderData? ExtraData { get; set; } + public List FrameTable { get; set; } = []; + public byte[] FrameData { get; set; } = []; + + public CAVp8File(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + ReadData(new ByteChunk(data)); + } + + private void ReadData(ByteChunk chunk) + { + var bytes = chunk.ReadBytes(chunk.BytesLeft); + using var reader = new BinaryReader(new MemoryStream(bytes)); + + var signature = Encoding.ASCII.GetString(reader.ReadBytes(4)); + if (signature != Signature) + throw new Exception($"CA_VP8 signature mismatch: expected '{Signature}' but got '{signature}'."); + + Version = reader.ReadUInt16(); + var rawHeaderLength = reader.ReadUInt16(); + CodecFourCC = Encoding.ASCII.GetString(reader.ReadBytes(4)); + Width = reader.ReadUInt16(); + Height = reader.ReadUInt16(); + var msPerFrame = reader.ReadSingle(); + reader.ReadUInt32(); // No idea, always 1. + var numFramesMinusOne = reader.ReadUInt32(); // Same as num_frames, but sometimes is num_frames - 1. When it's the same, there are 9 extra bytes in the header. + var offsetFrameTable = reader.ReadUInt32(); + NumberOfFrames = reader.ReadUInt32(); + reader.ReadUInt32(); // Largest frame's size, in bytes. Recalculated on save. + + if (numFramesMinusOne == NumberOfFrames) + { + ExtraData = new CAVp8ExtraHeaderData + { + UnknownByte = reader.ReadByte(), + UnknownUInt32First = reader.ReadUInt32(), + UnknownUInt32Second = reader.ReadUInt32(), + }; + } + + var expectedHeaderEnd = (long)(rawHeaderLength + 8); + if (reader.BaseStream.Position != expectedHeaderEnd) + throw new Exception($"CA_VP8 header size mismatch: expected stream to be at position {expectedHeaderEnd} after reading the header, but it is at position {reader.BaseStream.Position}."); + + var frameDataLength = (int)(offsetFrameTable - reader.BaseStream.Position); + FrameData = reader.ReadBytes(frameDataLength); + + var totalFileLength = reader.BaseStream.Length; + var frameTableLength = totalFileLength - reader.BaseStream.Position; + var hasBells = frameTableLength / 13 == NumberOfFrames && frameTableLength % 13 == 0; + + var runningOffset = 0u; + FrameTable = new List((int)NumberOfFrames); + using var frameTableDecoded = new MemoryStream(); + + for (var frameIndex = 0; frameIndex < NumberOfFrames; frameIndex++) + { + var frameOffsetReal = reader.ReadUInt32(); + var frameSize = reader.ReadUInt32(); + if (hasBells) + reader.ReadUInt32(); + var isKeyFrame = reader.ReadBoolean(); + + var frame = new FrameTableRecord + { + Offset = runningOffset, + Size = frameSize, + IsKeyFrame = isKeyFrame, + }; + + runningOffset += frame.Size; + FrameTable.Add(frame); + + var frameOffsetRealEnd = frameOffsetReal + frameSize; + if (frameOffsetRealEnd > totalFileLength) + throw new Exception($"CA_VP8 frame {frameIndex} has an incorrect or unknown frame size: it would end at file offset {frameOffsetRealEnd} which is beyond the end of the file at {totalFileLength}."); + + var savedPosition = reader.BaseStream.Position; + reader.BaseStream.Seek(frameOffsetReal, SeekOrigin.Begin); + frameTableDecoded.Write(reader.ReadBytes((int)frameSize)); + reader.BaseStream.Seek(savedPosition, SeekOrigin.Begin); + } + + if (reader.BaseStream.Position != totalFileLength) + throw new Exception($"CA_VP8 file size mismatch: expected {totalFileLength} bytes but stream is at position {reader.BaseStream.Position}."); + + Framerate = 1000f / msPerFrame; + } + + public byte[] WriteData() + { + using var memStream = new MemoryStream(); + using var writer = new BinaryWriter(memStream); + + ushort headerLength; + if (Version == 0) + headerLength = ExtraData != null ? (ushort)(HeaderLengthV0 + 9) : HeaderLengthV0; + else + headerLength = ExtraData != null ? (ushort)(HeaderLengthV1 + 9) : HeaderLengthV1; + + var rawHeaderLength = (ushort)(headerLength - 8); + + writer.Write(Encoding.ASCII.GetBytes(Signature)); + writer.Write(Version); + writer.Write(rawHeaderLength); + writer.Write(Encoding.ASCII.GetBytes(CodecFourCC)); + writer.Write(Width); + writer.Write(Height); + writer.Write(1000f / Framerate); + writer.Write((uint)1); + + if (ExtraData != null || NumberOfFrames == 0) + writer.Write(NumberOfFrames); + else + writer.Write(NumberOfFrames - 1); + + writer.Write((uint)(headerLength + FrameData.Length)); + writer.Write(NumberOfFrames); + writer.Write(FrameTable.Max(frame => frame.Size)); + + if (ExtraData != null) + { + writer.Write(ExtraData.UnknownByte); + writer.Write(ExtraData.UnknownUInt32First); + writer.Write(ExtraData.UnknownUInt32Second); + } + + writer.Write(FrameData); + + var runningOffset = ExtraData != null ? (uint)rawHeaderLength : (uint)headerLength; + foreach (var frame in FrameTable) + { + writer.Write(runningOffset); + writer.Write(frame.Size); + writer.Write(frame.IsKeyFrame); + runningOffset += frame.Size; + } + + return memStream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Video/ClusterBlock.cs b/Shared/GameFiles/Video/ClusterBlock.cs new file mode 100644 index 000000000..b02a328b9 --- /dev/null +++ b/Shared/GameFiles/Video/ClusterBlock.cs @@ -0,0 +1,4 @@ +namespace Shared.GameFormats.Video +{ + public record ClusterBlock(long TimestampMs, byte[] Data); +} diff --git a/Shared/GameFiles/Video/FrameTableRecord.cs b/Shared/GameFiles/Video/FrameTableRecord.cs new file mode 100644 index 000000000..a5f5c55b7 --- /dev/null +++ b/Shared/GameFiles/Video/FrameTableRecord.cs @@ -0,0 +1,9 @@ +namespace Shared.GameFormats.Video +{ + public class FrameTableRecord + { + public uint Offset { get; set; } + public uint Size { get; set; } + public bool IsKeyFrame { get; set; } + } +} diff --git a/Shared/GameFiles/Video/IvfFile.cs b/Shared/GameFiles/Video/IvfFile.cs new file mode 100644 index 000000000..d3d5e9e8f --- /dev/null +++ b/Shared/GameFiles/Video/IvfFile.cs @@ -0,0 +1,102 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Video +{ + public class IvfFile(CAVp8File caVp8File) + { + private const string Signature = "DKIF"; + private const ushort HeaderLength = 32; + + public ushort Version { get; set; } = caVp8File.Version; + public string CodecFourCC { get; set; } = caVp8File.CodecFourCC; + public ushort Width { get; set; } = caVp8File.Width; + public ushort Height { get; set; } = caVp8File.Height; + public uint NumberOfFrames { get; set; } = caVp8File.NumberOfFrames; + public float Framerate { get; set; } = caVp8File.Framerate; + public List FrameTable { get; set; } = caVp8File.FrameTable; + public byte[] FrameData { get; set; } = caVp8File.FrameData; + + public byte[] WriteData() + { + using var memStream = new MemoryStream(); + using var writer = new BinaryWriter(memStream); + + writer.Write(Encoding.ASCII.GetBytes(Signature)); + writer.Write(Version); + writer.Write(HeaderLength); + writer.Write(Encoding.ASCII.GetBytes(CodecFourCC)); + writer.Write(Width); + writer.Write(Height); + + var (framerateNumerator, framerateDenominator) = ConvertFramerateToRational(Framerate); + writer.Write(framerateNumerator); + writer.Write(framerateDenominator); + + writer.Write(NumberOfFrames); + writer.Write((uint)0); + + var offset = 0; + for (var frameIndex = 0; frameIndex < FrameTable.Count; frameIndex++) + { + var frame = FrameTable[frameIndex]; + + var frameEndMinusOne = offset + (int)frame.Size - 1; + if (frameEndMinusOne < 0) + throw new Exception($"IVF frame {frameIndex} has a size of zero at offset zero, which causes integer underflow when computing the last byte index."); + + if (offset < FrameData.Length && frameEndMinusOne < FrameData.Length) + { + var frameData = FrameData[offset..(offset + (int)frame.Size)]; + writer.Write((uint)frameData.Length); + writer.Write((ulong)frameIndex); + writer.Write(frameData); + offset += (int)frame.Size; + } + } + + return memStream.ToArray(); + } + + private static (uint numerator, uint denominator) ConvertFramerateToRational(float framerate) + { + if (framerate <= 0f || float.IsNaN(framerate) || float.IsInfinity(framerate)) + throw new Exception($"Cannot convert a framerate of {framerate} to a rational number."); + + var bits = BitConverter.SingleToUInt32Bits(framerate); + var biasedExponent = (int)BitHelper.ExtractBits(bits, 23, 8); + var mantissaBits = BitHelper.ExtractBits(bits, 0, 23); + + ulong significand = (1UL << 23) | mantissaBits; + var exponentShift = biasedExponent - 150; + + ulong numerator; + ulong denominator; + + if (exponentShift >= 0) + { + numerator = significand << exponentShift; + denominator = 1; + } + else + { + numerator = significand; + denominator = 1UL << (-exponentShift); + } + + var divisor = GreatestCommonDivisor(numerator, denominator); + return ((uint)(numerator / divisor), (uint)(denominator / divisor)); + } + + private static ulong GreatestCommonDivisor(ulong a, ulong b) + { + while (b != 0) + { + var remainder = a % b; + a = b; + b = remainder; + } + return a; + } + } +} diff --git a/Shared/GameFiles/Video/WebMFile.cs b/Shared/GameFiles/Video/WebMFile.cs new file mode 100644 index 000000000..0891e3f77 --- /dev/null +++ b/Shared/GameFiles/Video/WebMFile.cs @@ -0,0 +1,379 @@ +using System.Text; +using Shared.GameFormats.Audio.Codecs; + +namespace Shared.GameFormats.Video +{ + public class WebMFile + { + private const uint IdTracks = 0x1654AE6B; + private const uint IdTrackEntry = 0xAE; + private const uint IdTrackNumber = 0xD7; + private const uint IdTrackUid = 0x73C5; + private const uint IdTrackType = 0x83; + private const uint IdFlagLacing = 0x9C; + private const uint IdCodecId = 0x86; + private const uint TrackNumberVideo = 1; + private const uint TrackNumberAudio = 2; + private const byte VintMarkerOneByte = 0x80; + private const byte VintMarkerEightByte = 0x01; + + public ushort Width { get; set; } + public ushort Height { get; set; } + public float Framerate { get; set; } + public List FrameTable { get; set; } = []; + public byte[] FrameData { get; set; } = []; + public byte[]? VorbisCodecPrivate { get; set; } + public List VorbisAudioPackets { get; set; } = []; + public int VorbisSampleRate { get; set; } + public int VorbisChannels { get; set; } + + public byte[] WriteData() + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + + writer.Write(BuildEbmlHeader()); + var idSegment = 0x18538067U; + WriteId(writer, idSegment); + // We use unknown size because total length is not known upfront + WriteUnknownSize(writer); + writer.Write(BuildInfo()); + writer.Write(BuildTracks()); + WriteClusters(writer); + return stream.ToArray(); + } + + private static byte[] BuildEbmlHeader() + { + var idEbml = 0x1A45DFA3U; + var idEbmlVersion = 0x4286U; + var idEbmlReadVersion = 0x42F7U; + var idEbmlMaxIdLength = 0x42F2U; + var idEbmlMaxSizeLength = 0x42F3U; + var idDocType = 0x4282U; + var idDocTypeVersion = 0x4287U; + var idDocTypeReadVersion = 0x4285U; + + var ebmlVersion = UintElement(idEbmlVersion, 1); + var ebmlReadVersion = UintElement(idEbmlReadVersion, 1); + var ebmlMaxIdLength = UintElement(idEbmlMaxIdLength, 4); + var ebmlMaxSizeLength = UintElement(idEbmlMaxSizeLength, 8); + var docType = StringElement(idDocType, "webm"); + var docTypeVersion = UintElement(idDocTypeVersion, 4); + var docTypeReadVersion = UintElement(idDocTypeReadVersion, 2); + + var content = Concat( + ebmlVersion, + ebmlReadVersion, + ebmlMaxIdLength, + ebmlMaxSizeLength, + docType, + docTypeVersion, + docTypeReadVersion + ); + + return Element(idEbml, content); + } + + private byte[] BuildInfo() + { + var idInfo = 0x1549A966U; + var idTimestampScale = 0x2AD7B1U; + var idMuxingApp = 0x4D80U; + var idWritingApp = 0x5741U; + var idDuration = 0x4489U; + var durationMs = FrameTable.Count / (double)Framerate * 1000.0; + var timestampScaleNanoseconds = 1_000_000UL; + + var timestampScale = UintElement(idTimestampScale, timestampScaleNanoseconds); + var muxingApp = StringElement(idMuxingApp, "AssetEditor"); + var writingApp = StringElement(idWritingApp, "AssetEditor"); + var duration = FloatElement(idDuration, durationMs); + + var content = Concat( + timestampScale, + muxingApp, + writingApp, + duration + ); + + return Element(idInfo, content); + } + + private byte[] BuildTracks() + { + var videoEntry = BuildVideoTrackEntry(); + + var hasVorbisAudio = VorbisCodecPrivate != null && VorbisAudioPackets.Count > 0; + if (!hasVorbisAudio) + return Element(IdTracks, videoEntry); + + var audioEntry = BuildAudioTrackEntry(); + return Element(IdTracks, Concat(videoEntry, audioEntry)); + } + + private byte[] BuildVideoTrackEntry() + { + var idVideo = 0xE0U; + var idPixelWidth = 0xB0U; + var idPixelHeight = 0xBAU; + + var trackNumber = UintElement(IdTrackNumber, TrackNumberVideo); + var trackUid = UintElement(IdTrackUid, TrackNumberVideo); + var trackType = UintElement(IdTrackType, 1); + var flagLacing = UintElement(IdFlagLacing, 0); + var codecId = StringElement(IdCodecId, "V_VP8"); + + var pixelWidth = UintElement(idPixelWidth, Width); + var pixelHeight = UintElement(idPixelHeight, Height); + var videoSettings = Element(idVideo, Concat(pixelWidth, pixelHeight)); + + var content = Concat( + trackNumber, + trackUid, + trackType, + flagLacing, + codecId, + videoSettings + ); + + return Element(IdTrackEntry, content); + } + + private byte[] BuildAudioTrackEntry() + { + var idCodecPrivate = 0x63A2U; + var idAudio = 0xE1U; + var idSamplingFrequency = 0xB5U; + var idChannels = 0x9FU; + + var trackNumber = UintElement(IdTrackNumber, TrackNumberAudio); + var trackUid = UintElement(IdTrackUid, TrackNumberAudio); + var trackType = UintElement(IdTrackType, 2); + var flagLacing = UintElement(IdFlagLacing, 0); + var codecId = StringElement(IdCodecId, "A_VORBIS"); + var codecPrivate = Element(idCodecPrivate, VorbisCodecPrivate!); + + var samplingFrequency = FloatElement(idSamplingFrequency, VorbisSampleRate); + var channels = UintElement(idChannels, (ulong)VorbisChannels); + var audioSettings = Element(idAudio, Concat(samplingFrequency, channels)); + + var content = Concat( + trackNumber, + trackUid, + trackType, + flagLacing, + codecId, + codecPrivate, + audioSettings + ); + + return Element(IdTrackEntry, content); + } + + private void WriteClusters(BinaryWriter writer) + { + if (FrameTable.Count == 0) + return; + + var idCluster = 0x1F43B675U; + var idTimestamp = 0xE7U; + var maxClusterDurationMs = 30000; + var msPerVideoFrame = 1000.0 / Framerate; + var videoDataOffset = 0; + var videoIndex = 0; + var audioIndex = 0; + + while (videoIndex < FrameTable.Count) + { + using var clusterContent = new MemoryStream(); + using var clusterWriter = new BinaryWriter(clusterContent); + + var clusterTimestampMs = (long)(videoIndex * msPerVideoFrame); + clusterWriter.Write(UintElement(idTimestamp, (ulong)clusterTimestampMs)); + + var blocks = new List(); + var clusterVideoStart = videoIndex; + + while (videoIndex < FrameTable.Count) + { + var frameTimestampMs = (long)(videoIndex * msPerVideoFrame); + var relativeMs = frameTimestampMs - clusterTimestampMs; + + var isKeyFrame = FrameTable[videoIndex].IsKeyFrame; + if (videoIndex > clusterVideoStart && isKeyFrame && relativeMs > maxClusterDurationMs) + break; + + var frame = FrameTable[videoIndex]; + var frameData = FrameData[videoDataOffset..(videoDataOffset + (int)frame.Size)]; + videoDataOffset += (int)frame.Size; + + blocks.Add(new ClusterBlock(frameTimestampMs, BuildSimpleBlock(frameData, (short)relativeMs, isKeyFrame, trackNumber: (int)TrackNumberVideo))); + videoIndex++; + } + + var clusterEndMs = long.MaxValue; + if (videoIndex < FrameTable.Count) + clusterEndMs = (long)(videoIndex * msPerVideoFrame); + + while (audioIndex < VorbisAudioPackets.Count && VorbisAudioPackets[audioIndex].TimestampMilliseconds < clusterEndMs) + { + var audioPacket = VorbisAudioPackets[audioIndex]; + var audioTimestampMs = audioPacket.TimestampMilliseconds; + var relativeMs = (short)Math.Clamp(audioTimestampMs - clusterTimestampMs, short.MinValue, short.MaxValue); + blocks.Add(new ClusterBlock(audioTimestampMs, BuildSimpleBlock(audioPacket.Data, relativeMs, isKeyFrame: false, trackNumber: (int)TrackNumberAudio))); + audioIndex++; + } + + foreach (var block in blocks.OrderBy(block => block.TimestampMs)) + clusterWriter.Write(block.Data); + + writer.Write(Element(idCluster, clusterContent.ToArray())); + } + } + + private static byte[] BuildSimpleBlock(byte[] frameData, short relativeTimestampMs, bool isKeyFrame, int trackNumber) + { + var idSimpleBlock = 0xA3U; + byte simpleBlockKeyframeFlag = 0x80; + byte simpleBlockNoFlags = 0x00; + + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + + writer.Write((byte)(VintMarkerOneByte | trackNumber)); + writer.Write((byte)((relativeTimestampMs >> 8) & 0xFF)); + writer.Write((byte)(relativeTimestampMs & 0xFF)); + + if (isKeyFrame) + writer.Write(simpleBlockKeyframeFlag); + else + writer.Write(simpleBlockNoFlags); + + writer.Write(frameData); + + return Element(idSimpleBlock, stream.ToArray()); + } + + private static byte[] Element(uint id, byte[] content) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + WriteId(writer, id); + WriteVint(writer, (ulong)content.Length); + writer.Write(content); + return stream.ToArray(); + } + + private static byte[] UintElement(uint id, ulong value) + { + var numBytes = 1; + var shifted = value >> 8; + while (shifted > 0) { shifted >>= 8; numBytes++; } + + var content = new byte[numBytes]; + var remaining = value; + for (var i = numBytes - 1; i >= 0; i--) + { + content[i] = (byte)(remaining & 0xFF); + remaining >>= 8; + } + return Element(id, content); + } + + private static byte[] StringElement(uint id, string value) => Element(id, Encoding.ASCII.GetBytes(value)); + + private static byte[] FloatElement(uint id, double value) + { + var bytes = BitConverter.GetBytes(value); + if (BitConverter.IsLittleEndian) + Array.Reverse(bytes); + return Element(id, bytes); + } + + private static byte[] Concat(params byte[][] arrays) + { + var result = new byte[arrays.Sum(a => a.Length)]; + var offset = 0; + foreach (var array in arrays) + { + Buffer.BlockCopy(array, 0, result, offset, array.Length); + offset += array.Length; + } + return result; + } + + private static void WriteId(BinaryWriter writer, uint id) + { + var idMaxOneByte = 0xFFU; + var idMaxTwoByte = 0xFFFFU; + var idMaxThreeByte = 0xFFFFFFU; + + if (id <= idMaxOneByte) + writer.Write((byte)id); + else if (id <= idMaxTwoByte) + { + writer.Write((byte)(id >> 8)); + writer.Write((byte)(id & 0xFF)); + } + else if (id <= idMaxThreeByte) + { + writer.Write((byte)(id >> 16)); + writer.Write((byte)((id >> 8) & 0xFF)); + writer.Write((byte)(id & 0xFF)); + } + else + { + writer.Write((byte)(id >> 24)); + writer.Write((byte)((id >> 16) & 0xFF)); + writer.Write((byte)((id >> 8) & 0xFF)); + writer.Write((byte)(id & 0xFF)); + } + } + + private static void WriteVint(BinaryWriter writer, ulong value) + { + var vintMaxOneByte = 0x7EUL; + var vintMaxTwoByte = 0x3FFEUL; + var vintMaxThreeByte = 0x1FFFFEUL; + var vintMaxFourByte = 0x0FFFFFFEUL; + var vintMarkerTwoByte = 0x40UL; + var vintMarkerThreeByte = 0x20UL; + var vintMarkerFourByte = 0x10UL; + + if (value <= vintMaxOneByte) + writer.Write((byte)(VintMarkerOneByte | value)); + else if (value <= vintMaxTwoByte) + { + writer.Write((byte)(vintMarkerTwoByte | (value >> 8))); + writer.Write((byte)(value & 0xFF)); + } + else if (value <= vintMaxThreeByte) + { + writer.Write((byte)(vintMarkerThreeByte | (value >> 16))); + writer.Write((byte)((value >> 8) & 0xFF)); + writer.Write((byte)(value & 0xFF)); + } + else if (value <= vintMaxFourByte) + { + writer.Write((byte)(vintMarkerFourByte | (value >> 24))); + writer.Write((byte)((value >> 16) & 0xFF)); + writer.Write((byte)((value >> 8) & 0xFF)); + writer.Write((byte)(value & 0xFF)); + } + else + { + writer.Write(VintMarkerEightByte); + for (var i = 6; i >= 0; i--) + writer.Write((byte)((value >> (i * 8)) & 0xFF)); + } + } + + private static void WriteUnknownSize(BinaryWriter writer) + { + writer.Write(VintMarkerEightByte); + for (var i = 0; i < 7; i++) + writer.Write((byte)0xFF); + } + } +}