diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index e15a63473..fdfc58779 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -102,7 +102,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val metadata = ImageMetadataReader.readMetadata(input) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) - + val uuidDirCount = HashMap() for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory @@ -110,6 +110,16 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { }) { // directory name var dirName = dir.name + if (dir is Mp4UuidBoxDirectory) { + val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-') + dirName += " $uuid" + + val count = uuidDirCount[uuid] ?: 0 + uuidDirCount[uuid] = count + 1 + if (count > 0) { + dirName += " ($count)" + } + } // exclude directories known to be redundant with info derived on the Dart side // they are excluded by name instead of runtime type because excluding `Mp4Directory` @@ -168,10 +178,20 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } if (dir is Mp4UuidBoxDirectory) { - if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) { - val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) - metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) - metadataMap.remove(dirName) + when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) { + GSpherical.SPHERICAL_VIDEO_V1_UUID -> { + val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) + metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) + metadataMap.remove(dirName) + } + SonyVideoMetadata.USMT_UUID -> { + val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) + val fields = SonyVideoMetadata.parseUsmt(bytes) + if (fields.isNotEmpty()) { + dirMap.remove("Data") + dirMap.putAll(fields) + } + } } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SonyVideoMetadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SonyVideoMetadata.kt new file mode 100644 index 000000000..181035c5f --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SonyVideoMetadata.kt @@ -0,0 +1,73 @@ +package deckers.thibault.aves.metadata + +import java.math.BigInteger +import java.nio.charset.Charset +import java.util.* + +object SonyVideoMetadata { + const val PROF_UUID = "50524f46-21d2-4fce-bb88-695cfac9c740" + const val USMT_UUID = "55534d54-21d2-4fce-bb88-695cfac9c740" + + fun parseUsmt(data: ByteArray): HashMap { + val dirMap = HashMap() + var bytes = data + var size = BigInteger(bytes.copyOfRange(0, 4)).toInt() + val box = String(bytes.copyOfRange(4, 8)) + if (box == "MTDT") { + val blockCount = BigInteger(bytes.copyOfRange(8, 10)).toInt() + bytes = bytes.copyOfRange(10, size) + size -= 10 + + for (i in 0 until blockCount) { + // cf https://github.com/sonyxperiadev/MultimediaForAndroidLibrary + // cf https://rubenlaguna.com/post/2007-02-25-how-to-read-title-in-sony-psp-mp4-files/ + + val blockSize = BigInteger(bytes.copyOfRange(0, 2)).toInt() + + val blockType = BigInteger(bytes.copyOfRange(2, 6)).toInt() + + // ISO 639 language code written as 3 groups of 5 bits for each letter (ascii code - 0x60) + // e.g. 0x55c4 -> 10101 01110 00100 -> 21 14 4 -> "und" + val language = BigInteger(bytes.copyOfRange(6, 8)).toInt() + val c1 = Character.toChars((language shr 10 and 0x1F) + 0x60)[0] + val c2 = Character.toChars((language shr 5 and 0x1F) + 0x60)[0] + val c3 = Character.toChars((language and 0x1F) + 0x60)[0] + val languageString = "$c1$c2$c3" + + val encoding = BigInteger(bytes.copyOfRange(8, 10)).toInt() + + val payload = bytes.copyOfRange(10, blockSize) + val payloadString = when (encoding) { + // 0x00: short array + 0x00 -> { + payload + .asList() + .chunked(2) + .map { (h, l) -> ((h.toInt() shl 8) + l.toInt()).toShort() } + .joinToString() + } + // 0x01: string + 0x01 -> String(payload, Charset.forName("UTF-16BE")).trim() + // 0x101: artwork/icon + else -> "0x${payload.joinToString("") { "%02x".format(it) }}" + } + + val blockTypeString = when (blockType) { + 0x01 -> "Title" + 0x03 -> "Timestamp" + 0x04 -> "Creator name" + 0x0A -> "End of track" + else -> "0x${"%02x".format(blockType)}" + } + + val prefix = if (blockCount > 1) "$i/" else "" + dirMap["${prefix}Data"] = payloadString + dirMap["${prefix}Language"] = languageString + dirMap["${prefix}Type"] = blockTypeString + + bytes = bytes.copyOfRange(blockSize, bytes.size) + } + } + return dirMap + } +} \ No newline at end of file diff --git a/lib/model/video/codecs.dart b/lib/model/video/codecs.dart new file mode 100644 index 000000000..aa29d560b --- /dev/null +++ b/lib/model/video/codecs.dart @@ -0,0 +1,16 @@ +class Codecs { + static const aac = 'aac'; + static const ac3 = 'ac3'; + static const eac3 = 'eac3'; + static const h264 = 'h264'; + static const hevc = 'hevc'; + static const matroska = 'matroska'; + static const mpeg4 = 'mpeg4'; + static const mpts = 'mpegts'; + static const opus = 'opus'; + static const pgs = 'hdmv_pgs_subtitle'; + static const subrip = 'subrip'; + static const theora = 'theora'; + static const vorbis = 'vorbis'; + static const webm = 'webm'; +} diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index dd8a4f01e..1724305b9 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/video/channel_layouts.dart'; -import 'package:aves/model/video/h264.dart'; +import 'package:aves/model/video/codecs.dart'; import 'package:aves/model/video/keys.dart'; +import 'package:aves/model/video/profiles/aac.dart'; +import 'package:aves/model/video/profiles/h264.dart'; +import 'package:aves/model/video/profiles/hevc.dart'; import 'package:aves/ref/languages.dart'; import 'package:aves/ref/mp4.dart'; import 'package:aves/utils/file_utils.dart'; @@ -20,16 +23,19 @@ class VideoMetadataFormatter { static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)'); static final _locationPattern = RegExp(r'([+-][.0-9]+)'); static final Map _codecNames = { - 'ac3': 'AC-3', - 'eac3': 'E-AC-3', - 'h264': 'AVC (H.264)', - 'hdmv_pgs_subtitle': 'PGS', - 'hevc': 'HEVC (H.265)', - 'matroska': 'Matroska', - 'mpeg4': 'MPEG-4 Visual', - 'mpegts': 'MPEG-TS', - 'subrip': 'SubRip', - 'webm': 'WebM', + Codecs.ac3: 'AC-3', + Codecs.eac3: 'E-AC-3', + Codecs.h264: 'AVC (H.264)', + Codecs.hevc: 'HEVC (H.265)', + Codecs.matroska: 'Matroska', + Codecs.mpeg4: 'MPEG-4 Visual', + Codecs.mpts: 'MPEG-TS', + Codecs.opus: 'Opus', + Codecs.pgs: 'PGS', + Codecs.subrip: 'SubRip', + Codecs.theora: 'Theora', + Codecs.vorbis: 'Vorbis', + Codecs.webm: 'WebM', }; static Future getVideoMetadata(AvesEntry entry) async { @@ -153,15 +159,36 @@ class VideoMetadataFormatter { } break; case Keys.codecProfileId: - if (codec == 'h264') { + { final profile = int.tryParse(value); - final levelString = info[Keys.codecLevel]; - if (profile != null && profile != 0 && levelString != null) { - final level = int.tryParse(levelString) ?? 0; - save('Codec Profile', H264.formatProfile(profile, level)); + if (profile != null) { + String? profileString; + switch (codec) { + case Codecs.h264: + case Codecs.hevc: + { + final levelString = info[Keys.codecLevel]; + if (levelString != null) { + final level = int.tryParse(levelString) ?? 0; + if (codec == Codecs.h264) { + profileString = H264.formatProfile(profile, level); + } else { + profileString = Hevc.formatProfile(profile, level); + } + } + break; + } + case Codecs.aac: + profileString = AAC.formatProfile(profile); + break; + default: + profileString = profile.toString(); + break; + } + save('Codec Profile', profileString); } + break; } - break; case Keys.compatibleBrands: final formattedBrands = RegExp(r'.{4}').allMatches(value).map((m) { final brand = m.group(0)!; diff --git a/lib/model/video/profiles/aac.dart b/lib/model/video/profiles/aac.dart new file mode 100644 index 000000000..ffeb1e8ee --- /dev/null +++ b/lib/model/video/profiles/aac.dart @@ -0,0 +1,33 @@ +class AAC { + static const profileMain = 0; + static const profileLowComplexity = 1; + static const profileScalableSampleRate = 2; + static const profileLongTermPrediction = 3; + static const profileHighEfficiency = 4; + static const profileHighEfficiencyV2 = 28; + static const profileLowDelay = 22; + static const profileLowDelayV2 = 38; + + static String formatProfile(int profileIndex) { + switch (profileIndex) { + case profileMain: + return 'Main'; + case profileLowComplexity: + return 'LC'; + case profileLongTermPrediction: + return 'LTP'; + case profileScalableSampleRate: + return 'SSR'; + case profileHighEfficiency: + return 'HE-AAC'; + case profileHighEfficiencyV2: + return 'HE-AAC v2'; + case profileLowDelay: + return 'LD'; + case profileLowDelayV2: + return 'ELD'; + default: + return '$profileIndex'; + } + } +} diff --git a/lib/model/video/h264.dart b/lib/model/video/profiles/h264.dart similarity index 81% rename from lib/model/video/h264.dart rename to lib/model/video/profiles/h264.dart index 675875244..c9fe1dae6 100644 --- a/lib/model/video/h264.dart +++ b/lib/model/video/profiles/h264.dart @@ -1,21 +1,29 @@ class H264 { static const profileConstrained = 1 << 9; static const profileIntra = 1 << 11; + static const profileBaseline = 66; static const profileConstrainedBaseline = 66 | profileConstrained; static const profileMain = 77; static const profileExtended = 88; static const profileHigh = 100; static const profileHigh10 = 110; - static const profileHigh10Intra = 110 | profileIntra; static const profileHigh422 = 122; - static const profileHigh422Intra = 122 | profileIntra; static const profileHigh444 = 144; static const profileHigh444Predictive = 244; + + // intra + static const profileHigh10Intra = 110 | profileIntra; + static const profileHigh422Intra = 122 | profileIntra; static const profileHigh444Intra = 244 | profileIntra; static const profileCAVLC444 = 44; - static String formatProfile(int profileIndex, int level) { + // multiview + static const profileMultiviewHigh = 118; + static const profileStereoHigh = 128; + static const profileMultiviewDepthHigh = 138; + + static String formatProfile(int profileIndex, int levelIndex) { String profile; switch (profileIndex) { case profileBaseline: @@ -60,7 +68,8 @@ class H264 { default: return '$profileIndex'; } - if (level < 10) return profile; - return '$profile Profile, Level ${level % 10 == 0 ? level ~/ 10 : level / 10}'; + if (levelIndex <= 0) return profile; + final level = (levelIndex / 10.0).toStringAsFixed(levelIndex % 10 != 0 ? 1 : 0); + return '$profile Profile, Level $level'; } } diff --git a/lib/model/video/profiles/hevc.dart b/lib/model/video/profiles/hevc.dart new file mode 100644 index 000000000..4678fa699 --- /dev/null +++ b/lib/model/video/profiles/hevc.dart @@ -0,0 +1,29 @@ +class Hevc { + static const profileMain = 1; + static const profileMain10 = 2; + static const profileMainStillPicture = 3; + static const profileRExt = 4; + + static String formatProfile(int profileIndex, int levelIndex) { + String profile; + switch (profileIndex) { + case profileMain: + profile = 'Main'; + break; + case profileMain10: + profile = 'Main 10'; + break; + case profileMainStillPicture: + profile = 'Main Still Picture'; + break; + case profileRExt: + profile = 'Format Range'; + break; + default: + return '$profileIndex'; + } + if (levelIndex <= 0) return profile; + final level = (levelIndex / 30.0).toStringAsFixed(levelIndex % 10 != 0 ? 1 : 0); + return '$profile Profile, Level $level'; + } +} diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index e35ed2ee5..c593865d0 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -41,6 +41,7 @@ class MimeTypes { static const anyVideo = 'video/*'; static const avi = 'video/avi'; + static const mov = 'video/quicktime'; static const mp2t = 'video/mp2t'; // .m2ts static const mp4 = 'video/mp4'; diff --git a/lib/utils/mime_utils.dart b/lib/utils/mime_utils.dart index e419cd529..46afcf635 100644 --- a/lib/utils/mime_utils.dart +++ b/lib/utils/mime_utils.dart @@ -1,12 +1,16 @@ +import 'package:aves/ref/mime_types.dart'; + class MimeUtils { static String displayType(String mime) { switch (mime) { - case 'image/x-icon': - return 'ICO'; - case 'image/x-jg': + case MimeTypes.art: return 'ART'; - case 'image/vnd.adobe.photoshop': - case 'image/x-photoshop': + case MimeTypes.ico: + return 'ICO'; + case MimeTypes.mov: + return 'MOV'; + case MimeTypes.psdVnd: + case MimeTypes.psdX: return 'PSD'; default: final patterns = [