Skip to content

Instantly share code, notes, and snippets.

@Hackmodford
Created February 9, 2021 02:39
Show Gist options
  • Save Hackmodford/9bfd7144d51fc9dc7f74087d7d07f0e4 to your computer and use it in GitHub Desktop.
Save Hackmodford/9bfd7144d51fc9dc7f74087d7d07f0e4 to your computer and use it in GitHub Desktop.
MNAVChapter C# port attempt
using System.Collections.Generic;
using AVFoundation;
namespace Chapters
{
// ReSharper disable once InconsistentNaming
public static class AVAssetExtensions
{
public static IList<Chapter> GetChapters(this AVAsset asset)
{
IChapterReader reader = null;
var formats = asset.AvailableMetadataFormats;
IList<Chapter> chapters = new List<Chapter>();
foreach (var format in formats)
{
switch (format)
{
case Constants.MetadataFormatMp4:
reader = new ChapterReaderMp4();
break;
case Constants.MetadataFormatId3:
reader = new ChapterReaderMp3();
break;
}
chapters = reader?.GetChapters(asset);
}
return chapters;
}
}
}
using System;
using CoreMedia;
using UIKit;
namespace Chapters
{
public class Chapter
{
public CMTime Duration { get; }
public CMTime Time { get; }
public string Identifier { get; set; }
public bool IsHidden { get; set; }
public string Title { get; set; }
public string Url { get; set; }
public UIImage Artwork { get; set; }
public Chapter(CMTime time, CMTime duration)
{
Time = time;
Duration = duration;
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using AVFoundation;
using CoreMedia;
using Foundation;
using UIKit;
namespace Chapters
{
internal class ChapterReaderMp3 : IChapterReader
{
private enum ID3Frame : uint
{
ID3FrameEncoding = 1,
ID3FrameShortDescription = 1,
ID3FramePictureType = 1,
ID3FrameFlags = 2,
ID3FrameLanguage = 3,
ID3FrameSize = 4,
ID3FrameID = 4,
ID3FrameFrame = 10
}
private enum ID3FramePositions : uint
{
ID3FramePositionID = 0,
ID3FramePositionSize = ID3FramePositionID + ID3Frame.ID3FrameID,
ID3FramePositionFlags = ID3FramePositionSize + ID3Frame.ID3FrameSize,
ID3FramePositionEncoding = ID3FramePositionFlags + ID3Frame.ID3FrameFlags,
ID3FramePositionText = ID3FramePositionEncoding + ID3Frame.ID3FrameEncoding
}
private enum ID3TextEncoding : uint
{
ID3TextEncodingISO = 0,
ID3TextEncodingUTF16 = 1,
ID3TextEncodingUTF16BE = 2,
ID3TextEncodingUTF8 = 3
}
private const uint ID3HeaderSize = 4;
private const string MetadataID3MetadataKeyChapter = "CHAP";
private const string MetadataID3MetadataKeyTableOfContents = "CTOC";
public IList<Chapter> GetChapters(AVAsset asset)
{
var its = asset.GetMetadataForFormat(new NSString(Constants.MetadataFormatId3));
var items = AVMetadataItem.FilterWithKey(
its,
new NSString(MetadataID3MetadataKeyChapter),
Constants.MetadataFormatId3);
var chapterIdentifiers = GetTableOfContents(its);
var chapters = new List<Chapter>();
foreach (var item in items)
{
var chapter = CreateChapter(item.DataValue, chapterIdentifiers);
chapters.Add(chapter);
}
return chapters.OrderBy(x => x.Time).ToList();
}
private string[] GetTableOfContents(AVMetadataItem[] metadata)
{
var tableOfContents = AVMetadataItem.FilterWithKey(
metadata,
new NSString(MetadataID3MetadataKeyTableOfContents),
Constants.MetadataFormatId3);
var toc = tableOfContents.First();
if (toc == null) return new string[]{};
var tocData = toc.DataValue;
uint flagSize = 1;
uint chapterCountSize = 1;
uint index = (uint)GetDataToTerm(tocData).Length + flagSize;
var numberOfChaptersData = tocData.Subdata(index, chapterCountSize);
long numberOfChapters = 0;
unsafe
{
numberOfChapters = ByteToInt((byte*) numberOfChaptersData.Bytes, chapterCountSize, 0);
}
var chapterData = tocData.Subdata(index + chapterCountSize, (uint)(tocData.Length - chapterCountSize - index));
var chapterIdentifiers = new List<string>();
var splitData = SplitByTerminator(chapterData);
if (numberOfChapters == 0)
{
numberOfChapters = (uint) splitData.Count;
}
foreach (var subData in splitData.Take((int)numberOfChapters).ToList())
{
try
{
var chapterIdentifier = NSString.FromData(subData, NSStringEncoding.UTF8);
chapterIdentifiers.Add(chapterIdentifier);
}
catch (Exception e)
{
// ignored
}
}
return chapterIdentifiers.ToArray();
}
private IList<NSData> SplitByTerminator(NSData data)
{
uint maxLength = 1;
byte[] buffer = new byte[maxLength];
bool terminated = false;
NSInputStream stream = NSInputStream.FromData(data);
IList<NSData> splitData = new List<NSData>();
stream.Open();
NSMutableData result = new NSMutableData();
while (stream.Read(buffer, maxLength) > 0)
{
result.AppendBytes(buffer, 1);
terminated = *(char *)buffer == '\0';
if (terminated) {
[splitData addObject:result];
result = [NSMutableData new];
}
}
[stream close];
return [splitData copy];
}
private Chapter CreateChapter(NSData data, string[] chapterIdentifiers)
{
var identifierData = GetDataToTerm(data);
var identifier = NSString.FromData(identifierData, NSStringEncoding.UTF8);
var index = (uint)identifierData.Length;
var startTimeData = data.Subdata(index, ID3HeaderSize);
var endTimeData = data.Subdata(index += ID3HeaderSize, ID3HeaderSize);
// var startOffsetData = data.Subdata(index += ID3HeaderSize, ID3HeaderSize);
// var endOffsetData = data.Subdata(index += ID3HeaderSize, ID3HeaderSize);
long startTime = 0;
long endTime = 0;
unsafe
{
startTime = ByteToInt((byte*)startTimeData.Bytes.ToPointer(), (uint)startTimeData.Length, 0);
endTime = ByteToInt((byte*)startTimeData.Bytes.ToPointer(), (uint)endTimeData.Length, 0);
// startTimeOffset = ByteToInt((byte*)startTimeData.Bytes.ToPointer(), (uint)startTimeData.Length, 0);
// endTimeOffset = ByteToInt((byte*)startTimeData.Bytes.ToPointer(), (uint)endTimeData.Length, 0);
}
var time = new CMTime(startTime, 1000);
var duration = new CMTime(endTime - startTime, 1000);
var chapter = new Chapter(time, duration)
{
Identifier = identifier,
IsHidden = chapterIdentifiers.Contains(identifier),
Title = GetTitle(data),
Url = GetUrl(data),
Artwork = GetArtwork(data)
};
return chapter;
}
private UIImage GetArtwork(NSData data)
{
// UIImage *result = nil;
//
// @try {
// NSRange range = [self rangeOfFrameWithID:AVMetadataID3MetadataKeyAttachedPicture inData:data];
// unsigned long loc = range.location;
//
// if (loc==NSNotFound) {
// return nil;
// }
//
// NSData *sizeData = SUBDATA(data, loc + ID3FramePositionSize, ID3FrameSize);
// NSInteger size = btoi((char *)sizeData.bytes, sizeData.length, 0);
//
// // NSData *textEncodingData = SUBDATA(data, loc + ID3FramePositionEncoding, ID3FrameEncoding);
// // NSInteger textEncodingValue = btoi((char *)textEncodingData.bytes, textEncodingData.length, 0);
// // NSInteger textEncoding = [self textEncoding:textEncodingValue];
//
// NSData *content = SUBDATA(data, loc + ID3FrameFrame + ID3FrameEncoding, size - ID3FrameEncoding);
//
// NSData *mimeTypeData = [self dataToTermInData:content];
// // NSString *mimeType = [NSString stringWithUTF8String:mimeTypeData.bytes];
//
// content = SUBDATA(content, mimeTypeData.length+ID3FrameEncoding, content.length-mimeTypeData.length-ID3FrameEncoding);
//
// NSData *imageDescriptionData = [self dataToTermInData:content];
// // NSString *imageDescriptionText = [NSString stringWithUTF8String:imageDescriptionData.bytes];
//
// content = SUBDATA(content, imageDescriptionData.length, content.length-imageDescriptionData.length);
//
// result = [UIImage imageWithData:content];
// }
// @catch (NSException *exception) {
// //
// }
// @finally {
// return result;
// }
}
private string GetUrl(NSData data)
{
// NSString *result = nil;
//
// @try {
// NSRange range = [self rangeOfFrameWithID:AVMetadataID3MetadataKeyUserURL inData:data];
// if (range.location == NSNotFound) {
// return result;
// }
//
// unsigned long loc = range.location;
//
// NSData *sizeData = SUBDATA(data, loc + ID3FramePositionSize, ID3FrameSize);
// NSInteger size = btoi((char *)sizeData.bytes, sizeData.length, 0);
//
// NSData *encData = SUBDATA(data, loc + ID3FramePositionEncoding, ID3FrameEncoding);
// NSInteger encValue = btoi((char *)encData.bytes, encData.length, 0);
// NSInteger encoding = [self textEncoding:encValue];
//
// NSData *content = SUBDATA(data, loc + ID3FrameFrame + ID3FrameEncoding, size - ID3FrameEncoding);
// NSUInteger index = [self dataToTermInData:content].length;
// NSData *url = SUBDATA(content, index, size - index - ID3FrameEncoding);
// NSString *str = [[NSString alloc] initWithBytes:url.bytes length:url.length encoding:encoding];
//
// result = [str stringByRemovingPercentEncoding];
// } @catch (NSException * e) {
// //
// } @finally {
// return result;
// }
}
private string GetTitle(NSData data)
{
// NSString *result = nil;
// @try {
// NSRange range = [self rangeOfFrameWithID:AVMetadataID3MetadataKeyTitleDescription inData:data];
// unsigned long loc = range.location;
//
// NSData *sizeData = SUBDATA(data, loc + ID3FramePositionSize, ID3FrameSize);
// NSUInteger size = btoi((char *)sizeData.bytes, sizeData.length, 0);
//
// NSData *encData = SUBDATA(data, loc + ID3FramePositionEncoding, ID3FrameEncoding);
// NSInteger encValue = btoi((char *)encData.bytes, encData.length, 0);
// NSInteger encoding = [self textEncoding:encValue];
//
// NSData *titleData = SUBDATA(data, loc + ID3FramePositionText, size - ID3FrameEncoding);
//
// result = [[NSString alloc] initWithBytes:titleData.bytes
// length:titleData.length
// encoding:encoding];
// }
// @catch (NSException *exception) {
// //
// }
// @finally {
// return result;
// }
}
private NSData GetDataToTerm(NSData tocData)
{
throw new System.NotImplementedException();
}
private static unsafe bool IsSet(byte* bytes, uint size)
{
var isSet = false;
var index = size;
while (index-- != 0 && !isSet) {
isSet = bytes[index] != '\xff';
}
return isSet;
}
private static unsafe long ByteToInt(byte* bytes, uint size, uint offset)
{
int i;
long result = 0x00;
for(i = 0; i < size; i++) {
result <<= 8;
result |= bytes[offset + i];
}
return result;
}
}
}
using System.Collections.Generic;
using System.Linq;
using ATL;
using AVFoundation;
using CoreMedia;
using Foundation;
using UIKit;
namespace Chapters
{
internal class ChapterReaderMp4 : IChapterReader
{
public IList<Chapter> GetChapters(AVAsset asset)
{
var languages = LanguagesForAsset(asset);
var groups = asset.GetChapterMetadataGroupsBestMatchingPreferredLanguages(languages);
var chapterCount = groups.Length;
IList<Chapter> chapters = new List<Chapter>(chapterCount);
foreach (var group in groups)
{
var time = TimeFromGroup(group);
var duration = DurationFromGroup(group);
var title = TitleFromGroup(group);
var chapter = new Chapter(time, duration)
{
Title = title,
Artwork = ArtworkFromGroup(group),
Url = UrlFromGroup(group, title)
};
chapters.Add(chapter);
}
return chapters;
}
private static string[] LanguagesForAsset(AVAsset asset)
{
var languages = NSLocale.PreferredLanguages.ToList();
languages.AddRange(asset.AvailableChapterLocales.ToList().Select(x => x.Identifier));
return languages.ToArray();
}
private static CMTime TimeFromGroup(AVTimedMetadataGroup group)
{
var items = ItemsFromArray(group.Items, "title");
var item = items[0];
return item.Time;
}
private static CMTime DurationFromGroup(AVTimedMetadataGroup group)
{
var items = ItemsFromArray(group.Items, "title");
var item = items[0];
return item.Duration;
}
private static string UrlFromGroup(AVTimedMetadataGroup group, string title)
{
var items = ItemsFromArray(group.Items, "title");
string href = null;
foreach (var item in items)
{
if (item.StringValue == title && item.ExtraAttributes != null)
{
href = (NSString) item.ExtraAttributes["HREF"];
break;
}
}
return href;
}
private static UIImage ArtworkFromGroup(AVTimedMetadataGroup group)
{
var items = ItemsFromArray(group.Items, "title");
foreach (var item in items)
{
var data = item.DataValue;
if (data != null)
{
return UIImage.LoadFromData(data);
}
}
return null;
}
private static string TitleFromGroup(AVTimedMetadataGroup group)
{
var items = ItemsFromArray(group.Items, "title");
var item = items[0];
return item.StringValue;
}
private static AVMetadataItem[] ItemsFromArray(AVMetadataItem[] items, string key)
{
return AVMetadataItem.FilterWithKey(items, new NSString(key), null);
}
}
}
namespace Chapters
{
internal static class Constants
{
public const string MetadataFormatApple = @"com.apple.itunes";
public const string MetadataFormatMp4 = @"org.mp4ra";
public const string MetadataFormatId3 = @"org.id3";
}
}
using System.Collections.Generic;
using AVFoundation;
namespace Chapters
{
internal interface IChapterReader
{
IList<Chapter> GetChapters(AVAsset asset);
}
}
using Foundation;
namespace Chapters
{
public static class NSDataExtensions
{
public static NSData Subdata(this NSData data, uint location, uint length)
{
return data.Subdata(new NSRange((int)location, (int)length));
}
}
}
@Zeugma440
Copy link

Zeugma440 commented Feb 9, 2021

A few notes at first read :

MP4

MP3

  • I've always found the CTOC frame to be accessory, even optional, even though the specs are unclear about it. You should consider the CHAP frames to be your central source of information, with CTOC providing additional descriptive data, not the other way around.

  • Even though it might look like a good idea while reading the specs, relying on chapter identifiers might not work with badly formatted files and files with no CTOC frame. I'd advise you to organize your chapters according to their order, no more, no less. Imho, chapter identifiers from the CTOC frame should be a descriptive attribute, not a cornerstone.

  • Line 94 : Some people want to add more than 255 chapters and rely on the actual chapters count rather than the "entry count" field. I think your final number of chapters should rely on splitData.Count entirely.

@Zeugma440
Copy link

About is_set, you'll have to know that the vast majority of the files I've seen so far use the time marker rather than the offset marker to signal their chapters. Playing with offsets doesn't seem like a core feature to me.

The explanation about the piece of code you've highlighted is in the specs :

The Start offset is a zero-based count of bytes from the beginning of the file to the first byte of the first audio frame in the chapter. If these bytes are all set to 0xFF then the value should be ignored and the start time value should be utilized.

The End offset is a zero-based count of bytes from the beginning of the file to the first byte of the audio frame following the end of the chapter. If these bytes are all set to 0xFF then the value should be ignored and the end time value should be utilized.

Please not how setting the offset fields to 0xff is the convention to say they are unused (or "unset", as the code calls it)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment