[mob] Display video metadata in file info

This commit is contained in:
Neeraj Gupta
2024-07-16 14:12:46 +05:30
parent 74f4698fd6
commit e2ef2eacc4
4 changed files with 313 additions and 192 deletions

View File

@@ -1,7 +1,5 @@
// Adapted from: https://github.com/deckerst/aves
import "dart:developer";
import "package:collection/collection.dart";
import "package:intl/intl.dart";
import "package:photos/models/ffmpeg/channel_layouts.dart";
@@ -12,203 +10,74 @@ import "package:photos/models/ffmpeg/mp4.dart";
import "package:photos/models/location/location.dart";
class FFProbeProps {
final double? captureFps;
final String? androidManufacturer;
final String? androidModel;
final String? androidVersion;
final String? bitRate;
final String? bitsPerRawSample;
final String? byteCount;
final String? channelLayout;
final String? chromaLocation;
final String? codecName;
final String? codecPixelFormat;
final int? codedHeight;
final int? codedWidth;
final String? colorPrimaries;
final String? colorRange;
final String? colorSpace;
final String? colorTransfer;
final String? colorProfile;
final String? compatibleBrands;
final String? creationTime;
final String? displayAspectRatio;
final DateTime? date;
final String? duration;
final String? durationMicros;
final String? extraDataSize;
final String? fieldOrder;
final String? fpsDen;
final int? frameCount;
final String? handlerName;
final bool? hasBFrames;
final int? height;
final String? language;
final Location? location;
final String? majorBrand;
final String? mediaFormat;
final String? mediaType;
final String? minorVersion;
final String? nalLengthSize;
final String? quicktimeLocationAccuracyHorizontal;
final int? rFrameRate;
final String? rotate;
final String? sampleFormat;
final String? sampleRate;
final String? sampleAspectRatio;
final String? sarDen;
final int? segmentCount;
final String? sourceOshash;
final String? startMicros;
final String? startPts;
final String? startTime;
final String? statisticsWritingApp;
final String? statisticsWritingDateUtc;
final String? timeBase;
final String? track;
final String? vendorId;
final int? width;
final String? xiaomiSlowMoment;
final Map<String, dynamic>? prodData;
FFProbeProps({
required this.captureFps,
required this.androidManufacturer,
required this.androidModel,
required this.androidVersion,
required this.bitRate,
required this.bitsPerRawSample,
required this.byteCount,
required this.channelLayout,
required this.chromaLocation,
required this.codecName,
required this.codecPixelFormat,
required this.codedHeight,
required this.codedWidth,
required this.colorPrimaries,
required this.colorRange,
required this.colorSpace,
required this.colorTransfer,
required this.colorProfile,
required this.compatibleBrands,
required this.creationTime,
required this.displayAspectRatio,
required this.date,
required this.duration,
required this.durationMicros,
required this.extraDataSize,
required this.fieldOrder,
required this.fpsDen,
required this.frameCount,
required this.handlerName,
required this.hasBFrames,
required this.height,
required this.language,
required this.location,
required this.majorBrand,
required this.mediaFormat,
required this.mediaType,
required this.minorVersion,
required this.nalLengthSize,
required this.quicktimeLocationAccuracyHorizontal,
required this.rFrameRate,
required this.rotate,
required this.sampleFormat,
required this.sampleRate,
required this.sampleAspectRatio,
required this.sarDen,
required this.segmentCount,
required this.sourceOshash,
required this.startMicros,
required this.startPts,
required this.startTime,
required this.statisticsWritingApp,
required this.statisticsWritingDateUtc,
required this.timeBase,
required this.track,
required this.vendorId,
required this.width,
required this.xiaomiSlowMoment,
required this.prodData,
});
// toString() method
@override
String toString() {
return 'FFProbeProps(captureFps: $captureFps, androidManufacturer: $androidManufacturer, androidModel: $androidModel, androidVersion: $androidVersion, bitRate: $bitRate, bitsPerRawSample: $bitsPerRawSample, byteCount: $byteCount, channelLayout: $channelLayout, chromaLocation: $chromaLocation, codecName: $codecName, codecPixelFormat: $codecPixelFormat, codedHeight: $codedHeight, codedWidth: $codedWidth, colorPrimaries: $colorPrimaries, colorRange: $colorRange, colorSpace: $colorSpace, colorTransfer: $colorTransfer, colorProfile: $colorProfile, compatibleBrands: $compatibleBrands, creationTime: $creationTime, displayAspectRatio: $displayAspectRatio, date: $date, duration: $duration, durationMicros: $durationMicros, extraDataSize: $extraDataSize, fieldOrder: $fieldOrder, fpsDen: $fpsDen, frameCount: $frameCount, handlerName: $handlerName, hasBFrames: $hasBFrames, height: $height, language: $language, location: $location, majorBrand: $majorBrand, mediaFormat: $mediaFormat, mediaType: $mediaType, minorVersion: $minorVersion, nalLengthSize: $nalLengthSize, quicktimeLocationAccuracyHorizontal: $quicktimeLocationAccuracyHorizontal, rFrameRate: $rFrameRate, rotate: $rotate, sampleFormat: $sampleFormat, sampleRate: $sampleRate, sampleAspectRatio: $sampleAspectRatio, sarDen: $sarDen, segmentCount: $segmentCount, sourceOshash: $sourceOshash, startMicros: $startMicros, startPts: $startPts, startTime: $startTime, statisticsWritingApp: $statisticsWritingApp, statisticsWritingDateUtc: $statisticsWritingDateUtc, timeBase: $timeBase, track: $track, vendorId: $vendorId, width: $width, xiaomiSlowMoment: $xiaomiSlowMoment)';
final buffer = StringBuffer();
for (final key in prodData!.keys) {
final value = prodData![key];
if (value != null) {
buffer.writeln('$key: $value');
}
}
return buffer.toString();
}
factory FFProbeProps.fromJson(Map<dynamic, dynamic>? json) {
return FFProbeProps(
captureFps:
double.tryParse(json?[FFProbeKeys.androidCaptureFramerate] ?? ""),
androidManufacturer: json?[FFProbeKeys.androidManufacturer],
androidModel: json?[FFProbeKeys.androidModel],
androidVersion: json?[FFProbeKeys.androidVersion],
bitRate: _formatMetric(
json?[FFProbeKeys.bitrate] ?? json?[FFProbeKeys.bps],
'b/s',
),
bitsPerRawSample: json?[FFProbeKeys.bitsPerRawSample],
byteCount: _formatFilesize(json?[FFProbeKeys.byteCount]),
channelLayout: _formatChannelLayout(json?[FFProbeKeys.channelLayout]),
chromaLocation: json?[FFProbeKeys.chromaLocation],
codecName: _formatCodecName(json?[FFProbeKeys.codecName]),
codecPixelFormat:
(json?[FFProbeKeys.codecPixelFormat] as String?)?.toUpperCase(),
codedHeight: int.tryParse(json?[FFProbeKeys.codedHeight] ?? ""),
codedWidth: int.tryParse(json?[FFProbeKeys.codedWidth] ?? ""),
colorPrimaries:
(json?[FFProbeKeys.colorPrimaries] as String?)?.toUpperCase(),
colorRange: (json?[FFProbeKeys.colorRange] as String?)?.toUpperCase(),
colorSpace: (json?[FFProbeKeys.colorSpace] as String?)?.toUpperCase(),
colorTransfer:
(json?[FFProbeKeys.colorTransfer] as String?)?.toUpperCase(),
colorProfile: json?[FFProbeKeys.colorTransfer],
compatibleBrands: json?[FFProbeKeys.compatibleBrands],
creationTime: _formatDate(json?[FFProbeKeys.creationTime] ?? ""),
displayAspectRatio: json?[FFProbeKeys.dar],
date: DateTime.tryParse(json?[FFProbeKeys.date] ?? ""),
duration: _formatDuration(json?[FFProbeKeys.durationMicros]),
durationMicros: formatPreciseDuration(
Duration(
microseconds:
int.tryParse(json?[FFProbeKeys.durationMicros] ?? "") ?? 0,
),
),
extraDataSize: json?[FFProbeKeys.extraDataSize],
fieldOrder: json?[FFProbeKeys.fieldOrder],
fpsDen: json?[FFProbeKeys.fpsDen],
frameCount: int.tryParse(json?[FFProbeKeys.frameCount] ?? ""),
handlerName: json?[FFProbeKeys.handlerName],
hasBFrames: json?[FFProbeKeys.hasBFrames],
height: int.tryParse(json?[FFProbeKeys.height] ?? ""),
language: json?[FFProbeKeys.language],
location: _formatLocation(json?[FFProbeKeys.location]),
majorBrand: _formatBrand(json?[FFProbeKeys.majorBrand]),
mediaFormat: json?[FFProbeKeys.mediaFormat],
mediaType: json?[FFProbeKeys.mediaType],
minorVersion: json?[FFProbeKeys.minorVersion],
nalLengthSize: json?[FFProbeKeys.nalLengthSize],
quicktimeLocationAccuracyHorizontal:
json?[FFProbeKeys.quicktimeLocationAccuracyHorizontal],
rFrameRate: int.tryParse(json?[FFProbeKeys.rFrameRate] ?? ""),
rotate: json?[FFProbeKeys.rotate],
sampleFormat: json?[FFProbeKeys.sampleFormat],
sampleRate: json?[FFProbeKeys.sampleRate],
sampleAspectRatio: json?[FFProbeKeys.sar],
sarDen: json?[FFProbeKeys.sarDen],
segmentCount: int.tryParse(json?[FFProbeKeys.segmentCount] ?? ""),
sourceOshash: json?[FFProbeKeys.sourceOshash],
startMicros: json?[FFProbeKeys.startMicros],
startPts: json?[FFProbeKeys.startPts],
startTime: _formatDuration(json?[FFProbeKeys.startTime]),
statisticsWritingApp: json?[FFProbeKeys.statisticsWritingApp],
statisticsWritingDateUtc: json?[FFProbeKeys.statisticsWritingDateUtc],
timeBase: json?[FFProbeKeys.timeBase],
track: json?[FFProbeKeys.title],
vendorId: json?[FFProbeKeys.vendorId],
width: int.tryParse(json?[FFProbeKeys.width] ?? ""),
xiaomiSlowMoment: json?[FFProbeKeys.xiaomiSlowMoment],
);
final Map<String, dynamic> parsedData = {};
for (final key in json!.keys) {
final stringKey = key.toString();
switch (stringKey) {
case FFProbeKeys.bitrate:
case FFProbeKeys.bps:
parsedData[stringKey] = _formatMetric(json[key], 'b/s');
break;
case FFProbeKeys.byteCount:
parsedData[stringKey] = _formatFilesize(json[key]);
break;
case FFProbeKeys.channelLayout:
parsedData[stringKey] = _formatChannelLayout(json[key]);
break;
case FFProbeKeys.codecName:
parsedData[stringKey] = _formatCodecName(json[key]);
break;
case FFProbeKeys.codecPixelFormat:
case FFProbeKeys.colorPrimaries:
case FFProbeKeys.colorRange:
case FFProbeKeys.colorSpace:
case FFProbeKeys.colorTransfer:
parsedData[stringKey] = (json[key] as String?)?.toUpperCase();
break;
case FFProbeKeys.creationTime:
parsedData[stringKey] = _formatDate(json[key] ?? "");
break;
case FFProbeKeys.durationMicros:
parsedData[stringKey] = formatPreciseDuration(
Duration(microseconds: int.tryParse(json[key] ?? "") ?? 0),
);
break;
case FFProbeKeys.location:
parsedData[stringKey] = _formatLocation(json[key]);
break;
case FFProbeKeys.majorBrand:
parsedData[stringKey] = _formatBrand(json[key]);
break;
case FFProbeKeys.startTime:
parsedData[stringKey] = _formatDuration(json[key]);
break;
default:
parsedData[stringKey] = json[key];
}
}
return FFProbeProps(prodData: parsedData);
}
static String _formatBrand(String value) => Mp4.brands[value] ?? value;

View File

@@ -10,6 +10,7 @@ import "package:photos/core/configuration.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/people_changed_event.dart";
import "package:photos/generated/l10n.dart";
import "package:photos/models/ffmpeg/ffprobe_props.dart";
import 'package:photos/models/file/file.dart';
import 'package:photos/models/file/file_type.dart';
import "package:photos/models/metadata/file_magic.dart";
@@ -24,6 +25,7 @@ import "package:photos/ui/viewer/file_details/albums_item_widget.dart";
import 'package:photos/ui/viewer/file_details/backed_up_time_item_widget.dart';
import "package:photos/ui/viewer/file_details/creation_time_item_widget.dart";
import 'package:photos/ui/viewer/file_details/exif_item_widgets.dart';
import "package:photos/ui/viewer/file_details/exif_video.dart";
import "package:photos/ui/viewer/file_details/faces_item_widget.dart";
import "package:photos/ui/viewer/file_details/file_properties_item_widget.dart";
import "package:photos/ui/viewer/file_details/location_tags_widget.dart";
@@ -45,7 +47,6 @@ class FileDetailsWidget extends StatefulWidget {
}
class _FileDetailsWidgetState extends State<FileDetailsWidget> {
final ValueNotifier<Map<String, IfdTag>?> _exifNotifier = ValueNotifier(null);
final Map<String, dynamic> _exifData = {
"focalLength": null,
"fNumber": null,
@@ -65,8 +66,11 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
bool _isImage = false;
late int _currentUserID;
bool showExifListTile = false;
final ValueNotifier<Map<String, IfdTag>?> _exifNotifier = ValueNotifier(null);
final ValueNotifier<bool> hasLocationData = ValueNotifier(false);
final Logger _logger = Logger("_FileDetailsWidgetState");
final ValueNotifier<FFProbeProps?> _videoMetadataNotifier =
ValueNotifier(null);
@override
void initState() {
@@ -123,11 +127,11 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
return;
}
final properties = await FFProbeUtil.getProperties(mediaInfo);
final parsedMetadata = await FFProbeUtil.getMetadata(mediaInfo);
_videoMetadataNotifier.value = properties;
// print all the properties
log("videoCustomProps ${properties.toString()}");
log("videoMetadata ${parsedMetadata.toString()}");
log("PropData ${properties.prodData.toString()}");
setState(() {});
}
@@ -135,6 +139,7 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
@override
void dispose() {
_exifNotifier.dispose();
_videoMetadataNotifier.dispose();
_peopleChangedEvent.cancel();
super.dispose();
}
@@ -192,6 +197,25 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
),
);
fileDetailsTiles.add(
ValueListenableBuilder(
valueListenable: _videoMetadataNotifier,
builder: (context, value, _) {
return value != null &&
value.prodData != null &&
value.prodData!.isNotEmpty
? const Column(
children: [
Text("show video info"),
// VideoProbeInfo(probeData: value.prodData!),
FileDetailsDivider(),
],
)
: const SizedBox.shrink();
},
),
);
fileDetailsTiles.addAll([
ValueListenableBuilder(
valueListenable: hasLocationData,
@@ -263,6 +287,22 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
},
),
]);
} else if (_videoMetadataNotifier.value != null) {
fileDetailsTiles.addAll([
ValueListenableBuilder(
valueListenable: _videoMetadataNotifier,
builder: (context, value, _) {
return (value != null && value.prodData != null)
? Column(
children: [
VideoProbeInfoDetail(file, value.prodData),
const FileDetailsDivider(),
],
)
: const SizedBox.shrink();
},
),
]);
}
if (LocalSettings.instance.isFaceIndexingEnabled) {

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
class VideoProbeInfo extends StatelessWidget {
final Map<String, dynamic> probeData;
const VideoProbeInfo({Key? key, required this.probeData}) : super(key: key);
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSection('General Information', _buildGeneralInfo()),
const SizedBox(height: 8),
_buildSection('Streams', _buildStreamsList()),
],
),
),
);
}
Widget _buildSection(String title, Widget content) {
return ExpansionTile(
initiallyExpanded: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
children: [content],
);
}
Widget _buildGeneralInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('Duration', probeData, 'duration'),
_buildInfoRow('Probe Score', probeData, 'probe_score'),
_buildInfoRow('Number of Programs', probeData, 'nb_programs'),
_buildInfoRow('Number of Streams', probeData, 'nb_streams'),
_buildInfoRow('Bitrate', probeData, 'bitrate'),
_buildInfoRow('Format', probeData, 'format'),
_buildInfoRow('Creation Time', probeData, 'creation_time'),
],
);
}
Widget _buildStreamsList() {
final List<dynamic> streams = probeData['streams'];
final List<Map<String, dynamic>> data = [];
for (final stream in streams) {
final Map<String, dynamic> streamData = {};
for (final key in stream.keys) {
final dynamic value = stream[key];
// print type of value
if (value is int ||
value is double ||
value is String ||
value is bool) {
streamData[key] = stream[key];
} else {
streamData[key] = stream[key].toString();
}
}
data.add(streamData);
}
return Column(
children: data.map((stream) => _buildStreamInfo(stream)).toList(),
);
}
Widget _buildStreamInfo(Map<String, dynamic> stream) {
return ExpansionTile(
title: Text(
'Stream ${stream['index']}: ${stream['codec_name']} (${stream['type']})',
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: stream.entries
.map((entry) => _buildInfoRow(entry.key, stream, entry.key))
.toList(),
),
],
);
}
Widget _buildInfoRow(
String rowName,
Map<String, dynamic> data,
String dataKey,
) {
rowName = rowName.replaceAll('_', ' ');
rowName = rowName[0].toUpperCase() + rowName.substring(1);
try {
final value = data[dataKey];
if (value == null) {
return Container(); // Return an empty container if there's no data for the key.
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
rowName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(child: Text(value.toString())),
],
),
);
} catch (e, s) {
return Container();
}
}
}

View File

@@ -0,0 +1,90 @@
import "package:flutter/material.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
import "package:photos/generated/l10n.dart";
import 'package:photos/models/file/file.dart';
import "package:photos/theme/colors.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/components/info_item_widget.dart";
import "package:photos/ui/viewer/file/video_ffmpeg_info.dart";
import "package:photos/utils/toast_util.dart";
class VideoProbeInfoDetail extends StatefulWidget {
final EnteFile file;
final Map<String, dynamic>? exif;
const VideoProbeInfoDetail(
this.file,
this.exif, {
super.key,
});
@override
State<VideoProbeInfoDetail> createState() => _VideoProbeInfoState();
}
class _VideoProbeInfoState extends State<VideoProbeInfoDetail> {
VoidCallback? _onTap;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return InfoItemWidget(
leadingIcon: Icons.text_snippet_outlined,
title: S.of(context).exif,
subtitleSection: _exifButton(context, widget.file, widget.exif),
onTap: _onTap,
);
}
Future<List<Widget>> _exifButton(
BuildContext context,
EnteFile file,
Map<String, dynamic>? exif,
) async {
late final String label;
late final VoidCallback? onTap;
final Map<String, dynamic> data = {};
for (final key in exif!.keys) {
if (exif[key] != null) {
data[key] = exif[key];
}
}
if (exif == null) {
label = S.of(context).loadingExifData;
onTap = null;
} else if (exif.isNotEmpty) {
label = S.of(context).viewAllExifData;
onTap = () => showBarModalBottomSheet(
context: context,
builder: (BuildContext context) {
return VideoProbeInfo(
probeData: data,
);
},
shape: const RoundedRectangleBorder(
side: BorderSide(width: 0),
borderRadius: BorderRadius.vertical(
top: Radius.circular(5),
),
),
topControl: const SizedBox.shrink(),
backgroundColor: getEnteColorScheme(context).backgroundElevated,
barrierColor: backdropFaintDark,
enableDrag: false,
);
} else {
label = S.of(context).noExifData;
onTap =
() => showShortToast(context, S.of(context).thisImageHasNoExifData);
}
setState(() {
_onTap = onTap;
});
return Future.value([
Text(label, style: getEnteTextTheme(context).miniBoldMuted),
]);
}
}