info: improved video sections

This commit is contained in:
Thibault Deckers 2021-04-14 12:16:06 +09:00
parent fb9defb131
commit 04e7f76a66
17 changed files with 536 additions and 255 deletions

View file

@ -18,14 +18,11 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Tag import com.drew.metadata.Tag
import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.* import com.drew.metadata.exif.*
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory import com.drew.metadata.iptc.IptcDirectory
import com.drew.metadata.mov.QuickTimeDirectory
import com.drew.metadata.mov.media.QuickTimeMediaDirectory
import com.drew.metadata.mp4.Mp4Directory
import com.drew.metadata.mp4.media.Mp4MediaDirectory
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.png.PngDirectory import com.drew.metadata.png.PngDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
@ -62,9 +59,8 @@ import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeifLike import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
@ -80,7 +76,6 @@ import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.File import java.io.File
import java.text.ParseException import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -120,9 +115,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) { for (dir in metadata.directories.filter {
it.tagCount > 0
&& it !is FileTypeDirectory
&& it !is AviDirectory
}) {
// directory name // directory name
var dirName = dir.name var dirName = dir.name
// 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`
// would also exclude derived directories, such as `Mp4UuidBoxDirectory`
if (allMetadataRedundantDirNames.contains(dirName)) continue
// optional parent to distinguish child directories of the same type // optional parent to distinguish child directories of the same type
dir.parent?.name?.let { dirName = "$it/$dirName" } dir.parent?.name?.let { dirName = "$it/$dirName" }
@ -151,33 +156,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} else { } else {
dirMap.putAll(tags.map { tagMapper(it) }) dirMap.putAll(tags.map { tagMapper(it) })
} }
} else if (dir is Mp4Directory || dir is QuickTimeDirectory) {
tags.map { tag ->
val tagName = tag.tagName
when (tag.tagType) {
Mp4Directory.TAG_CREATION_TIME,
Mp4Directory.TAG_MODIFICATION_TIME,
Mp4MediaDirectory.TAG_CREATION_TIME,
Mp4MediaDirectory.TAG_MODIFICATION_TIME,
QuickTimeMediaDirectory.TAG_CREATION_TIME,
QuickTimeMediaDirectory.TAG_MODIFICATION_TIME -> {
val date = dir.getObject(tag.tagType)
if (date is Date) {
// only consider dates after Epoch time
date.takeIf { it.time > 0 }?.let {
// harmonize date format for further processing on Dart side
dirMap[tagName] = MP4_DATE_FORMAT.format(date)
}
} else {
dirMap[tagName] = tag.description
}
}
Mp4MediaDirectory.TAG_LANGUAGE_CODE -> {
tag.description.takeIf { it != "```" && it != "und" }?.let { dirMap[tagName] = it }
}
else -> dirMap[tagName] = tag.description
}
}
} else { } else {
dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
} }
@ -237,7 +215,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (isMultimedia(mimeType)) { if (isVideo(mimeType)) {
// this is used as fallback when the video metadata cannot be found on the Dart side
// do not include HEIC here
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri) val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
if (mediaDir.isNotEmpty()) { if (mediaDir.isNotEmpty()) {
metadataMap[Metadata.DIR_MEDIA] = mediaDir metadataMap[Metadata.DIR_MEDIA] = mediaDir
@ -298,7 +278,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val metadataMap = HashMap<String, Any>() val metadataMap = HashMap<String, Any>()
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap) getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
if (isMultimedia(mimeType)) { if (isVideo(mimeType) || isHeic(mimeType)) {
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap) getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
} }
@ -514,7 +494,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (isHeifLike(mimeType)) { if (isHeic(mimeType)) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
if (it > 1) flags = flags or MASK_IS_MULTIPAGE if (it > 1) flags = flags or MASK_IS_MULTIPAGE
} }
@ -622,7 +602,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) } getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) }
} }
} }
} else if (isHeifLike(mimeType)) { } else if (isHeic(mimeType)) {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key)) if (this.containsKey(key)) save(this.getInteger(key))
} }
@ -907,9 +887,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata" const val CHANNEL = "deckers.thibault/aves/metadata"
private val MP4_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXX", Locale.ROOT).apply { private val allMetadataRedundantDirNames = setOf(
timeZone = TimeZone.getTimeZone("UTC") "MP4",
} "MP4 Sound",
"MP4 Video",
"QuickTime",
"QuickTime Sound",
"QuickTime Video",
)
// catalog metadata & page info // catalog metadata & page info
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"

View file

@ -40,7 +40,7 @@ class RegionFetcher internal constructor(
imageHeight: Int, imageHeight: Int,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
if (MimeTypes.isHeifLike(mimeType) && pageId != null) { if (MimeTypes.isHeic(mimeType) && pageId != null) {
val id = Pair(uri, pageId) val id = Pair(uri, pageId)
fetch( fetch(
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) }, uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },

View file

@ -18,7 +18,7 @@ import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeifLike import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
@ -42,7 +42,7 @@ class ThumbnailFetcher internal constructor(
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
private val tiffFetch = mimeType == MimeTypes.TIFF private val tiffFetch = mimeType == MimeTypes.TIFF
private val multiTrackFetch = isHeifLike(mimeType) && pageId != null private val multiTrackFetch = isHeic(mimeType) && pageId != null
private val customFetch = tiffFetch || multiTrackFetch private val customFetch = tiffFetch || multiTrackFetch
fun fetch() { fun fetch() {

View file

@ -16,7 +16,7 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeifLike import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
@ -115,7 +115,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val model: Any = if (isHeifLike(mimeType) && pageId != null) { val model: Any = if (isHeic(mimeType) && pageId != null) {
MultiTrackImage(activity, uri, pageId) MultiTrackImage(activity, uri, pageId)
} else if (mimeType == MimeTypes.TIFF) { } else if (mimeType == MimeTypes.TIFF) {
TiffImage(activity, uri, pageId) TiffImage(activity, uri, pageId)

View file

@ -130,7 +130,7 @@ abstract class ImageProvider {
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension) val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) { val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(context, sourceUri, pageId) MultiTrackImage(context, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) { } else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId) TiffImage(context, sourceUri, pageId)

View file

@ -43,9 +43,7 @@ object MimeTypes {
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF) fun isHeic(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
fun isRaw(mimeType: String): Boolean { fun isRaw(mimeType: String): Boolean {
return when (mimeType) { return when (mimeType) {

View file

@ -366,7 +366,7 @@ class AvesEntry {
String _durationText; String _durationText;
String get durationText { String get durationText {
_durationText ??= formatDuration(Duration(milliseconds: durationMillis ?? 0)); _durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
return _durationText; return _durationText;
} }

View file

@ -13,7 +13,7 @@ class H264 {
static const profileHigh444 = 144; static const profileHigh444 = 144;
static const profileHigh444Predictive = 244; static const profileHigh444Predictive = 244;
static const profileHigh444Intra = 244 | profileIntra; static const profileHigh444Intra = 244 | profileIntra;
static const profileCAVLC_444 = 44; static const profileCAVLC444 = 44;
static String formatProfile(int profileIndex, int level) { static String formatProfile(int profileIndex, int level) {
String profile; String profile;
@ -54,7 +54,7 @@ class H264 {
case profileHigh444Intra: case profileHigh444Intra:
profile = 'High 4:4:4 Intra'; profile = 'High 4:4:4 Intra';
break; break;
case profileCAVLC_444: case profileCAVLC444:
profile = 'CAVLC 4:4:4'; profile = 'CAVLC 4:4:4';
break; break;
default: default:

48
lib/model/video/keys.dart Normal file
View file

@ -0,0 +1,48 @@
// keys returned by fijkplayer when getting media and streams info
// they originate from FFmpeg, fijkplayer, and other software
// that write additional metadata to media files
class Keys {
static const androidCaptureFramerate = 'com.android.capture.fps';
static const androidVersion = 'com.android.version';
static const bps = 'bps';
static const bitrate = 'bitrate';
static const byteCount = 'number_of_bytes';
static const channelLayout = 'channel_layout';
static const codecLevel = 'codec_level';
static const codecName = 'codec_name';
static const codecPixelFormat = 'codec_pixel_format';
static const codecProfileId = 'codec_profile_id';
static const compatibleBrands = 'compatible_brands';
static const creationTime = 'creation_time';
static const date = 'date';
static const duration = 'duration';
static const durationMicros = 'duration_us';
static const encoder = 'encoder';
static const filename = 'filename';
static const fpsDen = 'fps_den';
static const fpsNum = 'fps_num';
static const frameCount = 'number_of_frames';
static const handlerName = 'handler_name';
static const height = 'height';
static const index = 'index';
static const language = 'language';
static const location = 'location';
static const majorBrand = 'major_brand';
static const mediaFormat = 'format';
static const mediaType = 'media_type';
static const minorVersion = 'minor_version';
static const rotate = 'rotate';
static const sampleRate = 'sample_rate';
static const sarDen = 'sar_den';
static const sarNum = 'sar_num';
static const startMicros = 'start_us';
static const statisticsTags = '_statistics_tags';
static const statisticsWritingApp = '_statistics_writing_app';
static const statisticsWritingDateUtc = '_statistics_writing_date_utc';
static const streams = 'streams';
static const tbrDen = 'tbr_den';
static const tbrNum = 'tbr_num';
static const streamType = 'type';
static const track = 'track';
static const width = 'width';
}

View file

@ -0,0 +1,310 @@
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/keys.dart';
import 'package:aves/ref/languages.dart';
import 'package:aves/ref/mp4.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart';
class VideoMetadataFormatter {
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
static final _locationPattern = RegExp(r'([+-][.0-9]+)');
static final Map<String, String> _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',
};
static Future<Map> getVideoMetadata(AvesEntry entry) async {
final player = FijkPlayer();
await player.setDataSource(entry.uri, autoPlay: false);
final completer = Completer();
void onChange() {
if ([FijkState.prepared, FijkState.error].contains(player.state)) {
completer.complete();
}
}
player.addListener(onChange);
await player.prepareAsync();
await completer.future;
player.removeListener(onChange);
final info = await player.getInfo();
await player.release();
return info;
}
// pattern to extract optional language code suffix, e.g. 'location-eng'
static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$');
static Map<String, String> formatInfo(Map info) {
final dir = <String, String>{};
final streamType = info[Keys.streamType];
final codec = info[Keys.codecName];
for (final kv in info.entries) {
final value = kv.value;
if (value != null) {
try {
String key;
String keyLanguage;
// some keys have a language suffix, but they may be duplicates
// we only keep the root key when they have the same value as the same key with no language
final languageMatch = keyWithLanguagePattern.firstMatch(kv.key);
if (languageMatch != null) {
final code = languageMatch.group(2);
final native = _formatLanguage(code);
if (native != code) {
final root = languageMatch.group(1);
final rootValue = info[root];
// skip if it is a duplicate of the same entry with no language
if (rootValue == value) continue;
key = root;
if (info.keys.cast<String>().where((k) => k.startsWith('$root-')).length > 1) {
// only keep language when multiple languages are present
keyLanguage = native;
}
}
}
key = (key ?? (kv.key as String)).toLowerCase();
void save(String key, String value) {
if (value != null) {
dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value;
}
}
switch (key) {
case Keys.codecLevel:
case Keys.fpsNum:
case Keys.handlerName:
case Keys.index:
case Keys.sarNum:
case Keys.streams:
case Keys.tbrNum:
case Keys.tbrDen:
case Keys.statisticsTags:
case Keys.streamType:
break;
case Keys.androidCaptureFramerate:
final captureFps = double.parse(value);
save('Capture Frame Rate', '${roundToPrecision(captureFps, decimals: 3).toString()} FPS');
break;
case Keys.androidVersion:
save('Android Version', value);
break;
case Keys.bitrate:
case Keys.bps:
save('Bit Rate', _formatMetric(value, 'b/s'));
break;
case Keys.byteCount:
save('Size', _formatFilesize(value));
break;
case Keys.channelLayout:
save('Channel Layout', _formatChannelLayout(value));
break;
case Keys.codecName:
save('Format', _formatCodecName(value));
break;
case Keys.codecPixelFormat:
if (streamType == StreamTypes.video) {
// this is just a short name used by FFmpeg
// user-friendly descriptions for related enums are defined in libavutil/pixfmt.h
save('Pixel Format', (value as String).toUpperCase());
}
break;
case Keys.codecProfileId:
if (codec == 'h264') {
final profile = int.tryParse(value);
if (profile != null && profile != 0) {
final level = int.tryParse(info[Keys.codecLevel]);
save('Codec Profile', H264.formatProfile(profile, level));
}
}
break;
case Keys.compatibleBrands:
save('Compatible Brands', RegExp(r'.{4}').allMatches(value).map((m) => _formatBrand(m.group(0))).join(', '));
break;
case Keys.creationTime:
save('Creation Time', _formatDate(value));
break;
case Keys.date:
if (value != '0') {
final charCount = (value as String)?.length ?? 0;
save(charCount == 4 ? 'Year' : 'Date', value);
}
break;
case Keys.duration:
save('Duration', _formatDuration(value));
break;
case Keys.durationMicros:
if (value != 0) save('Duration', formatPreciseDuration(Duration(microseconds: value)));
break;
case Keys.fpsDen:
save('Frame Rate', '${roundToPrecision(info[Keys.fpsNum] / info[Keys.fpsDen], decimals: 3).toString()} FPS');
break;
case Keys.frameCount:
save('Frame Count', value);
break;
case Keys.height:
save('Height', '$value pixels');
break;
case Keys.language:
if (value != 'und') save('Language', _formatLanguage(value));
break;
case Keys.location:
save('Location', _formatLocation(value));
break;
case Keys.majorBrand:
save('Major Brand', _formatBrand(value));
break;
case Keys.mediaFormat:
save('Format', (value as String).splitMapJoin(',', onMatch: (s) => ', ', onNonMatch: _formatCodecName));
break;
case Keys.mediaType:
save('Media Type', value);
break;
case Keys.minorVersion:
if (value != '0') save('Minor Version', value);
break;
case Keys.rotate:
save('Rotation', '$value°');
break;
case Keys.sampleRate:
save('Sample Rate', _formatMetric(value, 'Hz'));
break;
case Keys.sarDen:
final sarNum = info[Keys.sarNum];
final sarDen = info[Keys.sarDen];
// skip common square pixels (1:1)
if (sarNum != sarDen) save('SAR', '$sarNum:$sarDen');
break;
case Keys.startMicros:
if (value != 0) save('Start', formatPreciseDuration(Duration(microseconds: value)));
break;
case Keys.statisticsWritingApp:
save('Stats Writing App', value);
break;
case Keys.statisticsWritingDateUtc:
save('Stats Writing Date', _formatDate(value));
break;
case Keys.track:
if (value != '0') save('Track', value);
break;
case Keys.width:
save('Width', '$value pixels');
break;
default:
save(key.toSentenceCase(), value.toString());
}
} catch (error) {
debugPrint('failed to process video info key=${kv.key} value=${kv.value}, error=$error');
}
}
}
return dir;
}
static String _formatBrand(String value) => Mp4.brands[value] ?? value;
static String _formatChannelLayout(value) => ChannelLayouts.names[value] ?? 'unknown ($value)';
static String _formatCodecName(String value) => _codecNames[value] ?? value?.toUpperCase()?.replaceAll('_', ' ');
// input example: '2021-04-12T09:14:37.000000Z'
static String _formatDate(String value) {
final date = DateTime.tryParse(value);
if (date == null) return value;
if (date == _epoch) return null;
return date.toIso8601String();
}
// input example: '00:00:05.408000000'
static String _formatDuration(String value) {
final match = _durationPattern.firstMatch(value);
if (match != null) {
final h = int.tryParse(match.group(1));
final m = int.tryParse(match.group(2));
final s = int.tryParse(match.group(3));
final millis = double.tryParse(match.group(4));
if (h != null && m != null && s != null && millis != null) {
return formatPreciseDuration(Duration(
hours: h,
minutes: m,
seconds: s,
milliseconds: (millis * 1000).toInt(),
));
}
}
return value;
}
static String _formatFilesize(String value) {
final size = int.tryParse(value);
return size != null ? formatFilesize(size) : value;
}
static String _formatLanguage(String value) {
final language = Language.living639_2.firstWhere((language) => language.iso639_2 == value, orElse: () => null);
return language?.native ?? value;
}
// format ISO 6709 input, e.g. '+37.5090+127.0243/' (Samsung), '+51.3328-000.7053+113.474/' (Apple)
static String _formatLocation(String value) {
final matches = _locationPattern.allMatches(value);
if (matches.isNotEmpty) {
final coordinates = matches.map((m) => double.tryParse(m.group(0))).toList();
if (coordinates.every((c) => c == 0)) return null;
return coordinates.join(', ');
}
return value;
}
static String _formatMetric(dynamic size, String unit, {int round = 2}) {
if (size is String) {
final parsed = int.tryParse(size);
if (parsed == null) return size;
size = parsed;
}
const divider = 1000;
if (size < divider) return '$size $unit';
if (size < divider * divider && size % divider == 0) {
return '${(size / divider).toStringAsFixed(0)} K$unit';
}
if (size < divider * divider) {
return '${(size / divider).toStringAsFixed(round)} K$unit';
}
if (size < divider * divider * divider && size % divider == 0) {
return '${(size / (divider * divider)).toStringAsFixed(0)} M$unit';
}
return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
}
}
class StreamTypes {
static const audio = 'audio';
static const metadata = 'metadata';
static const subtitle = 'subtitle';
static const timedText = 'timedtext';
static const unknown = 'unknown';
static const video = 'video';
}

View file

@ -1,169 +0,0 @@
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/ref/languages.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:fijkplayer/fijkplayer.dart';
class StreamInfo {
static const keyBitrate = 'bitrate';
static const keyChannelLayout = 'channel_layout';
static const keyCodecLevel = 'codec_level';
static const keyCodecName = 'codec_name';
static const keyCodecPixelFormat = 'codec_pixel_format';
static const keyCodecProfileId = 'codec_profile_id';
static const keyDurationMicro = 'duration_us';
static const keyFormat = 'format';
static const keyFpsDen = 'fps_den';
static const keyFpsNum = 'fps_num';
static const keyHeight = 'height';
static const keyIndex = 'index';
static const keyLanguage = 'language';
static const keySampleRate = 'sample_rate';
static const keySarDen = 'sar_den';
static const keySarNum = 'sar_num';
static const keyStartMicro = 'start_us';
static const keyTbrDen = 'tbr_den';
static const keyTbrNum = 'tbr_num';
static const keyType = 'type';
static const keyWidth = 'width';
static const typeAudio = 'audio';
static const typeMetadata = 'metadata';
static const typeSubtitle = 'subtitle';
static const typeTimedText = 'timedtext';
static const typeUnknown = 'unknown';
static const typeVideo = 'video';
static Future<Map> getVideoInfo(AvesEntry entry) async {
final player = FijkPlayer();
await player.setDataSource(entry.uri, autoPlay: false);
final completer = Completer();
void onChange() {
if ([FijkState.prepared, FijkState.error].contains(player.state)) {
completer.complete();
}
}
player.addListener(onChange);
await player.prepareAsync();
await completer.future;
player.removeListener(onChange);
final info = await player.getInfo();
await player.release();
return info;
}
static String formatBitrate(int size, {int round = 2}) {
const divider = 1000;
const symbol = 'bit/s';
if (size < divider) return '$size $symbol';
if (size < divider * divider && size % divider == 0) {
return '${(size / divider).toStringAsFixed(0)} K$symbol';
}
if (size < divider * divider) {
return '${(size / divider).toStringAsFixed(round)} K$symbol';
}
if (size < divider * divider * divider && size % divider == 0) {
return '${(size / (divider * divider)).toStringAsFixed(0)} M$symbol';
}
return '${(size / divider / divider).toStringAsFixed(round)} M$symbol';
}
static Map<String, String> formatStreamInfo(Map stream) {
final dir = <String, String>{};
final type = stream[keyType];
final codec = stream[keyCodecName];
for (final kv in stream.entries) {
final value = kv.value;
if (value != null) {
final key = kv.key;
switch (key) {
case keyCodecLevel:
case keyFpsNum:
case keyIndex:
case keySarNum:
case keyTbrNum:
case keyTbrDen:
case keyType:
break;
case keyBitrate:
dir['Bitrate'] = formatBitrate(value, round: 1);
break;
case keyChannelLayout:
dir['Channel Layout'] = ChannelLayouts.names[value] ?? 'unknown ($value)';
break;
case keyCodecName:
dir['Codec'] = _getCodecName(value as String);
break;
case keyCodecPixelFormat:
if (type == typeVideo) {
dir['Pixel Format'] = (value as String).toUpperCase();
}
break;
case keyCodecProfileId:
if (codec == 'h264') {
final profile = int.tryParse(value as String);
if (profile != null && profile != 0) {
final level = int.tryParse(stream[keyCodecLevel] as String);
dir['Codec Profile'] = H264.formatProfile(profile, level);
}
}
break;
case keyFpsDen:
dir['Frame Rate'] = roundToPrecision(stream[keyFpsNum] / stream[keyFpsDen], decimals: 3).toString();
break;
case keyHeight:
dir['Height'] = '$value pixels';
break;
case keyLanguage:
if (value != 'und') {
final language = Language.living639_2.firstWhere((language) => language.iso639_2 == value, orElse: () => null);
dir['Language'] = language?.native ?? value;
}
break;
case keySampleRate:
dir['Sample Rate'] = '$value Hz';
break;
case keySarDen:
dir['SAR'] = '${stream[keySarNum]}:${stream[keySarDen]}';
break;
case keyWidth:
dir['Width'] = '$value pixels';
break;
default:
dir[key] = value.toString();
}
}
}
return dir;
}
static String _getCodecName(String value) {
switch (value) {
case 'ac3':
return 'AC-3';
case 'eac3':
return 'E-AC-3';
case 'h264':
return 'AVC (H.264)';
case 'hdmv_pgs_subtitle':
return 'PGS';
case 'hevc':
return 'HEVC (H.265)';
case 'mpeg4':
return 'MPEG-4 Visual';
case 'subrip':
return 'SubRip';
default:
return value.toUpperCase().replaceAll('_', ' ');
}
}
}

88
lib/ref/mp4.dart Normal file
View file

@ -0,0 +1,88 @@
class Mp4 {
// adapted from `metadata-extractor`
static final brands = <String, String>{
'3g2a': '3GPP2 Media compliant with 3GPP2 C.S0050-0 V1.0',
'3g2b': '3GPP2 Media compliant with 3GPP2 C.S0050-A V1.0.0',
'3g2c': '3GPP2 Media compliant with 3GPP2 C.S0050-B v1.0',
'3ge6': '3GPP Release 6 MBMS Extended Presentations',
'3ge7': '3GPP Release 7 MBMS Extended Presentations',
'3gg6': '3GPP Release 6 General Profile',
'3gp1': '3GPP Media Release 1',
'3gp2': '3GPP Media Release 2',
'3gp3': '3GPP Media Release 3',
'3gp4': '3GPP Media Release 4',
'3gp5': '3GPP Media Release 5',
'3gp6': '3GPP Media Release 6',
'3gs7': '3GPP Media Release 7',
'avc1': 'MP4 Base w/ AVC ext',
'CAEP': 'Canon Digital Camera',
'caqv': 'Casio Digital Camera',
'CDes': 'Convergent Design',
'da0a': 'DMB MAF w/ MPEG Layer II aud, MOT slides, DLS, JPG/PNG/MNG images',
'da0b': 'DMB MAF, extending DA0A, with 3GPP timed text, DID, TVA, REL, IPMP',
'da1a': 'DMB MAF audio with ER-BSAC audio, JPG/PNG/MNG images',
'da1b': 'DMB MAF, extending da1a, with 3GPP timed text, DID, TVA, REL, IPMP',
'da2a': 'DMB MAF aud w/ HE-AAC v2 aud, MOT slides, DLS, JPG/PNG/MNG images',
'da2b': 'DMB MAF, extending da2a, with 3GPP timed text, DID, TVA, REL, IPMP',
'da3a': 'DMB MAF aud with HE-AAC aud, JPG/PNG/MNG images',
'da3b': 'DMB MAF, extending da3a w/ BIFS, 3GPP timed text, DID, TVA, REL, IPMP',
'dmb1': 'DMB MAF supporting all the components defined in the specification',
'dmpf': 'Digital Media Project',
'drc1': 'Dirac (wavelet compression), encapsulated in ISO base media (MP4)',
'dv1a': 'DMB MAF vid w/ AVC vid, ER-BSAC aud, BIFS, JPG/PNG/MNG images, TS',
'dv1b': 'DMB MAF, extending dv1a, with 3GPP timed text, DID, TVA, REL, IPMP',
'dv2a': 'DMB MAF vid w/ AVC vid, HE-AAC v2 aud, BIFS, JPG/PNG/MNG images, TS',
'dv2b': 'DMB MAF, extending dv2a, with 3GPP timed text, DID, TVA, REL, IPMP',
'dv3a': 'DMB MAF vid w/ AVC vid, HE-AAC aud, BIFS, JPG/PNG/MNG images, TS',
'dv3b': 'DMB MAF, extending dv3a, with 3GPP timed text, DID, TVA, REL, IPMP',
'dvr1': 'DVB over RTP',
'dvt1': 'DVB over MPEG-2 Transport Stream',
'F4V ': 'Video for Adobe Flash Player 9+',
'F4P ': 'Protected Video for Adobe Flash Player 9+',
'F4A ': 'Audio for Adobe Flash Player 9+',
'F4B ': 'Audio Book for Adobe Flash Player 9+',
'isc2': 'ISMACryp 2.0 Encrypted File',
'iso2': 'MP4 Base Media v2',
'isom': 'MP4 Base Media v1',
'JP2 ': 'JPEG 2000 Image',
'jpm ': 'JPEG 2000 Compound Image',
'jpx ': 'JPEG 2000 w/ extensions',
'KDDI': '3GPP2 EZmovie for KDDI 3G cellphones',
'M4A ': 'Apple iTunes AAC-LC Audio',
'M4B ': 'Apple iTunes AAC-LC Audio Book',
'M4P ': 'Apple iTunes AAC-LC AES Protected Audio',
'M4V ': 'Apple iTunes Video',
'M4VH': 'Apple TV',
'M4VP': 'Apple iPhone',
'mj2s': 'Motion JPEG 2000 Simple Profile',
'mjp2': 'Motion JPEG 2000 General Profile',
'mmp4': 'MPEG-4/3GPP Mobile Profile',
'mp21': 'MPEG-21',
'mp41': 'MP4 v1',
'mp42': 'MP4 v2',
'mp71': 'MP4 w/ MPEG-7 Metadata',
'MPPI': 'Photo Player, MAF',
'mqt ': 'Sony / Mobile QuickTime',
'MSNV': 'MPEG-4 for SonyPSP',
'NDAS': 'MP4 v2 Nero Digital AAC Audio',
'NDSC': 'MPEG-4 Nero Cinema Profile',
'NDSH': 'MPEG-4 Nero HDTV Profile',
'NDSM': 'MPEG-4 Nero Mobile Profile',
'NDSP': 'MPEG-4 Nero Portable Profile',
'NDSS': 'MPEG-4 Nero Standard Profile',
'NDXC': 'H.264/MPEG-4 AVC Nero Cinema Profile',
'NDXH': 'H.264/MPEG-4 AVC Nero HDTV Profile',
'NDXM': 'H.264/MPEG-4 AVC Nero Mobile Profile',
'NDXP': 'H.264/MPEG-4 AVC Nero Portable Profile',
'NDXS': 'H.264/MPEG-4 AVC Nero Standard Profile',
'odcf': 'OMA DCF DRM Format 2.0',
'opf2': 'OMA PDCF DRM Format 2.1',
'opx2': 'OMA PDCF DRM + XBS extensions',
'pana': 'Panasonic Digital Camera',
'qt ': 'Apple QuickTime',
'ROSS': 'Ross Video',
'sdv ': 'SD Memory Card Video',
'ssc1': 'Samsung stereoscopic, single stream',
'ssc2': 'Samsung stereoscopic, dual stream',
};
}

View file

@ -1,14 +1,17 @@
String formatDuration(Duration d) { String formatFriendlyDuration(Duration d) {
String twoDigits(int n) { final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
if (n >= 10) return '$n'; if (d.inHours == 0) return '${d.inMinutes}:$seconds';
return '0$n';
}
final twoDigitSeconds = twoDigits(d.inSeconds.remainder(Duration.secondsPerMinute)); final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
if (d.inHours == 0) return '${d.inMinutes}:$twoDigitSeconds'; return '${d.inHours}:$minutes:$seconds';
}
final twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour)); String formatPreciseDuration(Duration d) {
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds'; final millis = ((d.inMicroseconds / 1000.0).round() % 1000).toString().padLeft(3, '0');
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
final hours = (d.inHours).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds.$millis';
} }
extension ExtraDateTime on DateTime { extension ExtraDateTime on DateTime {

View file

@ -2,7 +2,8 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/video/streams.dart'; import 'package:aves/model/video/keys.dart';
import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart';
@ -180,28 +181,35 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
Future<List<MetadataDirectory>> _getStreamDirectories() async { Future<List<MetadataDirectory>> _getStreamDirectories() async {
final directories = <MetadataDirectory>[]; final directories = <MetadataDirectory>[];
final info = await StreamInfo.getVideoInfo(entry); final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(entry);
if (info.containsKey('streams')) {
final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo);
if (formattedMediaTags.isNotEmpty) {
// overwrite generic directory found from the platform side
directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, null, _toSortedTags(formattedMediaTags)));
}
if (mediaInfo.containsKey('streams')) {
String getTypeText(Map stream) { String getTypeText(Map stream) {
final type = stream[StreamInfo.keyType] ?? StreamInfo.typeUnknown; final type = stream[Keys.streamType] ?? StreamTypes.unknown;
switch (type) { switch (type) {
case StreamInfo.typeAudio: case StreamTypes.audio:
return 'Audio'; return 'Audio';
case StreamInfo.typeMetadata: case StreamTypes.metadata:
return 'Metadata'; return 'Metadata';
case StreamInfo.typeSubtitle: case StreamTypes.subtitle:
case StreamInfo.typeTimedText: case StreamTypes.timedText:
return 'Text'; return 'Text';
case StreamInfo.typeVideo: case StreamTypes.video:
return stream.containsKey(StreamInfo.keyFpsDen) ? 'Video' : 'Image'; return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
case StreamInfo.typeUnknown: case StreamTypes.unknown:
default: default:
return 'Unknown'; return 'Unknown';
} }
} }
final allStreams = (info['streams'] as List).cast<Map>(); final allStreams = (mediaInfo['streams'] as List).cast<Map>();
final unknownStreams = allStreams.where((stream) => stream[StreamInfo.keyType] == StreamInfo.typeUnknown).toList(); final unknownStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.unknown).toList();
final knownStreams = allStreams.whereNot(unknownStreams.contains); final knownStreams = allStreams.whereNot(unknownStreams.contains);
// display known streams as separate directories (e.g. video, audio, subs) // display known streams as separate directories (e.g. video, audio, subs)
@ -209,24 +217,34 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
final indexDigits = knownStreams.length.toString().length; final indexDigits = knownStreams.length.toString().length;
for (final stream in knownStreams) { for (final stream in knownStreams) {
final index = (stream[StreamInfo.keyIndex] ?? 0) + 1; final index = (stream[Keys.index] ?? 0) + 1;
final typeText = getTypeText(stream); final typeText = getTypeText(stream);
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')}$typeText'; final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')}$typeText';
final rawTags = StreamInfo.formatStreamInfo(stream); final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
final color = stringToColor(typeText); if (formattedStreamTags.isNotEmpty) {
directories.add(MetadataDirectory(dirName, null, _toSortedTags(rawTags), color: color)); final color = stringToColor(typeText);
directories.add(MetadataDirectory(dirName, null, _toSortedTags(formattedStreamTags), color: color));
}
} }
} }
// display unknown streams as attachments (e.g. fonts) // display unknown streams as attachments (e.g. fonts)
if (unknownStreams.isNotEmpty) { if (unknownStreams.isNotEmpty) {
final unknownCodecCount = <String, int>{}; final unknownCodecCount = <String, List<String>>{};
for (final stream in unknownStreams) { for (final stream in unknownStreams) {
final codec = (stream[StreamInfo.keyCodecName] as String ?? 'unknown').toUpperCase(); final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase();
unknownCodecCount[codec] = (unknownCodecCount[codec] ?? 0) + 1; if (!unknownCodecCount.containsKey(codec)) {
unknownCodecCount[codec] = [];
}
unknownCodecCount[codec].add(stream[Keys.filename]);
} }
if (unknownCodecCount.isNotEmpty) { if (unknownCodecCount.isNotEmpty) {
final rawTags = unknownCodecCount.map((key, value) => MapEntry(key, value.toString())); final rawTags = unknownCodecCount.map((key, value) {
final count = value.length;
// remove duplicate names, so number of displayed names may not match displayed count
final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase);
return MapEntry(key, '$count items: ${names.join(', ')}');
});
directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags))); directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags)));
} }
} }

View file

@ -163,7 +163,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
builder: (context, snapshot) { builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos // do not use stream snapshot because it is obsolete when switching between videos
final position = controller.currentPosition?.floor() ?? 0; final position = controller.currentPosition?.floor() ?? 0;
return Text(formatDuration(Duration(milliseconds: position))); return Text(formatFriendlyDuration(Duration(milliseconds: position)));
}), }),
Spacer(), Spacer(),
Text(entry.durationText), Text(entry.durationText),

View file

@ -211,7 +211,7 @@ packages:
description: description:
path: "." path: "."
ref: aves ref: aves
resolved-ref: c217373cfe61fb17941571d17e38236765a8ec07 resolved-ref: "3c6f4e0d350416932b3a4efcbf1833b7eaf4adc1"
url: "git://github.com/deckerst/fijkplayer.git" url: "git://github.com/deckerst/fijkplayer.git"
source: git source: git
version: "0.8.7" version: "0.8.7"