ente/mobile/lib/models/ffmpeg/ffprobe_props.dart

447 lines
14 KiB
Dart

// 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";
import "package:photos/models/ffmpeg/codecs.dart";
import "package:photos/models/ffmpeg/ffprobe_keys.dart";
import "package:photos/models/ffmpeg/language.dart";
import "package:photos/models/ffmpeg/mp4.dart";
import "package:photos/models/location/location.dart";
class FFProbeProps {
Map<String, dynamic>? propData;
Location? location;
DateTime? creationTimeUTC;
String? bitrate;
String? majorBrand;
String? fps;
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 (_width != null && _height != null) {
info.add('$_width x $_height');
}
return info.join(' * ');
}
int? get width {
if (_width == null || _height == null) return null;
int? finalWidth = int.tryParse(_width!);
if (propData?[FFProbeKeys.sampleAspectRatio] != null &&
finalWidth != null) {
finalWidth = _calculateWidthConsideringSAR(finalWidth);
}
if (_rotation != null) {
if ((_rotation! ~/ 90).isOdd) {
finalWidth = int.tryParse(_height!);
}
}
return finalWidth;
}
/// To know more, read about Sample Aspect Ratio (SAR), Display Aspect Ratio (DAR)
/// and Pixel Aspect Ratio (PAR)
int _calculateWidthConsideringSAR(int width) {
final List<String> sar =
propData![FFProbeKeys.sampleAspectRatio].toString().split(":");
if (sar.length == 2) {
final int sarWidth = int.tryParse(sar[0]) ?? 1;
final int sarHeight = int.tryParse(sar[1]) ?? 1;
return (width * (sarWidth / sarHeight)).toInt();
} else {
return width;
}
}
int? get height {
if (_width == null || _height == null) return null;
final intHeight = int.tryParse(_height!);
if (_rotation == null) {
return intHeight;
} else {
if ((_rotation! ~/ 90).isEven) {
return intHeight;
} else {
return int.tryParse(_width!);
}
}
}
double? get aspectRatio {
if (width == null || height == null || height == 0 || width == 0) {
return null;
}
return width! / height!;
}
// toString() method
@override
String toString() {
final buffer = StringBuffer();
for (final key in propData!.keys) {
final value = propData![key];
if (value != null) {
buffer.writeln('$key: $value');
}
}
return buffer.toString();
}
static parseData(Map<dynamic, dynamic>? json) {
final Map<String, dynamic> parsedData = {};
final FFProbeProps result = FFProbeProps();
for (final key in json!.keys) {
final stringKey = key.toString();
switch (stringKey) {
case FFProbeKeys.bitrate:
case FFProbeKeys.bps:
result.bitrate = _formatMetric(json[key], 'b/s');
parsedData[stringKey] = result.bitrate;
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] ?? "");
result.creationTimeUTC = _getUTCDateTime(json[key] ?? "");
break;
case FFProbeKeys.durationMicros:
parsedData[stringKey] = formatPreciseDuration(
Duration(microseconds: int.tryParse(json[key] ?? "") ?? 0),
);
break;
case FFProbeKeys.duration:
parsedData[stringKey] = _formatDuration(json[key]);
case FFProbeKeys.location:
result.location = _formatLocation(json[key]);
if (result.location != null) {
parsedData[stringKey] =
'${result.location!.latitude}, ${result.location!.longitude}';
}
break;
case FFProbeKeys.quickTimeLocation:
result.location =
_formatLocation(json[FFProbeKeys.quickTimeLocation]);
if (result.location != null) {
parsedData[FFProbeKeys.location] =
'${result.location!.latitude}, ${result.location!.longitude}';
}
break;
case FFProbeKeys.majorBrand:
result.majorBrand = _formatBrand(json[key]);
parsedData[stringKey] = result.majorBrand;
break;
case FFProbeKeys.startTime:
parsedData[stringKey] = _formatDuration(json[key]);
break;
default:
parsedData[stringKey] = json[key];
}
}
// iterate through the streams
final List<dynamic> streams = json["streams"];
final List<dynamic> newStreams = [];
final Map<String, dynamic> metadata = {};
for (final stream in streams) {
if (stream['type'] == 'metadata') {
for (final key in stream.keys) {
if (key == FFProbeKeys.frameCount && stream[key]?.toString() == "1") {
continue;
}
metadata[key] = stream[key];
}
metadata.remove(FFProbeKeys.index);
} else {
newStreams.add(stream);
}
for (final key in stream.keys) {
if (key == FFProbeKeys.rFrameRate) {
result.fps = _formatFPS(stream[key]);
parsedData[key] = result.fps;
}
//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) {
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) {
for (Map sideData in stream[key]) {
if (sideData["side_data_type"] == "Display Matrix") {
result._rotation = sideData[FFProbeKeys.rotation];
parsedData[FFProbeKeys.rotation] = result._rotation;
}
}
} else if (key == FFProbeKeys.sampleAspectRatio) {
parsedData[key] = stream[key];
}
}
}
if (metadata.isNotEmpty) {
newStreams.add(metadata);
}
parsedData["streams"] = newStreams;
result.propData = parsedData;
return result;
}
static String _formatBrand(String value) => Mp4.brands[value] ?? value;
static String _formatChannelLayout(dynamic value) {
if (value is int) {
return ChannelLayouts.names[value] ?? 'unknown ($value)';
}
return '$value';
}
static final Map<String, String> _codecNames = {
Codecs.ac3: 'AC-3',
Codecs.eac3: 'E-AC-3',
Codecs.h264: 'AVC (H.264)',
Codecs.hevc: 'HEVC (H.265)',
Codecs.matroska: 'Matroska',
Codecs.mpeg4: 'MPEG-4 Visual',
Codecs.mpts: 'MPEG-TS',
Codecs.opus: 'Opus',
Codecs.pgs: 'PGS',
Codecs.subrip: 'SubRip',
Codecs.theora: 'Theora',
Codecs.vorbis: 'Vorbis',
Codecs.webm: 'WebM',
};
static String? _formatCodecName(String? value) =>
value == null || value == "none"
? null
: _codecNames[value] ?? value.toUpperCase().replaceAll('_', ' ');
// input example: '2021-04-12T09:14:37.000000Z'
static String? _formatDate(String value) {
final dateInUtc = DateTime.tryParse(value);
if (dateInUtc == null) return value;
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
if (dateInUtc == epoch) return null;
final newDate =
DateTime.fromMicrosecondsSinceEpoch(dateInUtc.microsecondsSinceEpoch);
return formatDateTime(newDate, 'en_US', false);
}
static DateTime? _getUTCDateTime(String value) {
final dateInUtc = DateTime.tryParse(value);
if (dateInUtc == null) return null;
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
if (dateInUtc == epoch) return null;
return DateTime.fromMicrosecondsSinceEpoch(
dateInUtc.microsecondsSinceEpoch,
);
}
// input example: '00:00:05.408000000' or '5.408000'
static Duration? _parseDuration(String? value) {
if (value == null) return null;
var match = _durationHmsmPattern.firstMatch(value);
if (match != null) {
final h = int.tryParse(match.group(1)!);
final m = int.tryParse(match.group(2)!);
final s = int.tryParse(match.group(3)!);
final millis = double.tryParse(match.group(4)!);
if (h != null && m != null && s != null && millis != null) {
return Duration(
hours: h,
minutes: m,
seconds: s,
milliseconds: (millis * 1000).toInt(),
);
}
}
match = _durationSmPattern.firstMatch(value);
if (match != null) {
final s = int.tryParse(match.group(1)!);
final millis = double.tryParse(match.group(2)!);
if (s != null && millis != null) {
return Duration(
seconds: s,
milliseconds: (millis * 1000).toInt(),
);
}
}
return null;
}
// input example: '00:00:05.408000000' or '5.408000'
static String? _formatDuration(String? value) {
if (value == null) return null;
final duration = _parseDuration(value);
return duration != null ? formatFriendlyDuration(duration) : value;
}
static String? _formatFilesize(dynamic value) {
if (value == null) return null;
final size = value is int ? value : int.tryParse(value);
const String asciiLocale = 'en_US';
return size != null ? formatFileSize(asciiLocale, size) : value;
}
static String? _formatFPS(dynamic value) {
if (value == null) return null;
final int? t = int.tryParse(value.split('/')[0]);
final int? b = int.tryParse(value.split('/')[1]);
if (t != null && b != null) {
// return the value upto 2 decimal places. ignore even two decimal places
// if t is perfectly divisible by b
return (t % b == 0)
? (t / b).toStringAsFixed(0)
: (t / b).toStringAsFixed(2);
}
return value;
}
static String _formatLanguage(String value) {
final language = Language.living639_2
.firstWhereOrNull((language) => language.iso639_2 == value);
return language?.native ?? value;
}
static final _durationHmsmPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
static final _durationSmPattern = RegExp(r'(\d+)(.\d+)');
static final _locationPattern = RegExp(r'([+-][.0-9]+)');
// format ISO 6709 input, e.g. '+37.5090+127.0243/' (Samsung), '+51.3328-000.7053+113.474/' (Apple)
static Location? _formatLocation(String? value) {
if (value == null) return null;
final matches = _locationPattern.allMatches(value);
if (matches.isNotEmpty) {
final coordinates =
matches.map((m) => double.tryParse(m.group(0)!)).toList();
if (coordinates.every((c) => c == 0)) return null;
try {
return Location(
latitude: coordinates[0],
longitude: coordinates[1],
);
} catch (e) {
log('failed to parse location: $value', error: e);
return null;
}
}
return null;
}
static String? _formatMetric(dynamic size, String unit, {int round = 2}) {
if (size == null) return null;
if (size is String) {
final parsed = int.tryParse(size);
if (parsed == null) return size;
size = parsed;
}
const divider = 1000;
if (size < divider) return '$size $unit';
if (size < divider * divider) {
return '${(size / divider).toStringAsFixed(round)} K$unit';
}
return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
}
}
String formatDay(DateTime date, String locale) =>
DateFormat.yMMMd(locale).format(date);
String formatTime(DateTime date, String locale, bool use24hour) =>
(use24hour ? DateFormat.Hm(locale) : DateFormat.jm(locale)).format(date);
String formatDateTime(DateTime date, String locale, bool use24hour) => [
formatDay(date, locale),
formatTime(date, locale, use24hour),
].join(" ");
String formatFriendlyDuration(Duration d) {
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute))
.toString()
.padLeft(2, '0');
if (d.inHours == 0) return '${d.inMinutes}:$seconds';
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour))
.toString()
.padLeft(2, '0');
return '${d.inHours}:$minutes:$seconds';
}
String? formatPreciseDuration(Duration d) {
if (d.inSeconds == 0) return null;
final millis =
((d.inMicroseconds / 1000.0).round() % 1000).toString().padLeft(3, '0');
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute))
.toString()
.padLeft(2, '0');
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour))
.toString()
.padLeft(2, '0');
final hours = (d.inHours).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds.$millis';
}
const kilo = 1024;
const mega = kilo * kilo;
const giga = mega * kilo;
const tera = giga * kilo;
String formatFileSize(String locale, int size, {int round = 2}) {
if (size < kilo) return '$size B';
final compactFormatter =
NumberFormat('0${round > 0 ? '.${'0' * round}' : ''}', locale);
if (size < mega) return '${compactFormatter.format(size / kilo)} KB';
if (size < giga) return '${compactFormatter.format(size / mega)} MB';
if (size < tera) return '${compactFormatter.format(size / giga)} GB';
return '${compactFormatter.format(size / tera)} TB';
}