collection: identify 360 images & videos, added filters for these and GeoTIFF

This commit is contained in:
Thibault Deckers 2020-12-04 11:09:49 +09:00
parent 2832351710
commit ab6124e093
12 changed files with 121 additions and 18 deletions

View file

@ -18,6 +18,7 @@ import com.drew.metadata.exif.GpsDirectory
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.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
@ -299,6 +300,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it } xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
} }
// identification of panorama (aka photo sphere)
if (XMP.panoramaRequiredProps.all { xmpMeta.doesPropertyExist(XMP.GPANO_SCHEMA_NS, it) }) {
flags = flags or MASK_IS_360
}
} catch (e: XMPException) { } catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
} }
@ -329,6 +335,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
} }
// identification of spherical video (aka 360° video)
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID
}) {
flags = flags or MASK_IS_360
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e) Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
@ -635,6 +648,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private const val MASK_IS_ANIMATED = 1 shl 0 private const val MASK_IS_ANIMATED = 1 shl 0
private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_FLIPPED = 1 shl 1
private const val MASK_IS_GEOTIFF = 1 shl 2 private const val MASK_IS_GEOTIFF = 1 shl 2
private const val MASK_IS_360 = 1 shl 3
private const val XMP_SUBJECTS_SEPARATOR = ";" private const val XMP_SUBJECTS_SEPARATOR = ";"
// overlay metadata // overlay metadata

View file

@ -13,6 +13,9 @@ object Metadata {
// "+51.3328-000.7053+113.474/" (Apple) // "+51.3328-000.7053+113.474/" (Apple)
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*") val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
// cf https://github.com/google/spatial-media
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"
// directory names, as shown when listing all metadata // directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // from metadata-extractor const val DIR_XMP = "XMP" // from metadata-extractor

View file

@ -23,27 +23,66 @@ object XMP {
private const val GENERIC_LANG = "" private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US" private const val SPECIFIC_LANG = "en-US"
// panorama
// cf https://developers.google.com/streetview/spherical-metadata
const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/"
private const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels"
private const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels"
private const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels"
private const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
private const val GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME = "GPano:FullPanoHeightPixels"
private const val GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME = "GPano:FullPanoWidthPixels"
private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
val panoramaRequiredProps = listOf(
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
GPANO_CROPPED_AREA_TOP_PROP_NAME,
GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME,
GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME,
GPANO_PROJECTION_TYPE_PROP_NAME,
)
// embedded media data properties // embedded media data properties
// cf https://developers.google.com/depthmap-metadata
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data" private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data"
private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data"
private const val GIMAGE_DATA_PROP_NAME = "GImage:Data" private const val GIMAGE_DATA_PROP_NAME = "GImage:Data"
private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data"
private const val GDEPTH_CONFIDENCE_PROP_NAME = "GDepth:Confidence"
private val dataProps = hashMapOf( private const val GAUDIO_MIME_PROP_NAME = "GAudio:Mime"
private const val GIMAGE_MIME_PROP_NAME = "GImage:Mime"
private const val GDEPTH_MIME_PROP_NAME = "GDepth:Mime"
private const val GDEPTH_CONFIDENCE_MIME_PROP_NAME = "GDepth:ConfidenceMime"
private val dataPropNamespaces = hashMapOf(
GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS, GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS,
GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS,
GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS, GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS,
GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS,
GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_SCHEMA_NS,
) )
fun isDataPath(path: String) = dataProps.containsKey(path) private val dataPropMimeProps = hashMapOf(
GAUDIO_DATA_PROP_NAME to GAUDIO_MIME_PROP_NAME,
GIMAGE_DATA_PROP_NAME to GIMAGE_MIME_PROP_NAME,
GDEPTH_DATA_PROP_NAME to GDEPTH_MIME_PROP_NAME,
GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_CONFIDENCE_MIME_PROP_NAME,
)
fun namespaceForDataPath(path: String) = dataProps[path] fun isDataPath(path: String) = dataPropNamespaces.containsKey(path)
fun mimeTypePathForDataPath(dataPropPath: String) = dataPropPath.replace("Data", "Mime") fun namespaceForDataPath(dataPropPath: String) = dataPropNamespaces[dataPropPath]
fun mimeTypePathForDataPath(dataPropPath: String) = dataPropMimeProps[dataPropPath]
// extensions // extensions

View file

@ -10,6 +10,9 @@ class MimeFilter extends CollectionFilter {
// fake mime type // fake mime type
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp` static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp`
static const panorama = 'aves/panorama'; // subset of images
static const sphericalVideo = 'aves/spherical_video'; // subset of videos
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
final String mime; final String mime;
bool Function(ImageEntry) _filter; bool Function(ImageEntry) _filter;
@ -22,6 +25,18 @@ class MimeFilter extends CollectionFilter {
_filter = (entry) => entry.isAnimated; _filter = (entry) => entry.isAnimated;
_label = 'Animated'; _label = 'Animated';
_icon = AIcons.animated; _icon = AIcons.animated;
} else if (mime == panorama) {
_filter = (entry) => entry.isImage && entry.is360;
_label = 'Panorama';
_icon = AIcons.threesixty;
} else if (mime == sphericalVideo) {
_filter = (entry) => entry.isVideo && entry.is360;
_label = '360° Video';
_icon = AIcons.threesixty;
} else if (mime == geotiff) {
_filter = (entry) => entry.isGeotiff;
_label = 'GeoTIFF';
_icon = AIcons.geo;
} else if (lowMime.endsWith('/*')) { } else if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2); lowMime = lowMime.substring(0, lowMime.length - 2);
_filter = (entry) => entry.mimeType.startsWith(lowMime); _filter = (entry) => entry.mimeType.startsWith(lowMime);

View file

@ -200,6 +200,8 @@ class ImageEntry {
bool get isRaw => MimeTypes.rawImages.contains(mimeType); bool get isRaw => MimeTypes.rawImages.contains(mimeType);
bool get isImage => MimeTypes.isImage(mimeType);
bool get isVideo => MimeTypes.isVideo(mimeType); bool get isVideo => MimeTypes.isVideo(mimeType);
bool get isCatalogued => _catalogMetadata != null; bool get isCatalogued => _catalogMetadata != null;
@ -208,6 +210,8 @@ class ImageEntry {
bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false; bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false;
bool get is360 => _catalogMetadata?.is360 ?? false;
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canPrint => !isVideo; bool get canPrint => !isVideo;

View file

@ -30,7 +30,7 @@ class DateMetadata {
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis; final int contentId, dateMillis;
final bool isAnimated, isGeotiff; final bool isAnimated, isGeotiff, is360;
bool isFlipped; bool isFlipped;
int rotationDegrees; int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription; final String mimeType, xmpSubjects, xmpTitleDescription;
@ -38,9 +38,10 @@ class CatalogMetadata {
Address address; Address address;
static const double _precisionErrorTolerance = 1e-9; static const double _precisionErrorTolerance = 1e-9;
static const isAnimatedMask = 1 << 0; static const _isAnimatedMask = 1 << 0;
static const isFlippedMask = 1 << 1; static const _isFlippedMask = 1 << 1;
static const isGeotiffMask = 1 << 2; static const _isGeotiffMask = 1 << 2;
static const _is360Mask = 1 << 3;
CatalogMetadata({ CatalogMetadata({
this.contentId, this.contentId,
@ -49,6 +50,7 @@ class CatalogMetadata {
this.isAnimated, this.isAnimated,
this.isFlipped, this.isFlipped,
this.isGeotiff, this.isGeotiff,
this.is360,
this.rotationDegrees, this.rotationDegrees,
this.xmpSubjects, this.xmpSubjects,
this.xmpTitleDescription, this.xmpTitleDescription,
@ -74,6 +76,7 @@ class CatalogMetadata {
isAnimated: isAnimated, isAnimated: isAnimated,
isFlipped: isFlipped, isFlipped: isFlipped,
isGeotiff: isGeotiff, isGeotiff: isGeotiff,
is360: is360,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
@ -88,9 +91,10 @@ class CatalogMetadata {
contentId: map['contentId'], contentId: map['contentId'],
mimeType: map['mimeType'], mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0, dateMillis: map['dateMillis'] ?? 0,
isAnimated: flags & isAnimatedMask != 0, isAnimated: flags & _isAnimatedMask != 0,
isFlipped: flags & isFlippedMask != 0, isFlipped: flags & _isFlippedMask != 0,
isGeotiff: flags & isGeotiffMask != 0, isGeotiff: flags & _isGeotiffMask != 0,
is360: flags & _is360Mask != 0,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0 // `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'], rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '', xmpSubjects: map['xmpSubjects'] ?? '',
@ -104,7 +108,7 @@ class CatalogMetadata {
'contentId': contentId, 'contentId': contentId,
'mimeType': mimeType, 'mimeType': mimeType,
'dateMillis': dateMillis, 'dateMillis': dateMillis,
'flags': (isAnimated ? isAnimatedMask : 0) | (isFlipped ? isFlippedMask : 0) | (isGeotiff ? isGeotiffMask : 0), 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0),
'rotationDegrees': rotationDegrees, 'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects, 'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription, 'xmpTitleDescription': xmpTitleDescription,
@ -114,7 +118,7 @@ class CatalogMetadata {
@override @override
String toString() { String toString() {
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
} }
} }

View file

@ -63,6 +63,7 @@ class AIcons {
static const IconData animated = Icons.slideshow; static const IconData animated = Icons.slideshow;
static const IconData geo = Icons.language_outlined; static const IconData geo = Icons.language_outlined;
static const IconData play = Icons.play_circle_outline; static const IconData play = Icons.play_circle_outline;
static const IconData threesixty = Icons.threesixty_outlined;
static const IconData selected = Icons.check_circle_outline; static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked; static const IconData unselected = Icons.radio_button_unchecked;
} }

View file

@ -50,7 +50,9 @@ class ThumbnailEntryOverlay extends StatelessWidget {
iconSize: iconSize, iconSize: iconSize,
showDuration: settings.showThumbnailVideoDuration, showDuration: settings.showThumbnailVideoDuration,
), ),
), )
else if (entry.is360)
SphericalImageIcon(iconSize: iconSize),
], ],
); );
}); });

View file

@ -23,9 +23,10 @@ class VideoIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.play, icon: entry.is360 ? AIcons.threesixty : AIcons.play,
size: iconSize, size: iconSize,
text: showDuration ? entry.durationText : null, text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1,
); );
} }
} }
@ -59,6 +60,20 @@ class GeotiffIcon extends StatelessWidget {
} }
} }
class SphericalImageIcon extends StatelessWidget {
final double iconSize;
const SphericalImageIcon({Key key, this.iconSize}) : super(key: key);
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.threesixty,
size: iconSize,
);
}
}
class GpsIcon extends StatelessWidget { class GpsIcon extends StatelessWidget {
final double iconSize; final double iconSize;

View file

@ -97,6 +97,7 @@ class FullscreenDebugPage extends StatelessWidget {
'isCatalogued': '${entry.isCatalogued}', 'isCatalogued': '${entry.isCatalogued}',
'isAnimated': '${entry.isAnimated}', 'isAnimated': '${entry.isAnimated}',
'isGeotiff': '${entry.isGeotiff}', 'isGeotiff': '${entry.isGeotiff}',
'is360': '${entry.is360}',
'canEdit': '${entry.canEdit}', 'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}', 'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}', 'canPrint': '${entry.canPrint}',

View file

@ -54,8 +54,10 @@ class BasicSection extends StatelessWidget {
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
final album = entry.directory; final album = entry.directory;
final filters = [ final filters = [
if (entry.isVideo) MimeFilter(MimeTypes.anyVideo),
if (entry.isAnimated) MimeFilter(MimeFilter.animated), if (entry.isAnimated) MimeFilter(MimeFilter.animated),
if (entry.isImage && entry.is360) MimeFilter(MimeFilter.panorama),
if (entry.isVideo) MimeFilter(entry.is360 ? MimeFilter.sphericalVideo : MimeTypes.anyVideo),
if (entry.isGeotiff) MimeFilter(MimeFilter.geotiff),
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
...tags.map((tag) => TagFilter(tag)), ...tags.map((tag) => TagFilter(tag)),
]; ];

View file

@ -82,6 +82,9 @@ class ImageSearchDelegate {
MimeFilter(MimeTypes.anyImage), MimeFilter(MimeTypes.anyImage),
MimeFilter(MimeTypes.anyVideo), MimeFilter(MimeTypes.anyVideo),
MimeFilter(MimeFilter.animated), MimeFilter(MimeFilter.animated),
MimeFilter(MimeFilter.panorama),
MimeFilter(MimeFilter.sphericalVideo),
MimeFilter(MimeFilter.geotiff),
MimeFilter(MimeTypes.svg), MimeFilter(MimeTypes.svg),
].where((f) => f != null && containQuery(f.label)), ].where((f) => f != null && containQuery(f.label)),
// usually perform hero animation only on tapped chips, // usually perform hero animation only on tapped chips,