diff --git a/.flutter-mediapipe-root b/.flutter-mediapipe-root new file mode 100644 index 0000000..c4598a0 --- /dev/null +++ b/.flutter-mediapipe-root @@ -0,0 +1,2 @@ +// Used to normalize the paths of commands. +// The contents of this file do not matter. diff --git a/packages/mediapipe-task-text/example/assets/libtext_classifier.dylib b/packages/mediapipe-task-text/example/assets/libtext_classifier.dylib new file mode 100755 index 0000000..f0c7b68 Binary files /dev/null and b/packages/mediapipe-task-text/example/assets/libtext_classifier.dylib differ diff --git a/tool/builder/.gitignore b/tool/builder/.gitignore new file mode 100644 index 0000000..3a85790 --- /dev/null +++ b/tool/builder/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/tool/builder/CHANGELOG.md b/tool/builder/CHANGELOG.md new file mode 100644 index 0000000..16c8153 --- /dev/null +++ b/tool/builder/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version with headers command to sync C headers from `google/mediapipe`. diff --git a/tool/builder/README.md b/tool/builder/README.md new file mode 100644 index 0000000..8f37332 --- /dev/null +++ b/tool/builder/README.md @@ -0,0 +1,20 @@ +# Flutter MediaPipe builder + +Helper utility which performs build or CI/CD step operations necessary to develop, release, and use `flutter-mediapipe`. + +### Usage: + +Usage depends on which task you need to accomplish. All supported workflows are described below. + +#### Header aggregation + +Header files across all tasks in `google/flutter-mediapipe` have to stay in sync with their origin, `google/mediapipe`. To +resync these files, check out both repositories on the same machine (ideally next to each other on the file system) and run: + +```sh +$ dart tool/builder/bin/main.dart headers +``` + +-- + +Check back in the future for any additional development workflows this command may support. \ No newline at end of file diff --git a/tool/builder/analysis_options.yaml b/tool/builder/analysis_options.yaml new file mode 100644 index 0000000..cbb4ee5 --- /dev/null +++ b/tool/builder/analysis_options.yaml @@ -0,0 +1,2 @@ +include: package:lints/recommended.yaml + diff --git a/tool/builder/bin/main.dart b/tool/builder/bin/main.dart new file mode 100644 index 0000000..f00d48a --- /dev/null +++ b/tool/builder/bin/main.dart @@ -0,0 +1,25 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; +import 'package:args/command_runner.dart'; +import 'package:builder/download_model.dart'; +import 'package:builder/sync_headers.dart'; +import 'package:logging/logging.dart'; + +final runner = CommandRunner( + 'build', + 'Performs build operations for google/flutter-mediapipe that ' + 'depend on contents in this repository', +) + ..addCommand(SyncHeadersCommand()) + ..addCommand(DownloadModelCommand()); + +void main(List arguments) { + Logger.root.onRecord.listen((LogRecord record) { + io.stdout + .writeln('${record.level.name}: ${record.time}: ${record.message}'); + }); + runner.run(arguments); +} diff --git a/tool/builder/lib/download_model.dart b/tool/builder/lib/download_model.dart new file mode 100644 index 0000000..14dc889 --- /dev/null +++ b/tool/builder/lib/download_model.dart @@ -0,0 +1,144 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; +import 'package:args/command_runner.dart'; +import 'package:builder/repo_finder.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +final _log = Logger('DownloadModelCommand'); + +enum Models { + textclassification, + languagedetection, +} + +class DownloadModelCommand extends Command with RepoFinderMixin { + @override + String description = 'Downloads a given MediaPipe model and places it in ' + 'the designated location.'; + @override + String name = 'model'; + + DownloadModelCommand() { + argParser + ..addOption( + 'model', + abbr: 'm', + allowed: [ + // Values will be added to this as the repository gets more + // integration tests that require new models. + Models.textclassification.name, + Models.languagedetection.name, + ], + help: 'The desired model to download. Use this option if you want the ' + 'standard model for a given task. Using this option also removes any ' + 'need to use the `destination` option, as a value here implies a ' + 'destination. However, you still can specify a destination to ' + 'override the default location where the model is placed.\n' + '\n' + 'Note: Either this or `custommodel` must be used. If both are ' + 'supplied, `model` is used.', + ) + ..addOption( + 'custommodel', + abbr: 'c', + help: 'The desired model to download. Use this option if you want to ' + 'specify a specific and nonstandard model. Using this option means ' + 'you *must* use the `destination` option.\n' + '\n' + 'Note: Either this or `model` must be used. If both are supplied, ' + '`model` is used.', + ) + ..addOption( + 'destination', + abbr: 'd', + help: + 'The location to place the downloaded model. This value is required ' + 'if you use the `custommodel` option, but optional if you use the ' + '`model` option.', + ); + } + + static final Map _standardModelSources = { + Models.textclassification.name: + 'https://storage.googleapis.com/mediapipe-models/text_classifier/bert_classifier/float32/1/bert_classifier.tflite', + Models.languagedetection.name: + 'https://storage.googleapis.com/mediapipe-models/language_detector/language_detector/float32/1/language_detector.tflite', + }; + + static final Map _standardModelDestinations = { + Models.textclassification.name: + 'packages/mediapipe-task-text/example/assets/', + Models.languagedetection.name: + 'packages/mediapipe-task-text/example/assets/', + }; + + @override + Future run() async { + final io.Directory flutterMediaPipeDirectory = findFlutterMediaPipeRoot(); + + late final String modelSource; + late final String modelDestination; + + if (argResults!['model'] != null) { + modelSource = _standardModelSources[argResults!['model']]!; + modelDestination = (_isArgProvided(argResults!['destination'])) + ? argResults!['destination'] + : _standardModelDestinations[argResults!['model']]!; + } else { + if (argResults!['custommodel'] == null) { + throw Exception( + 'You must use either the `model` or `custommodel` option.', + ); + } + if (argResults!['destination'] == null) { + throw Exception( + 'If you do not use the `model` option, then you must supply a ' + '`destination`, as a "standard" destination cannot be used.', + ); + } + modelSource = argResults!['custommodel']; + modelDestination = argResults!['destination']; + } + + io.File destinationFile = io.File( + path.joinAll([ + flutterMediaPipeDirectory.absolute.path, + modelDestination, + modelSource.split('/').last, + ]), + ); + ensureFolders(destinationFile); + await downloadModel(modelSource, destinationFile); + } + + Future downloadModel( + String modelSource, + io.File destinationFile, + ) async { + _log.info('Downloading $modelSource'); + + // TODO(craiglabenz): Convert to StreamedResponse + final response = await http.get(Uri.parse(modelSource)); + + if (response.statusCode != 200) { + throw Exception('${response.statusCode} ${response.reasonPhrase} :: ' + '$modelSource'); + } + + if (!(await destinationFile.exists())) { + _log.fine('Creating file at ${destinationFile.absolute.path}'); + await destinationFile.create(); + } + + _log.fine('Downloaded ${response.contentLength} bytes'); + _log.info('Saving to ${destinationFile.absolute.path}'); + await destinationFile.writeAsBytes(response.bodyBytes); + } +} + +bool _isArgProvided(String? val) => val != null && val != ''; diff --git a/tool/builder/lib/repo_finder.dart b/tool/builder/lib/repo_finder.dart new file mode 100644 index 0000000..f81bd67 --- /dev/null +++ b/tool/builder/lib/repo_finder.dart @@ -0,0 +1,120 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as path; +import 'package:io/ansi.dart'; + +/// Mixin to help [Command] subclasses locate both `google/mediapipe` and +/// the root of `google/flutter-mediapipe` (this repository). +/// +/// The primary methods are [findFlutterMediaPipeRoot] and [findMediaPipeRoot]. +/// +/// By default, the root for `google/flutter-mediapipe` is determined by the +/// firest ancestor directory which contains a `.flutter-mediapipe-root` file +/// (whose contents are irrelevant), and the root of `google/mediapipe` is +/// expected to be a sibling of that. However, the `--source` flag can overwrite +/// this expectation and specify an absolute path where to find `google/mediapipe`. +/// +/// Note that it is not possible to override the method of locating the root of +/// `google/flutter-mediapipe`. +mixin RepoFinderMixin on Command { + /// Name of the file which, when found, indicates the root of this repository. + static String sentinelFileName = '.flutter-mediapipe-root'; + + void addSourceOption(ArgParser argParser) { + argParser.addOption( + 'source', + abbr: 's', + help: 'The location of google/mediapipe. Defaults to being ' + 'adjacent to google/flutter-mediapipe.', + ); + } + + /// Looks upward for the root of the `google/mediapipe` repository. This assumes + /// the `dart build` command is executed from within said repository. If it is + /// not executed from within, then this searching algorithm will reach the root + /// of the file system, log the error, and exit. + io.Directory findFlutterMediaPipeRoot() { + final placesChecked = []; + io.Directory dir = io.Directory(path.current); + while (true) { + if (_isFlutterMediaPipeRoot(dir)) { + return dir; + } + placesChecked.add(dir); + dir = dir.parent; + if (dir.parent.path == dir.path) { + io.stderr.writeln( + wrapWith( + 'Failed to find google/flutter-mediapipe root directory. ' + 'Did you execute this command from within the repository?\n' + 'Looked in:', + [red], + ), + ); + io.stderr.writeln( + wrapWith( + placesChecked + .map((dir) => ' - ${dir.absolute.path}') + .toList() + .join('\n'), + [red], + ), + ); + io.exit(1); + } + } + } + + /// Finds the `google/mediapipe` checkout where artifacts built in this + /// repository should be sourced. By default, this command assumes the two + /// repositories are siblings on the file system, but the `--source` flag + /// allows for this assumption to be overridden. + io.Directory findMediaPipeRoot( + io.Directory flutterMediaPipeDir, + String? source, + ) { + final mediaPipeDirectory = io.Directory( + source ?? + path.joinAll([flutterMediaPipeDir.parent.absolute.path, 'mediapipe']), + ); + + if (!mediaPipeDirectory.existsSync()) { + io.stderr.writeln( + 'Could not find ${mediaPipeDirectory.absolute.path}. ' + 'Folder does not exist.', + ); + io.exit(1); + } + return mediaPipeDirectory; + } + + /// Looks for the sentinel file of this repository's root directory. This allows + /// the `dart build` command to be run from various locations within the + /// `google/mediapipe` repository and still correctly set paths for all of its + /// operations. + bool _isFlutterMediaPipeRoot(io.Directory dir) { + return io.File( + path.joinAll( + [dir.absolute.path, sentinelFileName], + ), + ).existsSync(); + } + + /// Builds any missing folders between the file and the root of the repository + void ensureFolders(io.File file) { + io.Directory parent = file.parent; + List dirsToCreate = []; + while (!parent.existsSync()) { + dirsToCreate.add(parent); + parent = parent.parent; + } + for (io.Directory dir in dirsToCreate.reversed) { + dir.createSync(); + } + } +} diff --git a/tool/builder/lib/sync_headers.dart b/tool/builder/lib/sync_headers.dart new file mode 100644 index 0000000..aefe1e9 --- /dev/null +++ b/tool/builder/lib/sync_headers.dart @@ -0,0 +1,167 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io' as io; +import 'package:args/command_runner.dart'; +import 'package:builder/repo_finder.dart'; +import 'package:io/ansi.dart'; +import 'package:path/path.dart' as path; +import 'package:process/process.dart'; + +/// Relative header paths (in both repositories) +final containers = 'mediapipe/tasks/c/components/containers'; +final processors = 'mediapipe/tasks/c/components/processors'; +final core = 'mediapipe/tasks/c/core'; +final tc = 'mediapipe/tasks/c/text/text_classifier'; + +/// google/flutter-mediapipe package paths +final corePackage = 'packages/mediapipe-core/third_party'; +final textPackage = 'packages/mediapipe-task-text/third_party'; + +/// First string is its relative location in both repositories, +/// Second string is its package location in `google/flutter-mediapipe`, +/// Third string is the file name +/// Fourth param is an optional function to modify the file +List<(String, String, String, Function(io.File)?)> headerPaths = [ + (containers, corePackage, 'category.h', null), + (containers, corePackage, 'classification_result.h', null), + (core, corePackage, 'base_options.h', null), + (processors, corePackage, 'classifier_options.h', null), + (tc, textPackage, 'text_classifier.h', relativeIncludes), +]; + +/// Command to copy all necessary C header files into this repository. +/// +/// Pulls a list of hard-coded header files out of various destinations within +/// the google/mediapipe repository and places them in the same paths within +/// this repository. The only major change between their orientation within the +/// source repository (google/mediapipe) and their orientation here is that +/// shared header files are placed in `mediapipe-core` here, and no such +/// distinction exists in the source. This also implies rewriting the import +/// paths within these files to match, as their old relative positioning is +/// disrupted by the move. +class SyncHeadersCommand extends Command with RepoFinderMixin { + @override + String description = 'Syncs header files to google/flutter-mediapipe'; + @override + String name = 'headers'; + + SyncHeadersCommand() { + argParser.addFlag( + 'overwrite', + abbr: 'o', + defaultsTo: true, + help: 'If true, will overwrite existing header files ' + 'at destination locations.', + ); + addSourceOption(argParser); + } + + @override + Future run() async { + final io.Directory flutterMediaPipeDirectory = findFlutterMediaPipeRoot(); + final io.Directory mediaPipeDirectory = findMediaPipeRoot( + flutterMediaPipeDirectory, + argResults!['source'], + ); + + final config = Options( + allowOverwrite: argResults!['overwrite'], + mediaPipeDir: mediaPipeDirectory, + flutterMediaPipeDir: flutterMediaPipeDirectory, + ); + + await copyHeaders(config); + } + + /// Central method that does the work of actually moving the files into the + /// local repository and rewriting their relative import statements within + /// the files themselves. + Future copyHeaders(Options config) async { + final mgr = LocalProcessManager(); + for (final tup in headerPaths) { + final headerFile = io.File(path.joinAll( + [config.mediaPipeDir.absolute.path, tup.$1, tup.$3], + )); + if (!headerFile.existsSync()) { + io.stderr.writeln( + 'Expected to find ${headerFile.path}, but ' + 'file does not exist.', + ); + io.exit(1); + } + final destinationPath = path.joinAll( + [config.flutterMediaPipeDir.absolute.path, tup.$2, tup.$1, tup.$3], + ); + final destinationFile = io.File(destinationPath); + if (destinationFile.existsSync() && !config.allowOverwrite) { + io.stdout.writeAll( + [ + 'Warning: Not overwriting existing file at $destinationPath\n', + wrapWith('Skipping ${tup.$3}.\n', [cyan]), + ], + ); + continue; + } + + // MediaPipe header files often come from deeply nested locations, and new + // header files could require new folders. Thus, create any missing folders. + ensureFolders(io.File(destinationPath)); + + final process = await mgr.start(['cp', headerFile.path, destinationPath]); + int processExitCode = await process.exitCode; + if (processExitCode != 0) { + final processStdErr = utf8.decoder.convert( + (await process.stderr.toList()) + .fold>([], (arr, el) => arr..addAll(el))); + io.stderr.write(wrapWith(processStdErr, [red])); + + final processStdOut = utf8.decoder.convert( + (await process.stdout.toList()) + .fold>([], (arr, el) => arr..addAll(el))); + io.stderr.write(wrapWith(processStdOut, [red])); + io.exit(processExitCode); + } else { + io.stderr.writeln(wrapWith('Copied ${tup.$3}', [green])); + } + + // Call the final modification function, if supplied + if (tup.$4 != null) { + tup.$4!.call(destinationFile); + } + } + } +} + +class Options { + const Options({ + required this.allowOverwrite, + required this.mediaPipeDir, + required this.flutterMediaPipeDir, + }); + + final bool allowOverwrite; + final io.Directory mediaPipeDir; + final io.Directory flutterMediaPipeDir; +} + +void relativeIncludes(io.File textClassifierHeader) { + assert(textClassifierHeader.path.endsWith('text_classifier.h')); + String contents = textClassifierHeader.readAsStringSync(); + + Map rewrites = { + 'mediapipe/tasks/c/components/containers/classification_result.h': + '../../../../../../../mediapipe-core/third_party/mediapipe/tasks/c/components/containers/classification_result.h', + 'mediapipe/tasks/c/components/processors/classifier_options.h': + '../../../../../../../mediapipe-core/third_party/mediapipe/tasks/c/components/processors/classifier_options.h', + 'mediapipe/tasks/c/core/base_options.h': + '../../../../../../../mediapipe-core/third_party/mediapipe/tasks/c/core/base_options.h', + }; + + for (final rewrite in rewrites.entries) { + contents = contents.replaceAll(rewrite.key, rewrite.value); + } + textClassifierHeader.writeAsStringSync(contents); +} diff --git a/tool/builder/pubspec.yaml b/tool/builder/pubspec.yaml new file mode 100644 index 0000000..43d8dce --- /dev/null +++ b/tool/builder/pubspec.yaml @@ -0,0 +1,21 @@ +name: builder +description: Performs build operations for google/flutter-mediapipe that depend + on contents in this repository. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo +environment: + sdk: ^3.2.0-162.0.dev + +# Add regular dependencies here. +dependencies: + args: ^2.4.2 + http: ^1.1.0 + io: ^1.0.4 + logging: ^1.2.0 + path: ^1.8.0 + process: ^5.0.0 + +dev_dependencies: + ffigen: ^9.0.1 + lints: ^2.1.0 + test: ^1.24.0