video: get rotation angle and date from metadata

This commit is contained in:
Thibault Deckers 2019-08-11 14:06:50 +09:00
parent 1b7d80dfc2
commit 836730f23c
10 changed files with 124 additions and 20 deletions

View file

@ -5,6 +5,7 @@ import android.media.MediaMetadataRetriever;
import android.text.format.Formatter; import android.text.format.Formatter;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.adobe.xmp.XMPException; import com.adobe.xmp.XMPException;
import com.adobe.xmp.XMPIterator; import com.adobe.xmp.XMPIterator;
@ -29,6 +30,7 @@ import java.util.Map;
import java.util.TimeZone; import java.util.TimeZone;
import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
@ -62,6 +64,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
private boolean isVideo(@Nullable String mimeType) {
return mimeType != null && mimeType.startsWith(Constants.MIME_VIDEO);
}
private void getAllMetadata(MethodCall call, MethodChannel.Result result) { private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
String path = call.argument("path"); String path = call.argument("path");
try (InputStream is = new FileInputStream(path)) { try (InputStream is = new FileInputStream(path)) {
@ -108,7 +114,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
private void getAllVideoMetadataFallback(MethodCall call, MethodChannel.Result result) { private void getAllVideoMetadataFallback(MethodCall call, MethodChannel.Result result) {
String path = call.argument("path"); String path = call.argument("path");
try { try {
Map<String, Map<String, String>> metadataMap = new HashMap<>(); Map<String, Map<String, String>> metadataMap = new HashMap<>();
Map<String, String> dirMap = new HashMap<>(); Map<String, String> dirMap = new HashMap<>();
@ -182,6 +187,29 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
e.printStackTrace(); e.printStackTrace();
} }
} }
if (isVideo(call.argument("mimeType"))) {
try {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(path);
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
String rotationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
retriever.release();
if (dateString != null) {
long dateMillis = Utils.parseVideoMetadataDate(dateString);
// some videos have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
metadataMap.put("dateMillis", dateMillis);
}
}
if (rotationString != null) {
metadataMap.put("videoRotation", Integer.parseInt(rotationString));
}
} catch (Exception e) {
result.error("getCatalogMetadata-exception", "failed to get video metadata for path=" + path, e);
}
}
result.success(metadataMap); result.success(metadataMap);
} catch (ImageProcessingException e) { } catch (ImageProcessingException e) {
result.error("getCatalogMetadata-imageprocessing", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); result.error("getCatalogMetadata-imageprocessing", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null);
@ -193,11 +221,17 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) { private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) {
Map<String, String> metadataMap = new HashMap<>();
if (isVideo(call.argument("mimeType"))) {
result.success(metadataMap);
return;
}
String path = call.argument("path"); String path = call.argument("path");
try (InputStream is = new FileInputStream(path)) { try (InputStream is = new FileInputStream(path)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
Map<String, String> metadataMap = new HashMap<>();
if (directory != null) { if (directory != null) {
if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) {
metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER));

View file

@ -20,7 +20,6 @@ public class Constants {
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio"); put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video"); put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate"); put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation");
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date"); put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location"); put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year"); put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");

View file

@ -1,5 +1,12 @@
package deckers.thibault.aves.utils; package deckers.thibault.aves.utils;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
public class Utils { public class Utils {
@ -21,4 +28,49 @@ public class Utils {
} }
return logTag; return logTag;
} }
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
public static long parseVideoMetadataDate(String dateString) {
// optional sub-second
String subSecond = null;
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);
if (subSecondMatcher.find()) {
subSecond = subSecondMatcher.group(2).substring(1);
dateString = subSecondMatcher.replaceAll("$1");
}
// optional time zone
TimeZone timeZone = null;
Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString);
if (timeZoneMatcher.find()) {
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", ""));
dateString = timeZoneMatcher.replaceAll("");
}
Date date = null;
try {
DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT"));
date = parser.parse(dateString);
} catch (ParseException ex) {
// ignore
}
if (date == null) {
return 0;
}
long dateMillis = date.getTime();
if (subSecond != null) {
try {
int millis = (int) (Double.parseDouble("." + subSecond) * 1000);
if (millis >= 0 && millis < 1000) {
dateMillis += millis;
}
} catch (NumberFormatException e) {
// ignore
}
}
return dateMillis;
}
} }

View file

@ -86,7 +86,13 @@ class ImageEntry with ChangeNotifier {
bool get isCataloged => catalogMetadata != null; bool get isCataloged => catalogMetadata != null;
double get aspectRatio => height == 0 ? 1 : width / height; double get aspectRatio {
if (width == 0 || height == 0) return 1;
if (isVideo && isCataloged) {
if (catalogMetadata.videoRotation % 180 == 90) return height / width;
}
return width / height;
}
int get megaPixels => (width * height / 1000000).round(); int get megaPixels => (width * height / 1000000).round();
@ -127,7 +133,7 @@ class ImageEntry with ChangeNotifier {
catalog() async { catalog() async {
if (isCataloged) return; if (isCataloged) return;
catalogMetadata = await MetadataService.getCatalogMetadata(contentId, path); catalogMetadata = await MetadataService.getCatalogMetadata(this);
notifyListeners(); notifyListeners();
} }

View file

@ -1,7 +1,7 @@
import 'package:geocoder/model.dart'; import 'package:geocoder/model.dart';
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis; final int contentId, dateMillis, videoRotation;
final String xmpSubjects; final String xmpSubjects;
final double latitude, longitude; final double latitude, longitude;
Address address; Address address;
@ -9,6 +9,7 @@ class CatalogMetadata {
CatalogMetadata({ CatalogMetadata({
this.contentId, this.contentId,
this.dateMillis, this.dateMillis,
this.videoRotation,
this.xmpSubjects, this.xmpSubjects,
double latitude, double latitude,
double longitude, double longitude,
@ -21,6 +22,7 @@ class CatalogMetadata {
return CatalogMetadata( return CatalogMetadata(
contentId: map['contentId'], contentId: map['contentId'],
dateMillis: map['dateMillis'] ?? 0, dateMillis: map['dateMillis'] ?? 0,
videoRotation: map['videoRotation'] ?? 0,
xmpSubjects: map['xmpSubjects'] ?? '', xmpSubjects: map['xmpSubjects'] ?? '',
latitude: map['latitude'], latitude: map['latitude'],
longitude: map['longitude'], longitude: map['longitude'],
@ -30,6 +32,7 @@ class CatalogMetadata {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'contentId': contentId, 'contentId': contentId,
'dateMillis': dateMillis, 'dateMillis': dateMillis,
'videoRotation': videoRotation,
'xmpSubjects': xmpSubjects, 'xmpSubjects': xmpSubjects,
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
@ -37,7 +40,7 @@ class CatalogMetadata {
@override @override
String toString() { String toString() {
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}'; return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}';
} }
} }

View file

@ -20,7 +20,7 @@ class MetadataDb {
await path, await path,
onCreate: (db, version) { onCreate: (db, version) {
return db.execute( return db.execute(
'CREATE TABLE $table(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, xmpSubjects TEXT, latitude REAL, longitude REAL)', 'CREATE TABLE $table(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, videoRotation INTEGER, xmpSubjects TEXT, latitude REAL, longitude REAL)',
); );
}, },
version: 1, version: 1,

View file

@ -1,3 +1,4 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -7,10 +8,11 @@ class MetadataService {
static const platform = const MethodChannel('deckers.thibault/aves/metadata'); static const platform = const MethodChannel('deckers.thibault/aves/metadata');
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description) // return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
static Future<Map> getAllMetadata(String path) async { static Future<Map> getAllMetadata(ImageEntry entry) async {
try { try {
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
'path': path, 'mimeType': entry.mimeType,
'path': entry.path,
}); });
return result as Map; return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
@ -19,7 +21,7 @@ class MetadataService {
return Map(); return Map();
} }
static Future<CatalogMetadata> getCatalogMetadata(int contentId, String path) async { static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry) async {
CatalogMetadata metadata; CatalogMetadata metadata;
try { try {
// return map with: // return map with:
@ -28,9 +30,10 @@ class MetadataService {
// 'longitude': longitude (double) // 'longitude': longitude (double)
// 'xmpSubjects': space separated XMP subjects (string) // 'xmpSubjects': space separated XMP subjects (string)
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'path': path, 'mimeType': entry.mimeType,
'path': entry.path,
}) as Map; }) as Map;
result['contentId'] = contentId; result['contentId'] = entry.contentId;
metadata = CatalogMetadata.fromMap(result); metadata = CatalogMetadata.fromMap(result);
metadataDb.insert(metadata); metadataDb.insert(metadata);
return metadata; return metadata;
@ -40,11 +43,12 @@ class MetadataService {
return null; return null;
} }
static Future<OverlayMetadata> getOverlayMetadata(String path) async { static Future<OverlayMetadata> getOverlayMetadata(ImageEntry entry) async {
try { try {
// return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso' // return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso'
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'path': path, 'mimeType': entry.mimeType,
'path': entry.path,
}) as Map; }) as Map;
return OverlayMetadata.fromMap(result); return OverlayMetadata.fromMap(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {

View file

@ -41,13 +41,13 @@ class InfoPageState extends State<InfoPage> {
title: Text('Info'), title: Text('Info'),
), ),
body: NotificationListener( body: NotificationListener(
onNotification: handleTopScroll, onNotification: _handleTopScroll,
child: ListView( child: ListView(
padding: EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
children: [ children: [
InfoRow('Title', entry.title), InfoRow('Title', entry.title),
InfoRow('Date', dateText), InfoRow('Date', dateText),
if (entry.isVideo) InfoRow('Duration', entry.durationText), if (entry.isVideo) ..._buildVideoRows(),
InfoRow('Resolution', resolutionText), InfoRow('Resolution', resolutionText),
InfoRow('Size', formatFilesize(entry.sizeBytes)), InfoRow('Size', formatFilesize(entry.sizeBytes)),
InfoRow('Path', entry.path), InfoRow('Path', entry.path),
@ -60,7 +60,13 @@ class InfoPageState extends State<InfoPage> {
); );
} }
bool handleTopScroll(Notification notification) { List<Widget> _buildVideoRows() {
final rotation = entry.catalogMetadata?.videoRotation;
if (rotation != null) InfoRow('Rotation', '$rotation°');
return [InfoRow('Duration', entry.durationText), if (rotation != null) InfoRow('Rotation', '$rotation°')];
}
bool _handleTopScroll(Notification notification) {
if (notification is ScrollNotification) { if (notification is ScrollNotification) {
if (notification is ScrollStartNotification) { if (notification is ScrollStartNotification) {
final metrics = notification.metrics; final metrics = notification.metrics;

View file

@ -30,7 +30,7 @@ class MetadataSectionState extends State<MetadataSection> {
} }
initMetadataLoader() async { initMetadataLoader() async {
_metadataLoader = MetadataService.getAllMetadata(widget.entry.path); _metadataLoader = MetadataService.getAllMetadata(widget.entry);
} }
@override @override

View file

@ -45,7 +45,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
} }
initDetailLoader() { initDetailLoader() {
_detailLoader = MetadataService.getOverlayMetadata(entry.path); _detailLoader = MetadataService.getOverlayMetadata(entry);
} }
@override @override