diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0c1d46d..7a534225e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed + +- Video: use `media-kit` instead of `ffmpeg-kit` for metadata fetch +- Info: show video chapters + +### Fixed + +- crash when cataloguing some videos + ## [v1.12.1] - 2025-01-05 ### Added diff --git a/lib/model/app/dependencies.dart b/lib/model/app/dependencies.dart index d09b2b933..5171e7fbb 100644 --- a/lib/model/app/dependencies.dart +++ b/lib/model/app/dependencies.dart @@ -73,11 +73,6 @@ class Dependencies { licenseUrl: 'https://github.com/material-foundation/flutter-packages/blob/main/packages/dynamic_color/LICENSE', sourceUrl: 'https://github.com/material-foundation/flutter-packages/tree/main/packages/dynamic_color', ), - Dependency( - name: 'FFmpegKit (Aves fork)', - license: lgpl3, - sourceUrl: 'https://github.com/deckerst/ffmpeg-kit', - ), Dependency( name: 'Floating', license: mit, diff --git a/lib/model/entry/entry.dart b/lib/model/entry/entry.dart index 0a3f6ab98..d5698a903 100644 --- a/lib/model/entry/entry.dart +++ b/lib/model/entry/entry.dart @@ -232,6 +232,7 @@ class AvesEntry with AvesEntryBase { // the MIME type reported by the Media Store is unreliable // so we use the one found during cataloguing if possible + @override String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType; bool get isCatalogued => _catalogMetadata != null; diff --git a/lib/model/entry/extensions/info.dart b/lib/model/entry/extensions/info.dart index 7353a0726..81fc0bf3e 100644 --- a/lib/model/entry/extensions/info.dart +++ b/lib/model/entry/extensions/info.dart @@ -9,6 +9,7 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart'; import 'package:aves/theme/colors.dart'; +import 'package:aves/theme/format.dart'; import 'package:aves/theme/text.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart'; import 'package:aves_model/aves_model.dart'; @@ -82,6 +83,21 @@ extension ExtraAvesEntryInfo on AvesEntry { directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags))); } + if (mediaInfo.containsKey(Keys.chapters)) { + final allChapters = (mediaInfo.remove(Keys.chapters) as List).cast(); + if (allChapters.isNotEmpty) { + allChapters.sortBy((v) => v[Keys.time] as num? ?? 0); + + final chapterTags = SplayTreeMap.of(Map.fromEntries(allChapters.mapIndexed((i, chapter) { + final chapterNumber = i + 1; + final time = Duration(seconds: (chapter[Keys.time] as num? ?? 0).round()); + final title = chapter[Keys.title] as String? ?? 'Chapter $chapterNumber'; + return MapEntry('$chapterNumber${AText.separator}${formatFriendlyDuration(time)}', title); + })), compareNatural); + directories.add(MetadataDirectory('Chapters', chapterTags)); + } + } + if (mediaInfo.containsKey(Keys.streams)) { String getTypeText(Map stream) { final type = stream[Keys.streamType] ?? MediaStreamTypes.unknown; @@ -96,7 +112,7 @@ extension ExtraAvesEntryInfo on AvesEntry { case MediaStreamTypes.timedText: return 'Text'; case MediaStreamTypes.video: - return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image'; + return stream.containsKey(Keys.fpsDen) || stream.containsKey(Keys.fps) ? 'Video' : 'Image'; case MediaStreamTypes.unknown: default: return 'Unknown'; diff --git a/lib/model/media/video/metadata.dart b/lib/model/media/video/metadata.dart index 972f227ba..299d632c8 100644 --- a/lib/model/media/video/metadata.dart +++ b/lib/model/media/video/metadata.dart @@ -6,6 +6,7 @@ import 'package:aves/model/media/video/codecs.dart'; import 'package:aves/model/media/video/profiles/aac.dart'; import 'package:aves/model/media/video/profiles/h264.dart'; import 'package:aves/model/media/video/profiles/hevc.dart'; +import 'package:aves/model/media/video/stereo_3d_modes.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/ref/languages.dart'; import 'package:aves/ref/locales.dart'; @@ -52,10 +53,10 @@ class VideoMetadataFormatter { final streams = mediaInfo[Keys.streams]; if (streams is List) { final allStreamInfo = streams.cast(); - final sizedStream = allStreamInfo.firstWhereOrNull((stream) => stream.containsKey(Keys.width) && stream.containsKey(Keys.height)); + final sizedStream = allStreamInfo.firstWhereOrNull((stream) => stream.containsKey(Keys.videoWidth) && stream.containsKey(Keys.videoHeight)); if (sizedStream != null) { - final width = sizedStream[Keys.width]; - final height = sizedStream[Keys.height]; + final width = sizedStream[Keys.videoWidth]; + final height = sizedStream[Keys.videoHeight]; if (width is int && height is int) { fields['width'] = width; fields['height'] = height; @@ -68,7 +69,7 @@ class VideoMetadataFormatter { fields['durationMillis'] = (durationMicros / 1000).round(); } else { final duration = _parseDuration(mediaInfo[Keys.duration]); - if (duration != null) { + if (duration != null && duration > Duration.zero) { fields['durationMillis'] = duration.inMilliseconds; } } @@ -82,7 +83,7 @@ class VideoMetadataFormatter { if (entry.mimeType == MimeTypes.avif) { final duration = _parseDuration(mediaInfo[Keys.duration]); - if (duration == null) return null; + if (duration == null || duration == Duration.zero) return null; catalogMetadata = catalogMetadata.copyWith(isAnimated: true); } @@ -189,13 +190,14 @@ class VideoMetadataFormatter { } key = (key ?? (kv.key as String)).toLowerCase(); - void save(String key, String? value) { + void save(String key, dynamic value) { if (value != null) { - dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value; + dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value.toString(); } } switch (key) { + case Keys.chapters: case Keys.codecLevel: case Keys.codecTag: case Keys.codecTagString: @@ -219,24 +221,22 @@ class VideoMetadataFormatter { break; case Keys.androidCaptureFramerate: final captureFps = double.parse(value); - save('Capture Frame Rate', '${roundToPrecision(captureFps, decimals: 3).toString()} FPS'); + save('Capture Frame Rate', '${roundToPrecision(captureFps, decimals: 3)} FPS'); case Keys.androidManufacturer: save('Android Manufacturer', value); case Keys.androidModel: save('Android Model', value); case Keys.androidVersion: save('Android Version', value); + case Keys.audioChannels: + save('Audio Channels', value); case Keys.bitrate: case Keys.bps: save('Bit Rate', _formatMetric(value, 'b/s')); - case Keys.bitsPerRawSample: - save('Bits Per Raw Sample', value); case Keys.byteCount: save('Size', _formatFilesize(value)); case Keys.channelLayout: save('Channel Layout', _formatChannelLayout(value)); - case Keys.chromaLocation: - save('Chroma Location', value); case Keys.codecName: if (value != 'none') { save('Format', _formatCodecName(value)); @@ -245,20 +245,28 @@ class VideoMetadataFormatter { if (streamType == MediaStreamTypes.video) { // this is just a short name used by FFmpeg // user-friendly descriptions for related enums are defined in libavutil/pixfmt.h - save('Pixel Format', (value as String).toUpperCase()); + save('Pixel Format', value.toString().toUpperCase()); } + case Keys.hwPixelFormat: + save('Hardware Pixel Format', value.toString().toUpperCase()); case Keys.codedHeight: save('Coded Height', '$value pixels'); case Keys.codedWidth: save('Coded Width', '$value pixels'); + case Keys.decoderHeight: + save('Decoder Height', '$value pixels'); + case Keys.decoderWidth: + save('Decoder Width', '$value pixels'); + case Keys.colorMatrix: + save('Color Matrix', value.toString().toUpperCase()); case Keys.colorPrimaries: - save('Color Primaries', (value as String).toUpperCase()); + save('Color Primaries', value.toString().toUpperCase()); case Keys.colorRange: - save('Color Range', (value as String).toUpperCase()); + save('Color Range', value.toString().toUpperCase()); case Keys.colorSpace: - save('Color Space', (value as String).toUpperCase()); + save('Color Space', value.toString().toUpperCase()); case Keys.colorTransfer: - save('Color Transfer', (value as String).toUpperCase()); + save('Color Transfer', value.toString().toUpperCase()); case Keys.codecProfileId: { final profile = int.tryParse(value); @@ -294,8 +302,6 @@ class VideoMetadataFormatter { save('Compatible Brands', formattedBrands); case Keys.creationTime: save('Creation Time', _formatDate(value)); - case Keys.dar: - save('Display Aspect Ratio', value); case Keys.date: if (value is String && value != '0') { final charCount = value.length; @@ -307,18 +313,18 @@ class VideoMetadataFormatter { if (value != 0) save('Duration', formatPreciseDuration(Duration(microseconds: value))); case Keys.extraDataSize: save('Extra Data Size', _formatFilesize(value)); - case Keys.fieldOrder: - save('Field Order', value); + case Keys.fps: + save('Frame Rate', '${roundToPrecision(info[Keys.fps], decimals: 3)} FPS'); case Keys.fpsDen: - save('Frame Rate', '${roundToPrecision(info[Keys.fpsNum] / info[Keys.fpsDen], decimals: 3).toString()} FPS'); + save('Frame Rate', '${roundToPrecision(info[Keys.fpsNum] / info[Keys.fpsDen], decimals: 3)} FPS'); case Keys.frameCount: save('Frame Count', value); - case Keys.handlerName: - save('Handler Name', value); + case Keys.gamma: + save('Gamma', value.toString().toUpperCase()); case Keys.hasBFrames: save('Has B-Frames', value); - case Keys.height: - save('Height', '$value pixels'); + case Keys.hearingImpaired: + save('Hearing impaired', value); case Keys.language: if (value != 'und') save('Language', _formatLanguage(value)); case Keys.location: @@ -326,9 +332,7 @@ class VideoMetadataFormatter { case Keys.majorBrand: save('Major Brand', _formatBrand(value)); case Keys.mediaFormat: - save('Format', (value as String).splitMapJoin(',', onMatch: (s) => ', ', onNonMatch: _formatCodecName)); - case Keys.mediaType: - save('Media Type', value); + save('Format', value.toString().splitMapJoin(',', onMatch: (s) => ', ', onNonMatch: _formatCodecName)); case Keys.minorVersion: if (value != '0') save('Minor Version', value); case Keys.nalLengthSize: @@ -347,7 +351,7 @@ class VideoMetadataFormatter { case Keys.rotate: save('Rotation', '$value°'); case Keys.sampleFormat: - save('Sample Format', (value as String).toUpperCase()); + save('Sample Format', value.toString().toUpperCase()); case Keys.sampleRate: save('Sample Rate', _formatMetric(value, 'Hz')); case Keys.sar: @@ -371,18 +375,24 @@ class VideoMetadataFormatter { save('Stats Writing App', value); case Keys.statisticsWritingDateUtc: save('Stats Writing Date', _formatDate(value)); + case Keys.stereo3dMode: + save('Stereo 3D Mode', _formatStereo3dMode(value)); case Keys.timeBase: save('Time Base', value); case Keys.track: if (value != '0') save('Track', value); case Keys.vendorId: save('Vendor ID', value); - case Keys.width: - save('Width', '$value pixels'); + case Keys.videoHeight: + save('Video Height', '$value pixels'); + case Keys.videoWidth: + save('Video Width', '$value pixels'); + case Keys.visualImpaired: + save('Visual impaired', value); case Keys.xiaomiSlowMoment: save('Xiaomi Slow Moment', value); default: - save(key.toSentenceCase(), value.toString()); + save(key.toSentenceCase(), value); } } catch (error) { debugPrint('failed to process video info key=${kv.key} value=${kv.value}, error=$error'); @@ -411,6 +421,8 @@ class VideoMetadataFormatter { return date.toIso8601String(); } + static String _formatStereo3dMode(String value) => Stereo3dModes.names[value] ?? value; + // input example: '00:00:05.408000000' or '5.408000' static Duration? _parseDuration(String? value) { if (value == null) return null; diff --git a/lib/model/media/video/stereo_3d_modes.dart b/lib/model/media/video/stereo_3d_modes.dart new file mode 100644 index 000000000..d0ff76bd6 --- /dev/null +++ b/lib/model/media/video/stereo_3d_modes.dart @@ -0,0 +1,22 @@ +class Stereo3dModes { + static const names = { + 'ab2l': 'above below half height left first', + 'tb2l': 'above below half height left first', + 'ab2r': 'above below half height right first', + 'tb2r': 'above below half height right first', + 'abl': 'above below left first', + 'tbl': 'above below left first', + 'abr': 'above below right first', + 'tbr': 'above below right first', + 'al': 'alternating frames left first', + 'ar': 'alternating frames right first', + 'sbs2l': 'side by side half width left first', + 'sbs2r': 'side by side half width right first', + 'sbsl': 'side by side left first', + 'sbsr': 'side by side right first', + 'irl': 'interleave rows left first', + 'irr': 'interleave rows right first', + 'icl': 'interleave columns left first', + 'icr': 'interleave columns right first', + }; +} diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart index 693199a96..0dc31a7ca 100644 --- a/lib/services/analysis_service.dart +++ b/lib/services/analysis_service.dart @@ -60,6 +60,7 @@ Future _init() async { await mobileServices.init(); await settings.init(monitorPlatformSettings: false); await reportService.init(); + videoMetadataFetcher.init(); final analyzer = Analyzer(); _channel.setMethodCallHandler((call) { diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart index 465f2b471..eae00cbcb 100644 --- a/lib/services/common/services.dart +++ b/lib/services/common/services.dart @@ -21,7 +21,6 @@ import 'package:aves_report_platform/aves_report_platform.dart'; import 'package:aves_services/aves_services.dart'; import 'package:aves_services_platform/aves_services_platform.dart'; import 'package:aves_video/aves_video.dart'; -import 'package:aves_video_ffmpeg/aves_video_ffmpeg.dart'; import 'package:aves_video_mpv/aves_video_mpv.dart'; import 'package:get_it/get_it.dart'; import 'package:path/path.dart' as p; @@ -58,7 +57,7 @@ void initPlatformServices() { getIt.registerLazySingleton(LiveAvesAvailability.new); getIt.registerLazySingleton(SqfliteLocalMediaDb.new); getIt.registerLazySingleton(MpvVideoControllerFactory.new); - getIt.registerLazySingleton(FfmpegVideoMetadataFetcher.new); + getIt.registerLazySingleton(MpvVideoMetadataFetcher.new); getIt.registerLazySingleton(PlatformAppService.new); getIt.registerLazySingleton(PlatformAppProfileService.new); diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 4b4950e41..519928571 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -1,9 +1,12 @@ extension ExtraString on String { static final _sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); - static final _sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); + static final _sentenceCaseStep2 = RegExp(r'_([a-z])'); + static final _sentenceCaseStep3 = RegExp(r'([a-z])([A-Z])'); String toSentenceCase() { var s = replaceFirstMapped(RegExp('.'), (m) => m.group(0)!.toUpperCase()); - return s.replaceAllMapped(_sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(_sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); + s = s.replaceAllMapped(_sentenceCaseStep1, (m) => ' ${m.group(1)}'); + s = s.replaceAllMapped(_sentenceCaseStep2, (m) => m.group(0)!.toUpperCase()).replaceAll('_', ' '); + return s.replaceAllMapped(_sentenceCaseStep3, (m) => '${m.group(1)} ${m.group(2)}').trim(); } } diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index e574d93d5..e3cf6328c 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -498,6 +498,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { await _onTvLayoutChanged(); _monitorSettings(); videoControllerFactory.init(); + videoMetadataFetcher.init(); unawaited(deviceService.setLocaleConfig(AvesApp.supportedLocales)); unawaited(storageService.deleteTempDirectory()); diff --git a/plugins/aves_model/lib/src/entry/base.dart b/plugins/aves_model/lib/src/entry/base.dart index 716cd96ab..6d97bcf83 100644 --- a/plugins/aves_model/lib/src/entry/base.dart +++ b/plugins/aves_model/lib/src/entry/base.dart @@ -9,6 +9,8 @@ mixin AvesEntryBase { int? get pageId; + String get mimeType; + String? get path; String? get bestTitle; diff --git a/plugins/aves_model/lib/src/video/keys.dart b/plugins/aves_model/lib/src/video/keys.dart index 40a64c534..c27f9b230 100644 --- a/plugins/aves_model/lib/src/video/keys.dart +++ b/plugins/aves_model/lib/src/video/keys.dart @@ -2,18 +2,23 @@ // they originate from FFmpeg, fijkplayer, and other software // that write additional metadata to media files class Keys { + static const alpha = 'alpha'; static const androidCaptureFramerate = 'com.android.capture.fps'; static const androidManufacturer = 'com.android.manufacturer'; static const androidModel = 'com.android.model'; static const androidVersion = 'com.android.version'; + static const audioChannels = 'audio-channels'; static const avgFrameRate = 'avg_frame_rate'; static const bps = 'bps'; static const bitrate = 'bitrate'; - static const bitsPerRawSample = 'bits_per_raw_sample'; + static const bitsPerSample = 'bits_per_sample'; static const byteCount = 'number_of_bytes'; static const channelLayout = 'channel_layout'; + static const chapters = 'chapters'; static const chromaLocation = 'chroma_location'; + static const closedCaptions = 'closed_captions'; static const codecLevel = 'codec_level'; + static const codecLongName = 'codec_long_name'; static const codecName = 'codec_name'; static const codecPixelFormat = 'codec_pixel_format'; static const codecProfileId = 'codec_profile_id'; @@ -21,6 +26,8 @@ class Keys { static const codecTagString = 'codec_tag_string'; static const codedHeight = 'coded_height'; static const codedWidth = 'coded_width'; + static const colorLevels = 'color_levels'; + static const colorMatrix = 'color_matrix'; static const colorPrimaries = 'color_primaries'; static const colorRange = 'color_range'; static const colorSpace = 'color_space'; @@ -29,29 +36,34 @@ class Keys { static const creationTime = 'creation_time'; static const dar = 'display_aspect_ratio'; static const date = 'date'; + static const decoderHeight = 'dh'; + static const decoderWidth = 'dw'; static const disposition = 'disposition'; static const duration = 'duration'; static const durationMicros = 'duration_us'; static const durationTs = 'duration_ts'; static const encoder = 'encoder'; static const extraDataSize = 'extradata_size'; - static const fieldOrder = 'field_order'; static const filename = 'filename'; + static const filmGrain = 'film_grain'; static const fpsDen = 'fps_den'; static const fpsNum = 'fps_num'; + static const fps = 'fps'; static const frameCount = 'number_of_frames'; - static const handlerName = 'handler_name'; + static const gamma = 'gamma'; static const hasBFrames = 'has_b_frames'; - static const height = 'height'; + static const hearingImpaired = 'hearing_impaired'; + static const hwPixelFormat = 'hw_pixel_format'; static const index = 'index'; static const isAvc = 'is_avc'; static const language = 'language'; + static const light = 'light'; static const location = 'location'; static const majorBrand = 'major_brand'; static const mediaFormat = 'format'; - static const mediaType = 'media_type'; static const minorVersion = 'minor_version'; static const nalLengthSize = 'nal_length_size'; + static const par = 'pixel_aspect_ratio'; static const probeScore = 'probe_score'; static const programCount = 'nb_programs'; static const quicktimeCreationDate = 'com.apple.quicktime.creationdate'; @@ -78,16 +90,20 @@ class Keys { static const statisticsTags = '_statistics_tags'; static const statisticsWritingApp = '_statistics_writing_app'; static const statisticsWritingDateUtc = '_statistics_writing_date_utc'; + static const stereo3dMode = 'stereo_3d_mode'; static const streamCount = 'nb_streams'; static const streams = 'streams'; static const tbrDen = 'tbr_den'; static const tbrNum = 'tbr_num'; + static const time = 'time'; static const segmentCount = 'segment_count'; static const streamType = 'type'; static const title = 'title'; static const timeBase = 'time_base'; static const track = 'track'; static const vendorId = 'vendor_id'; - static const width = 'width'; + static const videoHeight = 'height'; + static const videoWidth = 'width'; + static const visualImpaired = 'visual_impaired'; static const xiaomiSlowMoment = 'com.xiaomi.slow_moment'; } diff --git a/plugins/aves_video_ffmpeg/lib/src/metadata.dart b/plugins/aves_video_ffmpeg/lib/src/metadata.dart index 1c4b4a39c..5bf1f03ef 100644 --- a/plugins/aves_video_ffmpeg/lib/src/metadata.dart +++ b/plugins/aves_video_ffmpeg/lib/src/metadata.dart @@ -98,7 +98,7 @@ class FfmpegVideoMetadataFetcher extends AvesVideoMetadataFetcher { } void _normalizeGroup(Map stream) { - void replaceKey(k1, k2) { + void replaceKey(String k1, String k2) { final v = stream.remove(k1); if (v != null) { stream[k2] = v; @@ -119,16 +119,16 @@ class FfmpegVideoMetadataFetcher extends AvesVideoMetadataFetcher { } { + Keys.bitsPerSample, + Keys.closedCaptions, + Keys.codecLongName, Keys.codecProfileId, + Keys.filmGrain, + Keys.hasBFrames, Keys.rFrameRate, - 'bits_per_sample', - 'closed_captions', - 'codec_long_name', - 'film_grain', - 'has_b_frames', - 'start_pts', - 'start_time', - 'vendor_id', + Keys.startPts, + Keys.startTime, + Keys.vendorId, }.forEach((key) { final value = stream[key]; switch (value) { diff --git a/plugins/aves_video_ffmpeg/pubspec.lock b/plugins/aves_video_ffmpeg/pubspec.lock new file mode 100644 index 000000000..9def1001f --- /dev/null +++ b/plugins/aves_video_ffmpeg/pubspec.lock @@ -0,0 +1,158 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + aves_model: + dependency: "direct main" + description: + path: "../aves_model" + relative: true + source: path + version: "0.0.1" + aves_utils: + dependency: transitive + description: + path: "../aves_utils" + relative: true + source: path + version: "0.0.1" + aves_video: + dependency: "direct main" + description: + path: "../aves_video" + relative: true + source: path + version: "0.0.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + ffmpeg_kit_flutter: + dependency: "direct main" + description: + path: "flutter/flutter" + ref: background-lts + resolved-ref: "24213bd2334265cfc240525fb9a218b85ad4d872" + url: "https://github.com/deckerst/ffmpeg-kit.git" + source: git + version: "6.0.3" + ffmpeg_kit_flutter_platform_interface: + dependency: transitive + description: + name: ffmpeg_kit_flutter_platform_interface + sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee + url: "https://pub.dev" + source: hosted + version: "0.2.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" +sdks: + dart: ">=3.6.0 <4.0.0" + flutter: ">=2.0.0" diff --git a/plugins/aves_video_ffmpeg/pubspec.yaml b/plugins/aves_video_ffmpeg/pubspec.yaml index 515a2245e..d46ff3aec 100644 --- a/plugins/aves_video_ffmpeg/pubspec.yaml +++ b/plugins/aves_video_ffmpeg/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: none environment: sdk: ^3.6.0 -resolution: workspace +#resolution: workspace dependencies: flutter: diff --git a/plugins/aves_video_mpv/lib/aves_video_mpv.dart b/plugins/aves_video_mpv/lib/aves_video_mpv.dart index 40a67aa2e..0ec578d5b 100644 --- a/plugins/aves_video_mpv/lib/aves_video_mpv.dart +++ b/plugins/aves_video_mpv/lib/aves_video_mpv.dart @@ -1,2 +1,3 @@ export 'src/controller.dart'; export 'src/factory.dart'; +export 'src/metadata.dart'; diff --git a/plugins/aves_video_mpv/lib/src/metadata.dart b/plugins/aves_video_mpv/lib/src/metadata.dart new file mode 100644 index 000000000..f488c08f1 --- /dev/null +++ b/plugins/aves_video_mpv/lib/src/metadata.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:aves_model/aves_model.dart'; +import 'package:aves_video/aves_video.dart'; +import 'package:flutter/widgets.dart'; +import 'package:media_kit/media_kit.dart'; + +class MpvVideoMetadataFetcher extends AvesVideoMetadataFetcher { + static const mpvTypeAudio = 'audio'; + static const mpvTypeVideo = 'video'; + static const mpvTypeSub = 'sub'; + + static const probeTimeoutImage = 500; + static const probeTimeoutVideo = 5000; + + @override + void init() => MediaKit.ensureInitialized(); + + @override + Future getMetadata(AvesEntryBase entry) async { + final player = Player( + configuration: PlayerConfiguration( + logLevel: MPVLogLevel.warn, + protocolWhitelist: [ + ...const PlayerConfiguration().protocolWhitelist, + // Android `content` URIs are considered unsafe by default, + // as they are transferred via a custom `fd` protocol + 'fd', + ], + ), + ); + final platform = player.platform; + if (platform is! NativePlayer) { + throw Exception('Platform player ${platform.runtimeType} does not support property retrieval'); + } + + // We need to enable video decoding to retrieve video params, + // but it is disabled by default unless a `VideoController` is attached. + // Attaching a `VideoController` is problematic, because `player.open()` may not return + // unless a new frame is rendered, and triggering fails from a background service. + // It is simpler to enable the video track via properties. + await platform.setProperty('vid', 'auto'); + + // deselect audio track to prevent triggering Android audio sessions + await platform.setProperty('aid', 'no'); + + final videoDecodedCompleter = Completer(); + StreamSubscription? subscription; + subscription = player.stream.videoParams.listen((v) { + if (v.par != null) { + subscription?.cancel(); + videoDecodedCompleter.complete(); + } + }); + + await player.open(Media(entry.uri), play: false); + + final timeoutMillis = entry.mimeType.startsWith('image') ? probeTimeoutImage : probeTimeoutVideo; + await Future.any([videoDecodedCompleter.future, Future.delayed(Duration(milliseconds: timeoutMillis))]); + + final fields = {}; + + final videoParams = player.state.videoParams; + if (videoParams.par == null) { + debugPrint('failed to probe video metadata within $timeoutMillis ms for entry=$entry'); + } else { + // mpv properties: https://mpv.io/manual/stable/#property-list + + // mpv doc: "duration with milliseconds" + final durationMs = await platform.getProperty('duration/full'); + if (durationMs.isNotEmpty) { + fields[Keys.duration] = durationMs; + } + + // mpv doc: "metadata key/value pairs" + // note: seems to match FFprobe "format" > "tags" fields + final metadata = await platform.getProperty('metadata'); + if (metadata.isNotEmpty) { + try { + jsonDecode(metadata).forEach((key, value) { + fields[key] = value; + }); + } catch (error) { + debugPrint('failed to parse metadata=$metadata with error=$error'); + } + } + + final tracks = await platform.getProperty('track-list'); + if (tracks.isNotEmpty) { + try { + final tracksJson = jsonDecode(tracks); + if (tracksJson is List && tracksJson.isNotEmpty) { + fields[Keys.streams] = tracksJson.whereType().map((stream) { + return _normalizeStream(stream.cast(), videoParams); + }).toList(); + } + } catch (error) { + debugPrint('failed to parse tracks=$tracks with error=$error'); + } + } + + final chapters = await platform.getProperty('chapter-list'); + if (chapters.isNotEmpty) { + try { + final chaptersJson = jsonDecode(chapters); + if (chaptersJson is List && chaptersJson.isNotEmpty) { + final chapterMaps = chaptersJson.whereType().toList(); + if (chapterMaps.isNotEmpty) { + fields[Keys.chapters] = chapterMaps; + } + } + } catch (error) { + debugPrint('failed to parse chapters=$chapters with error=$error'); + } + } + } + + await player.dispose(); + return fields; + } + + Map _normalizeStream(Map stream, VideoParams videoParams) { + void replaceKey(String k1, String k2) { + final v = stream.remove(k1); + if (v != null) { + stream[k2] = v; + } + } + + void removeIfFalse(String k) { + if (stream[k] == false) { + stream.remove(k); + } + } + + stream.remove('id'); + stream.remove('decoder-desc'); + stream.remove('main-selection'); + stream.remove('selected'); + stream.remove('src-id'); + replaceKey('ff-index', Keys.index); + replaceKey('codec', Keys.codecName); + replaceKey('lang', Keys.language); + replaceKey('demux-bitrate', Keys.bitrate); + replaceKey('demux-channel-count', Keys.audioChannels); + replaceKey('demux-fps', Keys.fps); + replaceKey('demux-samplerate', Keys.sampleRate); + replaceKey('hearing-impaired', Keys.hearingImpaired); + replaceKey('visual-impaired', Keys.visualImpaired); + + stream.removeWhere((k, v) => k.startsWith('demux-')); + removeIfFalse('albumart'); + removeIfFalse('default'); + removeIfFalse('dependent'); + removeIfFalse('external'); + removeIfFalse('forced'); + removeIfFalse(Keys.hearingImpaired); + removeIfFalse(Keys.visualImpaired); + + final isImage = stream.remove('image'); + switch (stream.remove('type')) { + case mpvTypeAudio: + stream[Keys.streamType] = MediaStreamTypes.audio; + case mpvTypeVideo: + stream[Keys.streamType] = MediaStreamTypes.video; + if (isImage) { + stream.remove(Keys.fps); + } + + // Some video properties are not in the video track props but accessible via `video-params` (or `video-out-params`). + // These parameters are already stored in the player state, as `videoParams`. + // Parameters `sigPeak` and `averageBpp` are ignored. + final videoParamsTags = { + Keys.alpha: videoParams.alpha, + Keys.chromaLocation: videoParams.chromaLocation, + Keys.codecPixelFormat: videoParams.pixelformat, + Keys.colorLevels: videoParams.colorlevels, + Keys.colorMatrix: videoParams.colormatrix, + Keys.colorPrimaries: videoParams.primaries, + Keys.dar: videoParams.aspect, + Keys.decoderHeight: videoParams.dh, + Keys.decoderWidth: videoParams.dw, + Keys.gamma: videoParams.gamma, + Keys.hwPixelFormat: videoParams.hwPixelformat, + Keys.light: videoParams.light, + Keys.par: videoParams.par, + Keys.rotate: videoParams.rotate, + Keys.stereo3dMode: videoParams.stereoIn, + Keys.videoHeight: videoParams.h, + Keys.videoWidth: videoParams.w, + }..removeWhere((k, v) => v == null); + stream.addAll(videoParamsTags); + case mpvTypeSub: + stream[Keys.streamType] = MediaStreamTypes.subtitle; + } + return stream; + } +} diff --git a/pubspec.lock b/pubspec.lock index cfe61efe6..c998d5b0a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: daa1d780fdecf8af925680c06c86563cdd445deea995d5c9176f1302a2b10bbe + sha256: "27899c95f9e7ec06c8310e6e0eac967707714b9f1450c4a58fa00ca011a4a8ae" url: "https://pub.dev" source: hosted - version: "1.3.48" + version: "1.3.49" _macros: dependency: transitive description: dart @@ -289,23 +289,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - ffmpeg_kit_flutter: - dependency: transitive - description: - path: "flutter/flutter" - ref: background-lts - resolved-ref: "24213bd2334265cfc240525fb9a218b85ad4d872" - url: "https://github.com/deckerst/ffmpeg-kit.git" - source: git - version: "6.0.3" - ffmpeg_kit_flutter_platform_interface: - dependency: transitive - description: - name: ffmpeg_kit_flutter_platform_interface - sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee - url: "https://pub.dev" - source: hosted - version: "0.2.1" file: dependency: transitive description: @@ -318,10 +301,10 @@ packages: dependency: transitive description: name: firebase_core - sha256: "15d761b95dfa2906dfcc31b7fc6fe293188533d1a3ffe78389ba9e69bd7fdbde" + sha256: "0307c1fde82e2b8b97e0be2dab93612aff9a72f31ebe9bfac66ed8b37ef7c568" url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.10.0" firebase_core_platform_interface: dependency: transitive description: @@ -342,18 +325,18 @@ packages: dependency: transitive description: name: firebase_crashlytics - sha256: e235c8452d5622fc271404592388fde179e4b62c50e777ad3c8c3369296104ed + sha256: f6adb65fa3d6391a79f0e60833bb4cdc468ce0c318831c90057ee11e0909cd29 url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: "4ddadf44ed0a202f3acad053f12c083877940fa8cc1a9f747ae09e1ef4372160" + sha256: "6635166c22c6f75f634b8e77b70fcc43b24af4cfee28f975249dbdbd9769a702" url: "https://pub.dev" source: hosted - version: "3.7.0" + version: "3.8.0" fixnum: dependency: transitive description: @@ -813,8 +796,8 @@ packages: dependency: "direct overridden" description: path: media_kit - ref: d094ba83715b0ac893e546781b2862e855d34502 - resolved-ref: d094ba83715b0ac893e546781b2862e855d34502 + ref: "4d8c634c28d439384aab40b9d2edff83077f37c9" + resolved-ref: "4d8c634c28d439384aab40b9d2edff83077f37c9" url: "https://github.com/media-kit/media-kit.git" source: git version: "1.1.11" @@ -830,8 +813,8 @@ packages: dependency: "direct overridden" description: path: media_kit_video - ref: d094ba83715b0ac893e546781b2862e855d34502 - resolved-ref: d094ba83715b0ac893e546781b2862e855d34502 + ref: "4d8c634c28d439384aab40b9d2edff83077f37c9" + resolved-ref: "4d8c634c28d439384aab40b9d2edff83077f37c9" url: "https://github.com/media-kit/media-kit.git" source: git version: "1.2.5" diff --git a/pubspec.yaml b/pubspec.yaml index e75c66356..524ae7def 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,7 +28,6 @@ workspace: - plugins/aves_ui - plugins/aves_utils - plugins/aves_video - - plugins/aves_video_ffmpeg - plugins/aves_video_mpv # use `scripts/apply_flavor_{flavor}.sh` to set the right dependencies for the flavor @@ -55,8 +54,6 @@ dependencies: path: plugins/aves_services_google aves_video: path: plugins/aves_video - aves_video_ffmpeg: - path: plugins/aves_video_ffmpeg aves_video_mpv: path: plugins/aves_video_mpv aves_ui: @@ -137,12 +134,12 @@ dependency_overrides: media_kit: git: url: https://github.com/media-kit/media-kit.git - ref: d094ba83715b0ac893e546781b2862e855d34502 + ref: 4d8c634c28d439384aab40b9d2edff83077f37c9 path: media_kit media_kit_video: git: url: https://github.com/media-kit/media-kit.git - ref: d094ba83715b0ac893e546781b2862e855d34502 + ref: 4d8c634c28d439384aab40b9d2edff83077f37c9 path: media_kit_video dev_dependencies: diff --git a/test/utils/string_utils_test.dart b/test/utils/string_utils_test.dart index 8b5d1a3da..a7792fba7 100644 --- a/test/utils/string_utils_test.dart +++ b/test/utils/string_utils_test.dart @@ -13,5 +13,7 @@ void main() { expect('H'.toSentenceCase(), 'H'); expect('LW[1]'.toSentenceCase(), 'LW [1]'); + + expect('bits_per_raw_sample'.toSentenceCase(), 'Bits Per Raw Sample'); }); }