Skip to content

Commit

Permalink
[mob][photos] Fix bug in parsing rotation metadata from video using F…
Browse files Browse the repository at this point in the history
…FProbe (#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
  • Loading branch information
ashilkn authored Aug 6, 2024
2 parents a821d1f + 2916bcf commit b8cb480
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 50 deletions.
14 changes: 14 additions & 0 deletions mobile/lib/models/ffmpeg/ffprobe_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
}
}
}
75 changes: 51 additions & 24 deletions mobile/lib/models/ffmpeg/ffprobe_props.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,51 +12,51 @@ 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
String get videoInfo {
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!);
}
}
}
Expand All @@ -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');
}
Expand Down Expand Up @@ -167,22 +167,49 @@ 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;
}
}
}
}
}
if (metadata.isNotEmpty) {
newStreams.add(metadata);
}
parsedData["streams"] = newStreams;
result.prodData = parsedData;
result.propData = parsedData;
return result;
}

Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/ui/viewer/file/file_details_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(() {});
}
Expand Down
53 changes: 34 additions & 19 deletions mobile/lib/ui/viewer/file/video_exif_dialog.dart
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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(),
),
Expand All @@ -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),
Expand Down
11 changes: 5 additions & 6 deletions mobile/lib/ui/viewer/file_details/video_exif_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,29 +35,28 @@ 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,
);
}

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(
Expand Down

0 comments on commit b8cb480

Please sign in to comment.