[mob][photos] Fix bug in parsing rotation metadata from video using FFProbe (#2595)
### Description Parse width and height of video correctly using FFProbe by - Considering both `coded_height` & `height` + `coded_width` + `width` keys to parse height and width of video. Came across two videos where `coded_width` and `coded_height` were both `0` where as `height` and `width` had the correct values. - Parse `rotation` from `side_data_list` and consider `rotation` for accurate (i.e, not flipped) dimensions. Have made sure the correct height and width of the video is shown on the video's file info. Sometimes there could be a slight difference from what a user would expect, if the `coded_side` is different from `side` (`side` is `width` or `height`). Will be fixing this in future. Ref: https://superuser.com/questions/1523944/whats-the-difference-between-coded-width-and-width-in-ffprobe
This commit is contained in:
@@ -72,6 +72,7 @@ class FFProbeKeys {
|
||||
static const xiaomiSlowMoment = 'com.xiaomi.slow_moment';
|
||||
static const sideDataList = 'side_data_list';
|
||||
static const rotation = 'rotation';
|
||||
static const sideDataType = 'side_data_type';
|
||||
}
|
||||
|
||||
class MediaStreamTypes {
|
||||
@@ -83,3 +84,16 @@ class MediaStreamTypes {
|
||||
static const unknown = 'unknown';
|
||||
static const video = 'video';
|
||||
}
|
||||
|
||||
enum SideDataType {
|
||||
displayMatrix;
|
||||
|
||||
getString() {
|
||||
switch (this) {
|
||||
case SideDataType.displayMatrix:
|
||||
return 'Display Matrix';
|
||||
default:
|
||||
assert(false, 'Unknown side data type: $this');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ import "package:photos/models/ffmpeg/mp4.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
class FFProbeProps {
|
||||
Map<String, dynamic>? prodData;
|
||||
Map<String, dynamic>? propData;
|
||||
Location? location;
|
||||
DateTime? creationTimeUTC;
|
||||
String? bitrate;
|
||||
String? majorBrand;
|
||||
String? fps;
|
||||
String? _codecWidth;
|
||||
String? _codecHeight;
|
||||
String? _width;
|
||||
String? _height;
|
||||
int? _rotation;
|
||||
|
||||
// dot separated bitrate, fps, codecWidth, codecHeight. Ignore null value
|
||||
@@ -27,36 +27,36 @@ class FFProbeProps {
|
||||
final List<String> info = [];
|
||||
if (bitrate != null) info.add('$bitrate');
|
||||
if (fps != null) info.add('ƒ/$fps');
|
||||
if (_codecWidth != null && _codecHeight != null) {
|
||||
info.add('$_codecWidth x $_codecHeight');
|
||||
if (_width != null && _height != null) {
|
||||
info.add('$_width x $_height');
|
||||
}
|
||||
return info.join(' * ');
|
||||
}
|
||||
|
||||
int? get width {
|
||||
if (_codecWidth == null || _codecHeight == null) return null;
|
||||
final intCodecWidth = int.tryParse(_codecWidth!);
|
||||
if (_width == null || _height == null) return null;
|
||||
final intWidth = int.tryParse(_width!);
|
||||
if (_rotation == null) {
|
||||
return intCodecWidth;
|
||||
return intWidth;
|
||||
} else {
|
||||
if ((_rotation! ~/ 90).isEven) {
|
||||
return intCodecWidth;
|
||||
return intWidth;
|
||||
} else {
|
||||
return int.tryParse(_codecHeight!);
|
||||
return int.tryParse(_height!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int? get height {
|
||||
if (_codecWidth == null || _codecHeight == null) return null;
|
||||
final intCodecHeight = int.tryParse(_codecHeight!);
|
||||
if (_width == null || _height == null) return null;
|
||||
final intHeight = int.tryParse(_height!);
|
||||
if (_rotation == null) {
|
||||
return intCodecHeight;
|
||||
return intHeight;
|
||||
} else {
|
||||
if ((_rotation! ~/ 90).isEven) {
|
||||
return intCodecHeight;
|
||||
return intHeight;
|
||||
} else {
|
||||
return int.tryParse(_codecWidth!);
|
||||
return int.tryParse(_width!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ class FFProbeProps {
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer();
|
||||
for (final key in prodData!.keys) {
|
||||
final value = prodData![key];
|
||||
for (final key in propData!.keys) {
|
||||
final value = propData![key];
|
||||
if (value != null) {
|
||||
buffer.writeln('$key: $value');
|
||||
}
|
||||
@@ -167,14 +167,41 @@ class FFProbeProps {
|
||||
if (key == FFProbeKeys.rFrameRate) {
|
||||
result.fps = _formatFPS(stream[key]);
|
||||
parsedData[key] = result.fps;
|
||||
} else if (key == FFProbeKeys.codedWidth) {
|
||||
result._codecWidth = stream[key].toString();
|
||||
parsedData[key] = result._codecWidth;
|
||||
}
|
||||
//TODO: Use `height` and `width` instead of `codedHeight` and `codedWidth`
|
||||
//for better accuracy. `height' and `width` will give the video's "visual"
|
||||
//height and width.
|
||||
else if (key == FFProbeKeys.codedWidth) {
|
||||
final width = stream[key];
|
||||
if (width != null && width != 0) {
|
||||
result._width = width.toString();
|
||||
parsedData[key] = result._width;
|
||||
}
|
||||
} else if (key == FFProbeKeys.codedHeight) {
|
||||
result._codecHeight = stream[key].toString();
|
||||
parsedData[key] = result._codecHeight;
|
||||
final height = stream[key];
|
||||
if (height != null && height != 0) {
|
||||
result._height = height.toString();
|
||||
parsedData[key] = result._height;
|
||||
}
|
||||
} else if (key == FFProbeKeys.width) {
|
||||
final width = stream[key];
|
||||
if (width != null && width != 0) {
|
||||
result._width = width.toString();
|
||||
parsedData[key] = result._width;
|
||||
}
|
||||
} else if (key == FFProbeKeys.height) {
|
||||
final height = stream[key];
|
||||
if (height != null && height != 0) {
|
||||
result._height = height.toString();
|
||||
parsedData[key] = result._height;
|
||||
}
|
||||
} else if (key == FFProbeKeys.sideDataList) {
|
||||
result._rotation = stream[key][0][FFProbeKeys.rotation];
|
||||
for (Map sideData in stream[key]) {
|
||||
if (sideData["side_data_type"] == "Display Matrix") {
|
||||
result._rotation = sideData[FFProbeKeys.rotation];
|
||||
parsedData[FFProbeKeys.rotation] = result._rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +209,7 @@ class FFProbeProps {
|
||||
newStreams.add(metadata);
|
||||
}
|
||||
parsedData["streams"] = newStreams;
|
||||
result.prodData = parsedData;
|
||||
result.propData = parsedData;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
|
||||
_videoMetadataNotifier.value = properties;
|
||||
if (kDebugMode) {
|
||||
log("videoCustomProps ${properties.toString()}");
|
||||
log("PropData ${properties?.prodData.toString()}");
|
||||
log("PropData ${properties?.propData.toString()}");
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import "package:photos/models/ffmpeg/ffprobe_keys.dart";
|
||||
import "package:photos/models/ffmpeg/ffprobe_props.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
|
||||
class VideoExifDialog extends StatelessWidget {
|
||||
final Map<String, dynamic> probeData;
|
||||
final FFProbeProps props;
|
||||
|
||||
const VideoExifDialog({Key? key, required this.probeData}) : super(key: key);
|
||||
const VideoExifDialog({Key? key, required this.props}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -48,23 +49,23 @@ class VideoExifDialog extends StatelessWidget {
|
||||
context.l10n.videoInfo,
|
||||
style: getEnteTextTheme(context).large,
|
||||
),
|
||||
_buildInfoRow(context, 'Creation Time', probeData, 'creation_time'),
|
||||
_buildInfoRow(context, 'Duration', probeData, 'duration'),
|
||||
_buildInfoRow(context, context.l10n.location, probeData, 'location'),
|
||||
_buildInfoRow(context, 'Bitrate', probeData, 'bitrate'),
|
||||
_buildInfoRow(context, 'Frame Rate', probeData, FFProbeKeys.rFrameRate),
|
||||
_buildInfoRow(context, 'Width', probeData, FFProbeKeys.codedWidth),
|
||||
_buildInfoRow(context, 'Height', probeData, FFProbeKeys.codedHeight),
|
||||
_buildInfoRow(context, 'Model', probeData, 'com.apple.quicktime.model'),
|
||||
_buildInfoRow(context, 'OS', probeData, 'com.apple.quicktime.software'),
|
||||
_buildInfoRow(context, 'Major Brand', probeData, 'major_brand'),
|
||||
_buildInfoRow(context, 'Format', probeData, 'format'),
|
||||
_buildInfoRow(context, 'Creation Time', props, 'creation_time'),
|
||||
_buildInfoRow(context, 'Duration', props, 'duration'),
|
||||
_buildInfoRow(context, context.l10n.location, props, 'location'),
|
||||
_buildInfoRow(context, 'Bitrate', props, 'bitrate'),
|
||||
_buildInfoRow(context, 'Frame Rate', props, FFProbeKeys.rFrameRate),
|
||||
_buildInfoRow(context, 'Width', props, null),
|
||||
_buildInfoRow(context, 'Height', props, null),
|
||||
_buildInfoRow(context, 'Model', props, 'com.apple.quicktime.model'),
|
||||
_buildInfoRow(context, 'OS', props, 'com.apple.quicktime.software'),
|
||||
_buildInfoRow(context, 'Major Brand', props, 'major_brand'),
|
||||
_buildInfoRow(context, 'Format', props, 'format'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStreamsList(BuildContext context) {
|
||||
final List<dynamic> streams = probeData['streams'];
|
||||
final List<dynamic> streams = props.propData!['streams'];
|
||||
final List<Map<String, dynamic>> data = [];
|
||||
for (final stream in streams) {
|
||||
final Map<String, dynamic> streamData = {};
|
||||
@@ -113,7 +114,12 @@ class VideoExifDialog extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: stream.entries
|
||||
.map(
|
||||
(entry) => _buildInfoRow(context, entry.key, stream, entry.key),
|
||||
(entry) => _buildInfoRow(
|
||||
context,
|
||||
entry.key,
|
||||
FFProbeProps()..propData = stream,
|
||||
entry.key,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@@ -124,15 +130,24 @@ class VideoExifDialog extends StatelessWidget {
|
||||
Widget _buildInfoRow(
|
||||
BuildContext context,
|
||||
String rowName,
|
||||
Map<String, dynamic> data,
|
||||
String dataKey,
|
||||
FFProbeProps data,
|
||||
String? dataKey,
|
||||
) {
|
||||
final propData = data.propData;
|
||||
rowName = rowName.replaceAll('_', ' ');
|
||||
rowName = rowName[0].toUpperCase() + rowName.substring(1);
|
||||
try {
|
||||
final value = data[dataKey];
|
||||
dynamic value;
|
||||
|
||||
if (rowName == 'Width' || rowName == 'Height') {
|
||||
rowName == 'Width' ? value = data.width : value = data.height;
|
||||
} else {
|
||||
value = propData![dataKey];
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return Container(); // Return an empty container if there's no data for the key.
|
||||
return const SizedBox
|
||||
.shrink(); // Return an empty container if there's no data for the key.
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
|
||||
@@ -35,8 +35,7 @@ class _VideoProbeInfoState extends State<VideoExifRowItem> {
|
||||
return InfoItemWidget(
|
||||
leadingIcon: Icons.text_snippet_outlined,
|
||||
title: S.of(context).videoInfo,
|
||||
subtitleSection:
|
||||
_exifButton(context, widget.file, widget.props?.prodData),
|
||||
subtitleSection: _exifButton(context, widget.file, widget.props),
|
||||
onTap: _onTap,
|
||||
);
|
||||
}
|
||||
@@ -44,20 +43,20 @@ class _VideoProbeInfoState extends State<VideoExifRowItem> {
|
||||
Future<List<Widget>> _exifButton(
|
||||
BuildContext context,
|
||||
EnteFile file,
|
||||
Map<String, dynamic>? exif,
|
||||
FFProbeProps? props,
|
||||
) async {
|
||||
late final String label;
|
||||
late final VoidCallback? onTap;
|
||||
if (exif == null) {
|
||||
if (props?.propData == null) {
|
||||
label = S.of(context).loadingExifData;
|
||||
onTap = null;
|
||||
} else if (exif.isNotEmpty) {
|
||||
} else if (props!.propData!.isNotEmpty) {
|
||||
label = "${widget.props?.videoInfo ?? ''} ..";
|
||||
onTap = () => showBarModalBottomSheet(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return VideoExifDialog(
|
||||
probeData: exif,
|
||||
props: props,
|
||||
);
|
||||
},
|
||||
shape: const RoundedRectangleBorder(
|
||||
|
||||
Reference in New Issue
Block a user