diff --git a/README.md b/README.md index 3d2e09b..3529c68 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Compress videos, remove audio, manipulate thumbnails, and make your video compat In addition, google chrome uses VP8/VP9, safari uses h264, and most of the time, it is necessary to encode the video in two formats, but not with this library. All video files are encoded in an MP4 container with AAC audio that allows 100% compatibility with safari, mozila, chrome, android and iOS. -Works on ANDROID, IOS and desktop (just MacOS for now). +Works on ANDROID, IOS and desktop (just MacOS and Linux for now). diff --git a/example/linux/.gitignore b/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/example/linux/CMakeLists.txt b/example/linux/CMakeLists.txt new file mode 100644 index 0000000..b127070 --- /dev/null +++ b/example/linux/CMakeLists.txt @@ -0,0 +1,130 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "video_compress_example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.video_compress_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/example/linux/flutter/CMakeLists.txt b/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..14126da --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) video_compress_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "VideoCompressPlugin"); + video_compress_plugin_register_with_registrar(video_compress_registrar); +} diff --git a/example/linux/flutter/generated_plugin_registrant.h b/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..cfdd9bb --- /dev/null +++ b/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + video_compress +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/linux/runner/CMakeLists.txt b/example/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/example/linux/runner/main.cc b/example/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/example/linux/runner/my_application.cc b/example/linux/runner/my_application.cc new file mode 100644 index 0000000..78816ec --- /dev/null +++ b/example/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "video_compress_example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "video_compress_example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/example/linux/runner/my_application.h b/example/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/example/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 453ecec..b33e709 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -16,7 +16,8 @@ dependencies: video_player: ^2.4.2 file_selector: ^0.8.4+2 file_selector_macos: ^0.8.2+1 - + image_picker_linux: ^0.2.2 + dev_dependencies: flutter_test: sdk: flutter diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..3460ddf --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,96 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Project-level configuration. +set(PROJECT_NAME "video_compress") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed. +set(PLUGIN_NAME "video_compress_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "video_compress_plugin.cc" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(video_compress_bundled_libraries + "" + PARENT_SCOPE +) + +# === Tests === +# These unit tests can be run from a terminal after building the example. + +# Only enable test builds when building the example (which sets this variable) +# so that plugin clients aren't building the tests. +if (${include_${PROJECT_NAME}_tests}) +if(${CMAKE_VERSION} VERSION_LESS "3.11.0") +message("Unit tests require CMake 3.11.0 or later") +else() +set(TEST_RUNNER "${PROJECT_NAME}_test") +enable_testing() + +# Add the Google Test dependency. +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip +) +# Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +# Disable install commands for gtest so it doesn't end up in the bundle. +set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) + +FetchContent_MakeAvailable(googletest) + +# The plugin's exported API is not very useful for unit testing, so build the +# sources directly into the test binary rather than using the shared library. +add_executable(${TEST_RUNNER} + test/video_compress_plugin_test.cc + ${PLUGIN_SOURCES} +) +apply_standard_settings(${TEST_RUNNER}) +target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(${TEST_RUNNER} PRIVATE flutter) +target_link_libraries(${TEST_RUNNER} PRIVATE PkgConfig::GTK) +target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) + +# Enable automatic test discovery. +include(GoogleTest) +gtest_discover_tests(${TEST_RUNNER}) + +endif() # CMake version check +endif() # include_${PROJECT_NAME}_tests diff --git a/linux/include/video_compress/video_compress_plugin.h b/linux/include/video_compress/video_compress_plugin.h new file mode 100644 index 0000000..7d70233 --- /dev/null +++ b/linux/include/video_compress/video_compress_plugin.h @@ -0,0 +1,27 @@ +#ifndef FLUTTER_PLUGIN_VIDEO_COMPRESS_PLUGIN_H_ +#define FLUTTER_PLUGIN_VIDEO_COMPRESS_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _VideoCompressPlugin VideoCompressPlugin; +typedef struct { + GObjectClass parent_class; +} VideoCompressPluginClass; + +FLUTTER_PLUGIN_EXPORT GType video_compress_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void video_compress_plugin_register_with_registrar( + FlPluginRegistrar* registrar +); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_VIDEO_COMPRESS_PLUGIN_H_ diff --git a/linux/test/video_compress_plugin_test.cc b/linux/test/video_compress_plugin_test.cc new file mode 100644 index 0000000..01ee8cc --- /dev/null +++ b/linux/test/video_compress_plugin_test.cc @@ -0,0 +1,31 @@ +#include +#include +#include + +#include "include/video_compress/video_compress_plugin.h" +#include "video_compress_plugin_private.h" + +// This demonstrates a simple unit test of the C portion of this plugin's +// implementation. +// +// Once you have built the plugin's example app, you can run these tests +// from the command line. For instance, for a plugin called my_plugin +// built for x64 debug, run: +// $ build/linux/x64/debug/plugins/my_plugin/my_plugin_test + +namespace video_compress { +namespace test { + +TEST(VideoCompressPlugin, GetPlatformVersion) { + g_autoptr(FlMethodResponse) response = get_platform_version(); + ASSERT_NE(response, nullptr); + ASSERT_TRUE(FL_IS_METHOD_SUCCESS_RESPONSE(response)); + FlValue* result = fl_method_success_response_get_result( + FL_METHOD_SUCCESS_RESPONSE(response)); + ASSERT_EQ(fl_value_get_type(result), FL_VALUE_TYPE_STRING); + // The full string varies, so just validate that it has the right format. + EXPECT_THAT(fl_value_get_string(result), testing::StartsWith("Linux ")); +} + +} // namespace test +} // namespace video_compress diff --git a/linux/video_compress_plugin.cc b/linux/video_compress_plugin.cc new file mode 100644 index 0000000..40ad954 --- /dev/null +++ b/linux/video_compress_plugin.cc @@ -0,0 +1,562 @@ +#include "include/video_compress/video_compress_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "video_compress_plugin_private.h" + +#define VIDEO_COMPRESS_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), video_compress_plugin_get_type(), VideoCompressPlugin)) + +struct _VideoCompressPlugin { + GObject parent_instance; +}; + +G_DEFINE_TYPE(VideoCompressPlugin, video_compress_plugin, g_object_get_type()) + +extern char **environ; + +/** Set to -1 when no compression is ongoing. + * Set to 0 when compression is ongoing, but ffmpeg isn't live. + * Set to the pid of ffmpeg otherwise. + */ +static std::atomic ffmpeg_pid = -1; + +static std::string get_output_dir(void) { + std::stringstream ss; + ss << "/tmp/video_compress." << getpid(); + return ss.str(); +} + +static std::string new_output_filename(void) { + std::stringstream ss; + ss << "/tmp/video_compress." << getpid() << "/XXXXXX.mp4"; + return ss.str(); +} + +static FlMethodResponse *get_method_args(FlMethodCall *method_call, ...) { + FlValue *method_args = fl_method_call_get_args(method_call); + va_list args; + va_start(args, method_call); + + GString *error = g_string_new(nullptr); + for (;;) { + const char *key = va_arg(args, const char *); + if (key == nullptr) { + break; + } + printf("KEY: %s\n", key); + + FlValue *value = fl_value_lookup_string(method_args, key); + if (value == nullptr) { + // Skip over to the next key. + FlValueType value_type = va_arg(args, FlValueType); + if (value_type == FL_VALUE_TYPE_BOOL) { + va_arg(args, bool *); + } else if (value_type == FL_VALUE_TYPE_INT) { + va_arg(args, int *); + } else if (value_type == FL_VALUE_TYPE_INT) { + va_arg(args, const char **); + } else { + abort(); + } + continue; + } + + FlValueType value_type = va_arg(args, FlValueType); + if (value_type == FL_VALUE_TYPE_BOOL) { + FlValueType actual = fl_value_get_type(value); + bool *out = va_arg(args, bool *); + if (actual == FL_VALUE_TYPE_BOOL) { + *out = fl_value_get_bool(value); + } else if (actual != FL_VALUE_TYPE_NULL) { + g_string_printf(error, "expected '%s' to be bool", key); + break; + } + } else if (value_type == FL_VALUE_TYPE_INT) { + FlValueType actual = fl_value_get_type(value); + int64_t *out = va_arg(args, int64_t *); + if (actual == FL_VALUE_TYPE_INT) { + *out = fl_value_get_int(value); + } else if (actual != FL_VALUE_TYPE_NULL) { + g_string_printf(error, "expected '%s' to be int, got %d", key, fl_value_get_type(value)); + break; + } + } else if (value_type == FL_VALUE_TYPE_STRING) { + FlValueType actual = fl_value_get_type(value); + const char **out = va_arg(args, const char **); + if (actual == FL_VALUE_TYPE_STRING) { + *out = fl_value_get_string(value); + } else if (actual != FL_VALUE_TYPE_NULL) { + g_string_printf(error, "expected '%s' to be string", key); + break; + } + } else { + abort(); + } + } + + va_end(args); + + FlMethodResponse *response = nullptr; + + if (error->len != 0) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new("invalid arguments", error->str, nullptr)); + } + + g_string_free(error, true); + return response; +} + +// Called when a method call is received from Flutter. +static void video_compress_plugin_handle_method_call( + VideoCompressPlugin *self, + FlMethodCall *method_call +) { + g_autoptr(FlMethodResponse) response = nullptr; + + const char *method = fl_method_call_get_name(method_call); + + g_warning("CALLINGM ETHOD: %s", method); + + if (strcmp(method, "getPlatformVersion") == 0) { + response = get_platform_version(); + } else if (strcmp(method, "cancelCompression") == 0) { + response = cancel_compression(); + } else if (strcmp(method, "compressVideo") == 0) { + const char *path = nullptr; + int64_t quality = 0; + bool delete_origin = false; + int64_t start_time_s = -1; + int64_t duration_s = -1; + bool include_audio = true; + int64_t frame_rate = 30; + response = get_method_args(method_call, + "path", FL_VALUE_TYPE_STRING, &path, + "quality", FL_VALUE_TYPE_INT, &quality, + "delete_origin", FL_VALUE_TYPE_BOOL, &delete_origin, + "start_time", FL_VALUE_TYPE_INT, &start_time_s, + "duration", FL_VALUE_TYPE_INT, &duration_s, + "include_audio", FL_VALUE_TYPE_BOOL, &include_audio, + "frame_rate", FL_VALUE_TYPE_INT, &frame_rate, + nullptr + ); + if (response == nullptr) { + response = compress_video( + path, + quality, + delete_origin, + start_time_s, + duration_s, + include_audio, + frame_rate + ); + } + } else if (strcmp(method, "deleteAllCache") == 0) { + response = delete_all_cache(); + } else if (strcmp(method, "setLogLevel") == 0) { + response = set_log_level(); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + g_autoptr(GError) error = nullptr; + if (!fl_method_call_respond(method_call, response, &error)) { + g_warning("Failed to send response: %s", error->message); + } +} + +FlMethodResponse *get_platform_version(void) { + struct utsname uname_data = {}; + uname(&uname_data); + g_autofree gchar *version = g_strdup_printf("Linux %s", uname_data.version); + g_autoptr(FlValue) result = fl_value_new_string(version); + return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); +} + +FlMethodResponse *cancel_compression(void) { + pid_t pid = 0; + // Try to stop the compression before ffmpeg is spawned. + bool stopped = ffmpeg_pid.compare_exchange_strong( + pid, + -1, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + if (stopped || pid == -1) { + // We stopped the compression, or no compression was ongoing. + return FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + + if (kill(pid, SIGTERM) == 0) { + // we managed to kill ffmpeg :D (ffmpeg didn't die before + // compress_video had the time to reset ffmpeg_pid) + int status = 0; + TEMP_FAILURE_RETRY(waitpid(pid, &status, 0)); + } + + // Reset ffmpeg_pid... unless another call to cancel_compression has + // already done it, and then a compress_video call started which thus + // reinitialized ffmpeg_pid with another pid. In any case the + // cancellation is a success. + ffmpeg_pid.compare_exchange_strong( + pid, + -1, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + + return FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); +} + +FlMethodResponse *compress_video( + const char *path, + int64_t quality, + bool delete_origin, + int64_t start_time_s, + int64_t duration_s, + bool include_audio, + int64_t frame_rate +) { + g_warning("path=%s quality=%ld delete_origin=%d %ld %ld %d %ld", path, quality, delete_origin, start_time_s, duration_s, include_audio, frame_rate); + + // We can only start a compression job if ffmpeg_pid is -1. + pid_t pid = -1; + bool can_start = ffmpeg_pid.compare_exchange_strong( + pid, + 0, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + if (!can_start) { + return FL_METHOD_RESPONSE(fl_method_error_response_new("already compressing", nullptr, nullptr)); + } + + { + // Put this in a scope so that "dir" gets freed early. + std::string dir = get_output_dir(); + if (mkdir(dir.c_str(), 0700) < 0 && errno != EEXIST) { + pid_t zero = 0; + ffmpeg_pid.compare_exchange_strong( + zero, + -1, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + return FL_METHOD_RESPONSE(fl_method_error_response_new("failed to create output file", strerror(errno), nullptr)); + } + } + + std::string output_filename = new_output_filename(); + int output_fd = mkstemps(output_filename.data(), 4); // 4 == len(".mp4") + if (output_fd < 0) { + pid_t zero = 0; + ffmpeg_pid.compare_exchange_strong( + zero, + -1, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + return FL_METHOD_RESPONSE(fl_method_error_response_new("failed to create output file", nullptr, nullptr)); + } + + // Don't bother unlinking it, ffmpeg will overwrite it. + close(output_fd); + + std::vector argv({ + "ffmpeg", + "-i", path, + "-y", // overwrite the file created by mkstemps + "-c:v", "libx264", // most popular AVC encoder + "-preset", "veryfast", // it's already slow enough + }); + + if (include_audio) { + // use the built-in AAC encoder + argv.push_back("-c:a"); + argv.push_back("aac"); + + // lowest bitrate in the "recommended range" + // https://trac.ffmpeg.org/wiki/Encode/HighQualityAudio#Recommended + argv.push_back("-b:a"); + argv.push_back("128k"); + } else { + argv.push_back("-an"); + } + + std::stringstream ss; + std::string fps_filter; + std::string start_time_str; + std::string duration_str; + + argv.push_back("-vf"); + ss << "fps=min(source_fps\\," << frame_rate << ")"; + fps_filter = ss.str(); + argv.push_back(fps_filter.c_str()); + + if (start_time_s > 0) { + argv.push_back("-ss"); + ss.str(""); + ss.clear(); + ss << start_time_s; + start_time_str = ss.str(); + argv.push_back(start_time_str.c_str()); + } + + if (duration_s > 0) { + argv.push_back("-t"); + ss.str(""); + ss.clear(); + ss << start_time_s; + duration_str = ss.str(); + argv.push_back(duration_str.c_str()); + } + + // Notes on scaling: + // see https://ffmpeg.org/ffmpeg-filters.html#scale-1 for details. + // + // "iw" is the input width, "ow" is the output width, "a" is the input + // aspect ratio (iw/ih). + // + // For when we want to scale down to 480p, 720p or 1080p but we don't + // care about the other dimension (as long as aspect ratio is kept), we + // set the width to "min(iw,max(480,480*a))": the "min" is so that we + // don't upscale, and the "max" is so that if the video is vertical + // (iwih, i.e. a>1), we set the width to 480*a, so the height is 480 + // (i.e. 480*a/a). + // + // For when we want to scale down to LxH with constraints on the two + // dimensions, we separate the cases where "a" is smaller or larger than + // the desired aspect ratio. If it's smaller, it means that the height + // has to be constrained to TODO + // + // Also, "trunc" is used to ensure the height is even. libx264 doesn't + // work with odd sizes. + switch (quality) { + case 1: + // Low quality setting + // 480p, constant rate factor of 30 + argv.push_back("-crf"); + argv.push_back("30"); + argv.push_back("-vf"); + argv.push_back("scale=w=min(iw\\,max(480\\,480*a)):h=trunc(ow/a/2)*2"); + break; + case 3: + // High quality setting + // 1080p, constant rate factor of 22 + argv.push_back("-crf"); + argv.push_back("22"); + argv.push_back("-vf"); + argv.push_back("scale=w=min(iw\\,max(1080\\,1080*a)):h=trunc(ow/a/2)*2"); + break; + case 4: + // 640x480 quality setting + argv.push_back("-vf"); + argv.push_back("scale=w=if(lt(a\\,640/480)\\,min(iw\\,min(480\\,640*a))\\,min(iw\\,min(640\\,480*a))):h=trunc(ow/a/2)*2"); + break; + case 5: + // 960x540 quality setting + argv.push_back("-vf"); + argv.push_back("scale=w=if(lt(a\\,960/540)\\,min(iw\\,min(540\\,960*a))\\,min(iw\\,min(960\\,540*a))):h=trunc(ow/a/2)*2"); + break; + case 6: + // 1280x720 quality setting + argv.push_back("-vf"); + argv.push_back("scale=w=if(lt(a\\,1280/720)\\,min(iw\\,min(720\\,1280*a))\\,min(iw\\,min(1280\\,720*a))):h=trunc(ow/a/2)*2"); + break; + case 7: + // 1920x1080 quality setting + argv.push_back("-vf"); + argv.push_back("scale=w=if(lt(a\\,1920/1080)\\,min(iw\\,min(1080\\,1920*a))\\,min(iw\\,min(1920\\,1080*a))):h=trunc(ow/a/2)*2"); + break; + default: + // Medium quality setting + // 720p, constant rate factor of 26 + argv.push_back("-crf"); + argv.push_back("26"); + argv.push_back("-vf"); + argv.push_back("scale=w=min(iw\\,max(720\\,720*a)):h=trunc(ow/a/2)*2"); + break; + } + + argv.push_back(output_filename.c_str()); + argv.push_back(nullptr); + + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + // TODO log file name + posix_spawn_file_actions_addopen(&actions, 3, "/tmp/last_ffmpeg.log", O_CREAT | O_TRUNC | O_WRONLY, 0600); + posix_spawn_file_actions_adddup2(&actions, 3, 2); + posix_spawn_file_actions_adddup2(&actions, 3, 1); + + int r = posix_spawnp( + &pid, + "ffmpeg", + &actions, + nullptr, + const_cast(&argv[0]), + environ + ); + + posix_spawn_file_actions_destroy(&actions); + + if (r != 0) { + pid_t zero = 0; + ffmpeg_pid.compare_exchange_strong( + zero, + -1, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + return FL_METHOD_RESPONSE(fl_method_error_response_new("failed to spawn ffmpeg", nullptr, nullptr)); + } + + pid_t zero = 0; + bool can_continue = ffmpeg_pid.compare_exchange_strong( + zero, + pid, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + if (!can_continue) { + // We have been cancelled. + if (kill(pid, SIGTERM) == 0) { + int status = 0; + TEMP_FAILURE_RETRY(waitpid(pid, &status, 0)); + } + // TODO return isCancel=true + return FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + + int status = 0; + TEMP_FAILURE_RETRY(waitpid(pid, &status, 0)); + + ffmpeg_pid.compare_exchange_strong( + pid, + -1, + std::memory_order_seq_cst, + std::memory_order_seq_cst + ); + + if (status == 127) { + // Special status returned by posix_spawn when the execve call fails. + return FL_METHOD_RESPONSE(fl_method_error_response_new("failed to spawn ffmpeg", nullptr, nullptr)); + } else if (!WIFEXITED(status)) { + return FL_METHOD_RESPONSE(fl_method_error_response_new("ffmpeg error", nullptr, nullptr)); + } + + if (delete_origin) { + if (unlink(path) < 0 && errno != ENOENT) { + return FL_METHOD_RESPONSE(fl_method_error_response_new("failed to delete origin", strerror(errno), nullptr)); + } + } + + ss.str(""); + ss.clear(); + ss << + "{" + "\"path\":\"" << output_filename << "\"," + "\"title\":\"\"," + "\"author\":\"\"," + "\"width\":0," + "\"height\":0," + "\"duration\":0," + "\"filesize\":0" + "}"; + + std::string media_info = ss.str(); + FlValue *media_info_v = fl_value_new_string(media_info.c_str()); + + return FL_METHOD_RESPONSE(fl_method_success_response_new(media_info_v)); +} + +FlMethodResponse *delete_all_cache(void) { + FlMethodResponse *response = cancel_compression(); + if (response != nullptr) { + // Failed to cancel, don't bother deleting the directory. + return response; + } + + std::string output_dir = get_output_dir(); + DIR *d = opendir(output_dir.c_str()); + if (d == nullptr) { + if (errno == ENOENT) { + // Don't return an error if delete_all_cache is called + // before any compression job has started. + return FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + return FL_METHOD_RESPONSE(fl_method_error_response_new("failed to remove directory", nullptr, nullptr)); + } + + for (;;) { + struct dirent *entry = readdir(d); + if (entry == nullptr) { + break; + } + // If this fails, the rmdir call later will probably fail, so + // don't bother handling errors here. + unlink(entry->d_name); + } + + closedir(d); + + if (rmdir(output_dir.c_str()) < 0 && errno != ENOENT) { + return FL_METHOD_RESPONSE(fl_method_error_response_new("failed to remove directory", nullptr, nullptr)); + } + + return FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); +} + +FlMethodResponse *set_log_level(void) { + // TODO + return FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); +} + +static void video_compress_plugin_dispose(GObject* object) { + G_OBJECT_CLASS(video_compress_plugin_parent_class)->dispose(object); +} + +static void video_compress_plugin_class_init(VideoCompressPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = video_compress_plugin_dispose; +} + +static void video_compress_plugin_init(VideoCompressPlugin* self) {} + +static void method_call_cb( + FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data +) { + VideoCompressPlugin* plugin = VIDEO_COMPRESS_PLUGIN(user_data); + video_compress_plugin_handle_method_call(plugin, method_call); +} + +void video_compress_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + VideoCompressPlugin* plugin = VIDEO_COMPRESS_PLUGIN( + g_object_new(video_compress_plugin_get_type(), nullptr)); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + g_autoptr(FlMethodChannel) channel = fl_method_channel_new( + fl_plugin_registrar_get_messenger(registrar), + "video_compress", + FL_METHOD_CODEC(codec) + ); + fl_method_channel_set_method_call_handler( + channel, + method_call_cb, + g_object_ref(plugin), + g_object_unref + ); + + g_object_unref(plugin); +} diff --git a/linux/video_compress_plugin_private.h b/linux/video_compress_plugin_private.h new file mode 100644 index 0000000..6f69646 --- /dev/null +++ b/linux/video_compress_plugin_private.h @@ -0,0 +1,19 @@ +#include + +// This file exposes some plugin internals for unit testing. See +// https://github.com/flutter/flutter/issues/88724 for current limitations +// in the unit-testable API. + +FlMethodResponse *get_platform_version(void); +FlMethodResponse *cancel_compression(void); +FlMethodResponse *compress_video( + const gchar *path, + int64_t quality, + bool delete_origin, + int64_t start_time, + int64_t duration, + bool include_audio, + int64_t frame_rate +); +FlMethodResponse *delete_all_cache(void); +FlMethodResponse *set_log_level(void); diff --git a/pubspec.yaml b/pubspec.yaml index c29378f..2d8ab2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,8 @@ flutter: pluginClass: VideoCompressPlugin macos: pluginClass: VideoCompressPlugin + linux: + pluginClass: VideoCompressPlugin # To add assets to your plugin package, add an assets section, like this: # assets: