diff --git a/.augment-guidelines b/.augment-guidelines index 5629e3ea..1e4dcf3a 100644 --- a/.augment-guidelines +++ b/.augment-guidelines @@ -254,21 +254,110 @@ All source files (doesn't inlcude FFmpeg code) should include Apache 2.0 license ### Building the Project +OpenConverter supports two build modes: + +#### **Debug Mode (Development)** +Fast builds for development, uses system libraries: + ```bash # Create build directory mkdir build && cd build -# Configure with CMake -cmake ../src -DENABLE_GUI=ON +# Configure (Debug is default) +cmake ../src -DENABLE_GUI=ON -DBMF_TRANSCODER=ON # Build make -j4 # Run -./OpenConverter # GUI mode -./OpenConverter --help # CLI mode +./OpenConverter.app/Contents/MacOS/OpenConverter # macOS +./OpenConverter # Linux +``` + +**What's bundled:** +- ✅ Python modules (`enhance_module.py`) +- ✅ AI model weights (2.4 MB) +- ❌ BMF libraries (uses system BMF from CMake path) +- ❌ Qt frameworks (uses system Qt) +- ❌ Python runtime (uses system Python) + +**Requirements:** +- Python 3.9+ with PyTorch, Real-ESRGAN, OpenCV, NumPy +- BMF framework (path configured in CMake) +- Qt 5.15+ (for GUI) + +**Environment:** No environment variables needed! BMF path is detected from CMake configuration. + +#### **Release Mode (Distribution)** +Standalone builds for distribution, bundles everything: + +```bash +# Create separate build directory +mkdir build-release && cd build-release + +# Configure with Release mode +cmake ../src -DCMAKE_BUILD_TYPE=Release -DENABLE_GUI=ON -DBMF_TRANSCODER=ON + +# Build (takes longer due to bundling) +make -j4 + +# Run - NO environment variables needed! +open OpenConverter.app # macOS +./OpenConverter # Linux ``` +**What's bundled:** +- ✅ Python modules +- ✅ AI model weights (2.4 MB) +- ✅ BMF libraries (25+ libraries, ~30 MB) +- ✅ Qt frameworks (10+ frameworks, ~150 MB) +- ✅ Python runtime with PyTorch, Real-ESRGAN, OpenCV, NumPy (~30 MB) +- ✅ FFmpeg libraries (~25 MB) + +**Total Size:** ~800 MB - 1.2 GB (fully standalone) + +**Advantages:** +- ✅ Fully standalone (no dependencies) +- ✅ Works on any Mac (macOS 11+) +- ✅ No environment variables needed +- ✅ Ready for distribution (zip/DMG) + +### Library Bundling (macOS Release Mode) + +**Important:** All library bundling (Qt, FFmpeg, BMF) is handled by **`tool/fix_macos_libs.sh`**, NOT by CMake. + +#### **Workflow** + +1. **Build**: `cmake -B build-release -DCMAKE_BUILD_TYPE=Release && cd build-release && make -j4` +2. **Bundle**: `cd .. && tool/fix_macos_libs.sh` + +The script auto-detects build directory, bundles Qt/FFmpeg/BMF libraries, fixes paths, and code signs. + +#### **BMF Library Structure** + +``` +OpenConverter.app/Contents/ +├── Frameworks/ +│ ├── lib/ # Builtin modules (BMF hardcoded path) +│ │ ├── libbuiltin_modules.dylib +│ │ ├── libcopy_module.dylib +│ │ └── libcvtcolor.dylib +│ ├── libbmf_module_sdk.dylib # Core BMF libraries +│ ├── libhmp.dylib +│ ├── _bmf.cpython-39-darwin.so +│ └── BUILTIN_CONFIG.json +└── Resources/bmf_python/ # BMF Python package +``` + +**Why `Frameworks/lib/`?** BMF expects builtin modules in `lib/` subdirectory relative to `BMF_MODULE_CONFIG_PATH`. + +**Script Logic:** +- Auto-appends `/output/bmf` to `$BMF_ROOT_PATH` if needed (same as CMake) +- Copies builtin modules to `Frameworks/lib/` +- Copies core libraries to `Frameworks/` +- Processes both `.dylib` and `.so` files +- Fixes all paths to use `@executable_path` + ### Translation Workflow OpenConverter uses Qt Linguist tools for internationalization (i18n). Translation files are located in `src/resources/`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a35dd76..064ed009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Build with CMake run: | export PATH=$PATH:$FFMPEG_ROOT_PATH/bin - (cd src && cmake -B build -DENABLE_TESTS=ON -DENABLE_GUI=OFF && cd build && make -j$(nproc)) + (cd src && cmake -B build -DENABLE_TESTS=ON -DBMF_TRANSCODER=OFF -DENABLE_GUI=OFF && cd build && make -j$(nproc)) - name: Run tests run: | @@ -78,6 +78,35 @@ jobs: $FFMPEG_ROOT_PATH/bin/ffmpeg -version | head -n 1 $FFMPEG_ROOT_PATH/bin/ffmpeg -encoders 2>/dev/null | grep -E "libx264|libx265" || echo "Warning: x264/x265 not found" + - name: Checkout BMF repository(specific branch) + run: | + git clone https://github.com/OpenConverterLab/bmf.git + cd bmf + git checkout oc + + # wget https://invisible-island.net/archives/ncurses/ncurses-6.5.tar.gz + # wget https://ftp.gnu.org/gnu/binutils/binutils-2.43.1.tar.bz2 + + - name: Cache BMF build + uses: actions/cache@v3 + with: + path: bmf/output/ + key: ${{ runner.os }}-bmf-${{ hashFiles('bmf/build_osx.sh') }} + restore-keys: | + ${{ runner.os }}-bmf-macos-arm- + + - name: Set up BMF if not cached + run: | + if [ ! -d "$(pwd)/bmf/output/" ]; then + # export LIBRARY_PATH=$(pwd)/opt/binutils/lib:$LIBRARY_PATH + # export CMAKE_PREFIX_PATH=$(pwd)/opt/binutils:$CMAKE_PREFIX_PATH + pip install setuptools + (cd bmf && git checkout oc && git submodule update --init --recursive && ./build_osx.sh) + else + echo "BMF is already installed, skipping build." + fi + echo "BMF_ROOT_PATH=$(pwd)/bmf/output/bmf" >> $GITHUB_ENV + - name: Build with CMake run: | export PATH=$PATH:$FFMPEG_ROOT_PATH/bin diff --git a/.github/workflows/review.yaml b/.github/workflows/review.yaml index ce0f1629..0b0a97f5 100644 --- a/.github/workflows/review.yaml +++ b/.github/workflows/review.yaml @@ -21,22 +21,10 @@ jobs: echo "Current branch: $(git rev-parse --abbrev-ref HEAD)" echo "Current commit hash: $(git rev-parse HEAD)" - - name: Checkout BMF repository (specific branch) + - name: Install dependencies run: | - # sudo apt update - # sudo apt install -y make git pkg-config libssl-dev cmake binutils-dev libgoogle-glog-dev gcc g++ golang wget libgl1 - sudo apt install -y nasm yasm libx264-dev libx265-dev libnuma-dev - # sudo apt install -y python3.9 python3-dev python3-pip libsndfile1 libsndfile1-dev - - git clone https://github.com/JackLau1222/bmf.git - - # - name: Cache BMF build - # uses: actions/cache@v3 - # with: - # path: bmf/output/ - # key: ${{ runner.os }}-bmf-${{ hashFiles('bmf/build.sh') }} - # restore-keys: | - # ${{ runner.os }}-bmf-linux-x86 + sudo apt update + sudo apt install -y make git pkg-config libssl-dev cmake binutils-dev libgoogle-glog-dev libunwind-dev gcc g++ golang wget libgl1 - name: Get FFmpeg run: | @@ -45,14 +33,11 @@ jobs: ls ffmpeg-n5.1.6-11-gcde3c5fc0c-linux64-gpl-shared-5.1 echo "FFMPEG_ROOT_PATH=$(pwd)/ffmpeg-n5.1.6-11-gcde3c5fc0c-linux64-gpl-shared-5.1" >> $GITHUB_ENV - # - name: Set up BMF if not cached - # run: | - # if [ ! -d "$(pwd)/bmf/output/" ]; then - # (cd bmf && git checkout fork_by_oc && ./build.sh) - # else - # echo "BMF is already installed, skipping build." - # fi - # echo "BMF_ROOT_PATH=$(pwd)/bmf/output/bmf" >> $GITHUB_ENV + - name: Get BMF + run: | + wget https://github.com/OpenConverterLab/bmf/releases/download/oc0.0.3/bmf-bin-linux-x86_64-cp39.tar.gz + tar xzvf bmf-bin-linux-x86_64-cp39.tar.gz + echo "BMF_ROOT_PATH=$(pwd)/output/bmf" >> $GITHUB_ENV - name: Set up Qt run: | @@ -61,7 +46,7 @@ jobs: - name: Build with CMake run: | export PATH=$PATH:$FFMPEG_ROOT_PATH/bin - (cd src && cmake -B build -DBMF_TRANSCODER=OFF && cd build && make -j$(nproc)) + (cd src && cmake -B build && cd build && make -j$(nproc)) - name: Copy libs run: | @@ -84,15 +69,15 @@ jobs: continue-on-error: true - # - name: Copy runtime - # run: | - # cp $FFMPEG_ROOT_PATH/lib/libswscale.so.6 src/build/lib - # cp $FFMPEG_ROOT_PATH/lib/libavfilter.so.8 src/build/lib - # cp $FFMPEG_ROOT_PATH/lib/libpostproc.so.56 src/build/lib - # cp $BMF_ROOT_PATH/lib/libbuiltin_modules.so src/build/lib - # cp $BMF_ROOT_PATH/BUILTIN_CONFIG.json src/build - # touch src/build/activate_env.sh - # echo export LD_LIBRARY_PATH="./lib" >> src/build/activate_env.sh + - name: Copy runtime + run: | + cp $FFMPEG_ROOT_PATH/lib/libswscale.so.6 src/build/lib + cp $FFMPEG_ROOT_PATH/lib/libavfilter.so.8 src/build/lib + cp $FFMPEG_ROOT_PATH/lib/libpostproc.so.56 src/build/lib + cp $BMF_ROOT_PATH/lib/libbuiltin_modules.so src/build/lib + cp $BMF_ROOT_PATH/BUILTIN_CONFIG.json src/build + touch src/build/activate_env.sh + echo export LD_LIBRARY_PATH="./lib" >> src/build/activate_env.sh # Step to package the build directory - name: Create tar.gz package @@ -120,7 +105,7 @@ jobs: run: echo "Release upload complete" build-macos-arm: - runs-on: macos-latest + runs-on: macos-14 concurrency: group: "review-macos-${{ github.event.pull_request.number }}" cancel-in-progress: true @@ -137,7 +122,7 @@ jobs: - name: Install FFmpeg and Qt via Homebrew run: | # Install FFmpeg 5 with x264, x265 support (pre-built from Homebrew) - brew install ffmpeg@5 qt@5 + brew install ffmpeg@5 qt@5 python@3.9 # Set FFmpeg path export FFMPEG_ROOT_PATH=$(brew --prefix ffmpeg@5) @@ -148,6 +133,18 @@ jobs: $FFMPEG_ROOT_PATH/bin/ffmpeg -version | head -n 1 $FFMPEG_ROOT_PATH/bin/ffmpeg -encoders 2>/dev/null | grep -E "libx264|libx265" || echo "Warning: x264/x265 not found" + - name: Checkout BMF repository(specific branch) + run: | + git clone https://github.com/OpenConverterLab/bmf.git + brew link --force python@3.9 + export BMF_PYTHON_VERSION="3.9" + (cd bmf && git checkout oc && git submodule update --init --recursive && ./build_osx.sh) + echo "BMF_ROOT_PATH=$(pwd)/bmf/output/bmf" >> $GITHUB_ENV + + # wget https://github.com/OpenConverterLab/bmf/releases/download/oc0.0.3/bmf-bin-macOS-arm64-cp39.tar.gz + # tar xzvf bmf-bin-macOS-arm64-cp39.tar.gz + # echo "BMF_ROOT_PATH=$(pwd)/output/bmf" >> $GITHUB_ENV + - name: Build and Deploy run: | export PATH="$(brew --prefix ffmpeg@5)/bin:$PATH" @@ -156,9 +153,9 @@ jobs: export PATH="$(brew --prefix qt@5)/bin:$PATH" cd src - cmake -B build \ + cmake -B build -DCMAKE_BUILD_TYPE=Release \ -DFFMPEG_ROOT_PATH="$(brew --prefix ffmpeg@5)" \ - -DBMF_TRANSCODER=OFF + -DBMF_TRANSCODER=ON cd build make -j$(sysctl -n hw.ncpu) @@ -228,7 +225,7 @@ jobs: - name: Build Qt project run: | (cd src && - cmake -S . -B build "-DFFMPEG_ROOT_PATH=../ffmpeg/ffmpeg-n5.1.6-11-gcde3c5fc0c-win64-gpl-shared-5.1" -DFFTOOL_TRANSCODER=OFF && + cmake -S . -B build "-DFFMPEG_ROOT_PATH=../ffmpeg/ffmpeg-n5.1.6-11-gcde3c5fc0c-win64-gpl-shared-5.1" -DFFTOOL_TRANSCODER=OFF -DBMF_TRANSCODER=OFF && cmake --build build --config Release --parallel) - name : Deploy project diff --git a/.gitignore b/.gitignore index 9ceae138..8eae1efd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ src/build-* build .idea workload_output.txt + +# AI model weights (downloaded during build) +src/modules/weights/*.pth +src/modules/weights/*.onnx +src/modules/weights/*.pt diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 69eb4461..9427f1ff 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -16,7 +16,7 @@ endif() option(ENABLE_GUI "enable GUI" ON) option(ENABLE_TESTS "enable unit tests" OFF) # BMF is experimental feature, so we can't enable it by default -option(BMF_TRANSCODER "enable BMF Transcoder" OFF) +option(BMF_TRANSCODER "enable BMF Transcoder" ON) option(FFTOOL_TRANSCODER "enable FFmpeg Command Tool Transcoder" ON) option(FFMPEG_TRANSCODER "enable FFmpeg Core Transcoder" ON) @@ -71,9 +71,18 @@ endif() if(BMF_TRANSCODER) if(DEFINED ENV{BMF_ROOT_PATH}) set(BMF_ROOT_PATH $ENV{BMF_ROOT_PATH}) + # If BMF_ROOT_PATH doesn't end with /output/bmf, append it + if(NOT BMF_ROOT_PATH MATCHES "output/bmf$") + set(BMF_ROOT_PATH "${BMF_ROOT_PATH}/output/bmf") + endif() else() set(BMF_ROOT_PATH "/Users/jacklau/Documents/Programs/Git/Github/bmf/output/bmf") endif() + message(STATUS "Using BMF from: ${BMF_ROOT_PATH}") + + # Pass BMF_ROOT_PATH to C++ code as compile definition + add_definitions(-DBMF_ROOT_PATH_STR="${BMF_ROOT_PATH}") + include_directories(${BMF_ROOT_PATH}/include) link_directories(${BMF_ROOT_PATH}/lib) add_definitions(-DENABLE_BMF) @@ -226,6 +235,16 @@ target_include_directories(OpenConverterCore PUBLIC ${FFMPEG_INCLUDE_DIRS} ) +# Copy Python modules to build directory (if BMF is enabled) +if(BMF_TRANSCODER) + add_custom_command( + TARGET OpenConverterCore POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/modules + COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/modules ${CMAKE_BINARY_DIR}/modules + COMMENT "Copying Python modules to build directory" + ) +endif() + # Handle GUI mode if(ENABLE_GUI) add_definitions(-DENABLE_GUI) @@ -233,8 +252,8 @@ if(ENABLE_GUI) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) - find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Gui Widgets) - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Widgets) + find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Gui Widgets Network) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Widgets Network) # Add GUI-specific sources list(APPEND GUI_SOURCES @@ -304,6 +323,19 @@ if(ENABLE_GUI) ${CMAKE_SOURCE_DIR}/builder/include/open_converter.h ) + if(BMF_TRANSCODER) + list(APPEND GUI_SOURCES + ${CMAKE_SOURCE_DIR}/component/src/python_install_dialog.cpp + ${CMAKE_SOURCE_DIR}/builder/src/python_manager.cpp + ${CMAKE_SOURCE_DIR}/builder/src/ai_processing_page.cpp + ) + list(APPEND GUI_HEADERS + ${CMAKE_SOURCE_DIR}/component/include/python_install_dialog.h + ${CMAKE_SOURCE_DIR}/builder/include/python_manager.h + ${CMAKE_SOURCE_DIR}/builder/include/ai_processing_page.h + ) + endif() + # Add UI files list(APPEND UI_FILES ${CMAKE_SOURCE_DIR}/builder/src/open_converter.ui @@ -343,6 +375,7 @@ if(ENABLE_GUI) Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Network ) if(${QT_VERSION_MAJOR} GREATER_EQUAL 6) @@ -358,6 +391,73 @@ if(ENABLE_GUI) set_source_files_properties(${APP_ICON_MACOSX} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") target_sources(OpenConverter PRIVATE ${APP_ICON_MACOSX}) + # Bundle Python modules into app (if BMF is enabled) + if(BMF_TRANSCODER) + # Always bundle Python modules (needed for both Debug and Release) + file(GLOB PYTHON_MODULES "${CMAKE_SOURCE_DIR}/modules/*.py") + foreach(MODULE_FILE ${PYTHON_MODULES}) + set_source_files_properties(${MODULE_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/modules") + target_sources(OpenConverter PRIVATE ${MODULE_FILE}) + endforeach() + + # Always bundle AI model weights if they exist (for both Debug and Release) + # This allows AI features to work without internet connection + file(GLOB_RECURSE MODEL_WEIGHTS "${CMAKE_SOURCE_DIR}/modules/weights/*.pth") + if(MODEL_WEIGHTS) + message(STATUS "Bundling existing AI model weights (${CMAKE_BUILD_TYPE} mode)") + foreach(WEIGHT_FILE ${MODEL_WEIGHTS}) + set_source_files_properties(${WEIGHT_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/modules/weights") + target_sources(OpenConverter PRIVATE ${WEIGHT_FILE}) + endforeach() + else() + message(STATUS "No AI model weights found. Run tool/download_models.sh to download them.") + endif() + + # Bundle requirements.txt for Python package installation + set(REQUIREMENTS_FILE "${CMAKE_SOURCE_DIR}/resources/requirements.txt") + if(EXISTS ${REQUIREMENTS_FILE}) + set_source_files_properties(${REQUIREMENTS_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") + target_sources(OpenConverter PRIVATE ${REQUIREMENTS_FILE}) + message(STATUS "Bundling requirements.txt for Python package installation") + endif() + + # Check if this is a Release build + if(CMAKE_BUILD_TYPE MATCHES "Release") + message(STATUS "========================================") + message(STATUS "RELEASE BUILD - Library bundling via fix_macos_libs.sh") + message(STATUS "========================================") + message(STATUS "After building, run: ../tool/fix_macos_libs.sh") + message(STATUS "This will bundle Qt, FFmpeg, and BMF libraries") + message(STATUS "========================================") + + # Set rpath to find bundled libraries (Release mode) + # Include Frameworks, Frameworks/lib, and Python.framework + if(APPLE) + set_target_properties(OpenConverter PROPERTIES + BUILD_WITH_INSTALL_RPATH TRUE + INSTALL_RPATH "@executable_path/../Frameworks;@executable_path/../Frameworks/lib;@executable_path/../Frameworks/Python.framework/Versions/Current/lib" + ) + endif() + else() + # Debug mode - minimal bundling + message(STATUS "========================================") + message(STATUS "DEBUG BUILD - Using system libraries") + message(STATUS "========================================") + message(STATUS "Set environment variables:") + message(STATUS " export BMF_ROOT_PATH=${BMF_ROOT_PATH}") + message(STATUS " export PYTHONPATH=${BMF_ROOT_PATH}/lib:${BMF_ROOT_PATH}") + message(STATUS "========================================") + + # Set rpath for development (use system libraries) - macOS only + if(APPLE) + set_target_properties(OpenConverter PROPERTIES + BUILD_WITH_INSTALL_RPATH FALSE + INSTALL_RPATH "@executable_path/../Frameworks;@executable_path/../Frameworks/lib" + ) + endif() + endif() + endif() + set_target_properties(OpenConverter PROPERTIES MACOSX_BUNDLE_GUI_IDENTIFIER com.openconverter.app MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} diff --git a/src/builder/include/ai_processing_page.h b/src/builder/include/ai_processing_page.h new file mode 100644 index 00000000..2776d927 --- /dev/null +++ b/src/builder/include/ai_processing_page.h @@ -0,0 +1,125 @@ +/* + * Copyright 2025 Jack Lau + * Email: jacklau1222gm@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef AI_PROCESSING_PAGE_H +#define AI_PROCESSING_PAGE_H + +#include "base_page.h" +#include "converter_runner.h" +#include "file_selector_widget.h" +#include "progress_widget.h" +#include "batch_output_widget.h" +#include "batch_mode_helper.h" +#include "bitrate_widget.h" +#include +#include +#include +#include +#include +#include +#include + +class EncodeParameter; +class ProcessParameter; + +class AIProcessingPage : public BasePage { + Q_OBJECT + +public: + explicit AIProcessingPage(QWidget *parent = nullptr); + ~AIProcessingPage() override; + + void OnPageActivated() override; + void OnPageDeactivated() override; + QString GetPageTitle() const override { return "AI Processing"; } + void RetranslateUi() override; + +protected: + void OnInputFileChanged(const QString &newPath) override; + void OnOutputPathUpdate() override; + +private slots: + void OnInputFileSelected(const QString &filePath); + void OnOutputFileSelected(const QString &filePath); + void OnAlgorithmChanged(int index); + void OnFormatChanged(int index); + void OnProcessClicked(); + void OnProcessFinished(bool success); + +signals: + void ProcessComplete(bool success); + +private: + void SetupUI(); + void UpdateOutputPath(); + QString GetFileExtension(const QString &filePath); + EncodeParameter* CreateEncodeParameter(); + + // Input/Output section + FileSelectorWidget *inputFileSelector; + FileSelectorWidget *outputFileSelector; + + // Batch output widget (shown when batch files selected) + BatchOutputWidget *batchOutputWidget; + + // Algorithm selection section + QGroupBox *algorithmGroupBox; + QLabel *algorithmLabel; + QComboBox *algorithmComboBox; + + // Algorithm settings section (dynamic based on selected algorithm) + QGroupBox *algoSettingsGroupBox; + QStackedWidget *algoSettingsStack; + + // Upscaler settings widget + QWidget *upscalerSettingsWidget; + QLabel *upscaleFactorLabel; + QSpinBox *upscaleFactorSpinBox; + + // Video settings section + QGroupBox *videoGroupBox; + QLabel *videoCodecLabel; + QComboBox *videoCodecComboBox; + QLabel *videoBitrateLabel; + BitrateWidget *videoBitrateWidget; + + // Audio settings section + QGroupBox *audioGroupBox; + QLabel *audioCodecLabel; + QComboBox *audioCodecComboBox; + QLabel *audioBitrateLabel; + BitrateWidget *audioBitrateWidget; + + // Format section + QGroupBox *formatGroupBox; + QLabel *formatLabel; + QComboBox *formatComboBox; + + // Progress section + ProgressWidget *progressWidget; + + // Action section + QPushButton *processButton; + + // Conversion runner + ConverterRunner *converterRunner; + + // Batch mode helper + BatchModeHelper *batchModeHelper; +}; + +#endif // AI_PROCESSING_PAGE_H diff --git a/src/builder/include/open_converter.h b/src/builder/include/open_converter.h index 1df13ff0..4959e206 100644 --- a/src/builder/include/open_converter.h +++ b/src/builder/include/open_converter.h @@ -43,6 +43,7 @@ #include #include #include +#include #include #include @@ -88,6 +89,7 @@ class OpenConverter : public QMainWindow, public ProcessObserver { private slots: void SlotLanguageChanged(QAction *action); void SlotTranscoderChanged(QAction *action); + void SlotPythonChanged(QAction *action); void OnNavigationButtonClicked(int pageIndex); void OnQueueButtonClicked(); @@ -107,6 +109,8 @@ private slots: QMessageBox *displayResult; QActionGroup *transcoderGroup; QActionGroup *languageGroup; + QActionGroup *pythonGroup; + QString customPythonPath; // Navigation and page management QButtonGroup *navButtonGroup; @@ -130,6 +134,12 @@ private slots: // Get current transcoder name QString GetCurrentTranscoderName() const; + + // Get current Python path setting + QString GetPythonSitePackagesPath() const; + + // Static method for transcoder_bmf to get Python path + static QString GetStoredPythonPath(); }; #endif // OPEN_CONVERTER_H diff --git a/src/builder/include/python_manager.h b/src/builder/include/python_manager.h new file mode 100644 index 00000000..5761185b --- /dev/null +++ b/src/builder/include/python_manager.h @@ -0,0 +1,175 @@ +/* + * Copyright 2025 Jack Lau + * Email: jacklau1222gm@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef PYTHON_MANAGER_H +#define PYTHON_MANAGER_H + +#include +#include +#include +#include +#include + +/** + * @brief Manages embedded Python runtime for OpenConverter + * + * This class handles: + * - Detecting if Python is installed in app bundle + * - Downloading Python 3.9 standalone build + * - Installing Python packages from requirements.txt + * - Providing isolated Python environment (no system conflicts) + * + * Similar to how Blender, GIMP, and other apps bundle Python. + */ +class PythonManager : public QObject { + Q_OBJECT + +public: + enum class Status { + NotInstalled, // Python not found in app bundle + Installing, // Currently downloading/installing + Installed, // Python installed and ready + Error // Installation failed + }; + + explicit PythonManager(QObject *parent = nullptr); + ~PythonManager(); + + /** + * @brief Check if Python is installed in app bundle + * @return true if Python.framework exists and is functional + */ + bool IsPythonInstalled(); + + /** + * @brief Check if all required packages are installed + * @return true if all packages from requirements.txt are available + */ + bool ArePackagesInstalled(); + + /** + * @brief Check if system Python 3.9 with required packages exists + * @return true if system Python 3.9 has all required packages (Debug mode optimization) + */ + bool CheckSystemPython(); + + /** + * @brief Get path to embedded Python executable + * @return Path to python3 binary, or empty string if not installed + */ + QString GetPythonPath(); + + /** + * @brief Get path to site-packages directory + * @return Path to site-packages, or empty string if not installed + */ + QString GetSitePackagesPath(); + + /** + * @brief Get current installation status + */ + Status GetStatus() const { return status; } + + /** + * @brief Get installation progress (0-100) + */ + int GetProgress() const { return progress; } + + /** + * @brief Get current status message + */ + QString GetStatusMessage() const { return statusMessage; } + +public slots: + /** + * @brief Download and install Python 3.9 to app bundle + * + * Downloads Python standalone build from python.org + * Extracts to Contents/Frameworks/Python.framework + * Emits signals for progress updates + */ + void InstallPython(); + + /** + * @brief Install packages from requirements.txt + * + * Uses bundled pip to install packages + * Packages are installed to embedded site-packages + * Does not affect system Python + */ + void InstallPackages(); + + /** + * @brief Cancel ongoing installation + */ + void CancelInstallation(); + +signals: + /** + * @brief Emitted when installation status changes + */ + void StatusChanged(PythonManager::Status status); + + /** + * @brief Emitted during download/installation + * @param progress Progress percentage (0-100) + * @param message Status message + */ + void ProgressChanged(int progress, const QString &message); + + /** + * @brief Emitted when Python installation completes successfully + */ + void PythonInstalled(); + + /** + * @brief Emitted when package installation completes successfully + */ + void PackagesInstalled(); + + /** + * @brief Emitted when installation fails + * @param error Error message + */ + void InstallationFailed(const QString &error); + +private slots: + void OnDownloadProgress(qint64 bytesReceived, qint64 bytesTotal); + void OnDownloadFinished(); + void OnInstallProcessOutput(); + void OnInstallProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); + +private: + QString GetAppBundlePath(); + QString GetPythonFrameworkPath(); + QString GetRequirementsPath(); + bool ExtractPythonArchive(const QString &archivePath); + bool CopyDirectoryRecursively(const QString &source, const QString &destination); + void SetStatus(Status newStatus, const QString &message); + void SetProgress(int value, const QString &message); + + QNetworkAccessManager *networkManager; + QNetworkReply *currentDownload; + QProcess *installProcess; + + Status status; + int progress; + QString statusMessage; + QString downloadedFilePath; +}; + +#endif // PYTHON_MANAGER_H diff --git a/src/builder/src/ai_processing_page.cpp b/src/builder/src/ai_processing_page.cpp new file mode 100644 index 00000000..bce1ba5d --- /dev/null +++ b/src/builder/src/ai_processing_page.cpp @@ -0,0 +1,436 @@ +/* + * Copyright 2025 Jack Lau + * Email: jacklau1222gm@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../include/ai_processing_page.h" +#include "../include/open_converter.h" +#include "../include/shared_data.h" +#include "../include/batch_queue.h" +#include "../include/batch_item.h" +#include "../include/transcoder_helper.h" +#include "../include/python_manager.h" +#include "../../common/include/encode_parameter.h" +#include "../../common/include/process_parameter.h" +#include "../../engine/include/converter.h" +#include "../../component/include/python_install_dialog.h" +#include +#include +#include +#include +#include + +AIProcessingPage::AIProcessingPage(QWidget *parent) : BasePage(parent), converterRunner(nullptr) { + SetupUI(); +} + +AIProcessingPage::~AIProcessingPage() { +} + +void AIProcessingPage::OnPageActivated() { + BasePage::OnPageActivated(); + HandleSharedDataUpdate(inputFileSelector->GetLineEdit(), outputFileSelector->GetLineEdit(), + GetFileExtension(inputFileSelector->GetFilePath())); + + // Check if Python is installed for AI Processing + // In Debug mode, skip installation dialog (assume developer has configured environment) +#ifdef NDEBUG + // Release mode: check Python and offer installation + PythonManager pythonManager; + + // Check status: embedded Python, system Python, or not installed + if (pythonManager.GetStatus() != PythonManager::Status::Installed) { + // Python not available (neither embedded nor system) + // Show installation dialog + QMessageBox::StandardButton reply = QMessageBox::question( + this, + tr("Python Required"), + tr("AI Processing requires Python 3.9 and additional packages.\n\n" + "Would you like to download and install them now?\n" + "(Download size: ~550 MB, completely isolated from system Python)"), + QMessageBox::Yes | QMessageBox::No + ); + + if (reply == QMessageBox::Yes) { + PythonInstallDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) { + // User cancelled installation + QMessageBox::information( + this, + tr("AI Processing Unavailable"), + tr("AI Processing features require Python to be installed.\n\n" + "You can install it later by returning to this page.") + ); + } + } + } +#else + // Debug mode: assume developer has configured Python environment + // Skip installation dialog + qDebug() << "Debug mode: Skipping Python installation check (assuming developer environment)"; +#endif +} + +void AIProcessingPage::OnInputFileChanged(const QString &newPath) { + QString ext = GetFileExtension(newPath); + if (!ext.isEmpty()) { + int index = formatComboBox->findText(ext); + if (index >= 0) { + formatComboBox->setCurrentIndex(index); + } + } + // Update output path when input changes + UpdateOutputPath(); +} + +void AIProcessingPage::OnOutputPathUpdate() { + UpdateOutputPath(); +} + +void AIProcessingPage::OnPageDeactivated() { + BasePage::OnPageDeactivated(); + HandleSharedDataUpdate(inputFileSelector->GetLineEdit(), outputFileSelector->GetLineEdit(), + formatComboBox->currentText()); +} + +void AIProcessingPage::SetupUI() { + QVBoxLayout *mainLayout = new QVBoxLayout(this); + mainLayout->setSpacing(15); + mainLayout->setContentsMargins(20, 20, 20, 20); + + // Input File Selector (with Batch button) + inputFileSelector = new FileSelectorWidget( + tr("Input File"), + FileSelectorWidget::InputFile, + tr("Select a media file or click Batch for multiple files..."), + tr("All Files (*.*)"), + tr("Select Media File"), + this + ); + connect(inputFileSelector, &FileSelectorWidget::FileSelected, this, &AIProcessingPage::OnInputFileSelected); + mainLayout->addWidget(inputFileSelector); + + // Algorithm Selection Section + algorithmGroupBox = new QGroupBox(tr("Algorithm"), this); + QGridLayout *algorithmLayout = new QGridLayout(algorithmGroupBox); + algorithmLayout->setSpacing(10); + + algorithmLabel = new QLabel(tr("Select Algorithm:"), algorithmGroupBox); + algorithmComboBox = new QComboBox(algorithmGroupBox); + algorithmComboBox->addItem(tr("Upscaler")); + algorithmComboBox->setCurrentIndex(0); + connect(algorithmComboBox, QOverload::of(&QComboBox::currentIndexChanged), + this, &AIProcessingPage::OnAlgorithmChanged); + + algorithmLayout->addWidget(algorithmLabel, 0, 0); + algorithmLayout->addWidget(algorithmComboBox, 0, 1); + + mainLayout->addWidget(algorithmGroupBox); + + // Algorithm Settings Section (dynamic based on selected algorithm) + algoSettingsGroupBox = new QGroupBox(tr("Algorithm Settings"), this); + QGridLayout *algoSettingsLayout = new QGridLayout(algoSettingsGroupBox); + algoSettingsLayout->setSpacing(10); + + algoSettingsStack = new QStackedWidget(algoSettingsGroupBox); + algoSettingsStack->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + // Upscaler Settings Widget + upscalerSettingsWidget = new QWidget(algoSettingsStack); + QGridLayout *upscalerLayout = new QGridLayout(upscalerSettingsWidget); + upscalerLayout->setSpacing(10); + upscalerLayout->setContentsMargins(0, 0, 0, 0); + + upscaleFactorLabel = new QLabel(tr("Upscale Factor:"), upscalerSettingsWidget); + upscaleFactorSpinBox = new QSpinBox(upscalerSettingsWidget); + upscaleFactorSpinBox->setMinimum(2); + upscaleFactorSpinBox->setMaximum(8); + upscaleFactorSpinBox->setValue(2); + upscaleFactorSpinBox->setSuffix("x"); + + upscalerLayout->addWidget(upscaleFactorLabel, 0, 0); + upscalerLayout->addWidget(upscaleFactorSpinBox, 0, 1); + + algoSettingsStack->addWidget(upscalerSettingsWidget); + + algoSettingsLayout->addWidget(algoSettingsStack, 0, 0, 1, 2); + mainLayout->addWidget(algoSettingsGroupBox); + + // Video Settings Section + videoGroupBox = new QGroupBox(tr("Video Settings"), this); + QGridLayout *videoLayout = new QGridLayout(videoGroupBox); + videoLayout->setSpacing(10); + + videoCodecLabel = new QLabel(tr("Codec:"), videoGroupBox); + videoCodecComboBox = new QComboBox(videoGroupBox); + videoCodecComboBox->addItems({"auto", "libx264", "libx265", "libvpx-vp9", "copy"}); + videoCodecComboBox->setCurrentText("auto"); + + videoBitrateLabel = new QLabel(tr("Bitrate:"), videoGroupBox); + videoBitrateWidget = new BitrateWidget(BitrateWidget::Video, videoGroupBox); + + videoLayout->addWidget(videoCodecLabel, 0, 0); + videoLayout->addWidget(videoCodecComboBox, 0, 1); + videoLayout->addWidget(videoBitrateLabel, 1, 0); + videoLayout->addWidget(videoBitrateWidget, 1, 1); + + mainLayout->addWidget(videoGroupBox); + + // Audio Settings Section + audioGroupBox = new QGroupBox(tr("Audio Settings"), this); + QGridLayout *audioLayout = new QGridLayout(audioGroupBox); + audioLayout->setSpacing(10); + + audioCodecLabel = new QLabel(tr("Codec:"), audioGroupBox); + audioCodecComboBox = new QComboBox(audioGroupBox); + audioCodecComboBox->addItems({"auto", "aac", "libmp3lame", "libopus", "copy"}); + audioCodecComboBox->setCurrentText("auto"); + + audioBitrateLabel = new QLabel(tr("Bitrate:"), audioGroupBox); + audioBitrateWidget = new BitrateWidget(BitrateWidget::Audio, audioGroupBox); + + audioLayout->addWidget(audioCodecLabel, 0, 0); + audioLayout->addWidget(audioCodecComboBox, 0, 1); + audioLayout->addWidget(audioBitrateLabel, 1, 0); + audioLayout->addWidget(audioBitrateWidget, 1, 1); + + mainLayout->addWidget(audioGroupBox); + + // Format Section + formatGroupBox = new QGroupBox(tr("File Format"), this); + QHBoxLayout *formatLayout = new QHBoxLayout(formatGroupBox); + + formatLabel = new QLabel(tr("Format:"), formatGroupBox); + formatComboBox = new QComboBox(formatGroupBox); + formatComboBox->addItems({"mp4", "mkv", "avi", "mov", "flv", "webm", "ts", "jpg", "png"}); + formatComboBox->setCurrentText("mp4"); + connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), + this, &AIProcessingPage::OnFormatChanged); + + formatLayout->addWidget(formatLabel); + formatLayout->addWidget(formatComboBox); + formatLayout->addStretch(); + + mainLayout->addWidget(formatGroupBox); + + // Output File Selector + outputFileSelector = new FileSelectorWidget( + tr("Output File"), + FileSelectorWidget::OutputFile, + tr("Output file path..."), + tr("All Files (*.*)"), + tr("Select Output File"), + this + ); + connect(outputFileSelector, &FileSelectorWidget::FileSelected, this, &AIProcessingPage::OnOutputFileSelected); + mainLayout->addWidget(outputFileSelector); + + // Batch Output Widget (hidden by default, shown when batch files selected) + batchOutputWidget = new BatchOutputWidget(this); + batchOutputWidget->setVisible(false); + mainLayout->addWidget(batchOutputWidget); + + // Process Button + processButton = new QPushButton(tr("Process / Add to Queue"), this); + processButton->setEnabled(false); + processButton->setMinimumHeight(40); + connect(processButton, &QPushButton::clicked, this, &AIProcessingPage::OnProcessClicked); + mainLayout->addWidget(processButton); + + // Progress Section (placed after button to avoid blank space when hidden) + progressWidget = new ProgressWidget(this); + mainLayout->addWidget(progressWidget); + + // Initialize converter runner + converterRunner = new ConverterRunner( + progressWidget->GetProgressBar(), progressWidget->GetProgressLabel(), processButton, + tr("Processing..."), tr("Process / Add to Queue"), + tr("Success"), tr("AI processing completed successfully!"), + tr("Error"), tr("Failed to process file."), + this + ); + connect(converterRunner, &ConverterRunner::ConversionFinished, this, &AIProcessingPage::OnProcessFinished); + + // Initialize batch mode helper + batchModeHelper = new BatchModeHelper( + inputFileSelector, batchOutputWidget, processButton, + tr("Process / Add to Queue"), tr("Add to Queue"), this + ); + batchModeHelper->SetSingleOutputWidget(outputFileSelector); + batchModeHelper->SetEncodeParameterCreator([this]() { + return CreateEncodeParameter(); + }); +} + +void AIProcessingPage::OnInputFileSelected(const QString &filePath) { + if (filePath.isEmpty()) { + return; + } + + // Update shared input file path + OpenConverter *mainWindow = qobject_cast(window()); + if (mainWindow && mainWindow->GetSharedData()) { + mainWindow->GetSharedData()->SetInputFilePath(filePath); + } + + // Set default format to same as input file + QString ext = GetFileExtension(filePath); + if (!ext.isEmpty()) { + int index = formatComboBox->findText(ext); + if (index >= 0) { + formatComboBox->setCurrentIndex(index); + } + } + + // Update output path + UpdateOutputPath(); +} + +void AIProcessingPage::OnOutputFileSelected(const QString &filePath) { + // Mark output path as manually set + OpenConverter *mainWindow = qobject_cast(window()); + if (mainWindow && mainWindow->GetSharedData()) { + mainWindow->GetSharedData()->SetOutputFilePath(filePath); + } +} + +void AIProcessingPage::OnAlgorithmChanged(int index) { + // Switch to the corresponding settings widget + algoSettingsStack->setCurrentIndex(index); +} + +void AIProcessingPage::OnFormatChanged(int index) { + Q_UNUSED(index); + UpdateOutputPath(); +} + +void AIProcessingPage::OnProcessClicked() { + // Check if batch mode is active + if (batchModeHelper->IsBatchMode()) { + // Batch mode: Add to queue + QString format = formatComboBox->currentText(); + batchModeHelper->AddToQueue(format); + return; + } + + // Single file mode: Process immediately + QString inputPath = inputFileSelector->GetFilePath(); + QString outputPath = outputFileSelector->GetFilePath(); + + if (inputPath.isEmpty()) { + QMessageBox::warning(this, tr("Warning"), tr("Please select an input file.")); + return; + } + + if (outputPath.isEmpty()) { + QMessageBox::warning(this, tr("Warning"), tr("Please select an output file.")); + return; + } + + // Create parameters + EncodeParameter *encodeParam = CreateEncodeParameter(); + ProcessParameter *processParam = new ProcessParameter(); + + // Get transcoder name (must be BMF for AI processing) + QString transcoderName = "BMF"; + + // Run conversion using ConverterRunner + converterRunner->RunConversion(inputPath, outputPath, encodeParam, processParam, transcoderName); +} + +void AIProcessingPage::OnProcessFinished(bool success) { + Q_UNUSED(success); + // ConverterRunner handles all UI updates and message boxes + // This slot is kept for potential custom post-processing + emit ProcessComplete(success); +} + +void AIProcessingPage::UpdateOutputPath() { + QString inputPath = inputFileSelector->GetFilePath(); + if (!inputPath.isEmpty()) { + OpenConverter *mainWindow = qobject_cast(window()); + if (mainWindow && mainWindow->GetSharedData()) { + QString format = formatComboBox->currentText(); + QString outputPath = mainWindow->GetSharedData()->GenerateOutputPath(format); + outputFileSelector->SetFilePath(outputPath); + processButton->setEnabled(true); + } + } +} + +QString AIProcessingPage::GetFileExtension(const QString &filePath) { + QFileInfo fileInfo(filePath); + return fileInfo.suffix(); +} + +EncodeParameter* AIProcessingPage::CreateEncodeParameter() { + EncodeParameter *encodeParam = new EncodeParameter(); + + // Set algorithm mode based on selected algorithm + int algoIndex = algorithmComboBox->currentIndex(); + if (algoIndex == 0) { // Upscaler + encodeParam->set_algo_mode(AlgoMode::Upscale); + encodeParam->set_upscale_factor(upscaleFactorSpinBox->value()); + } + + // Set video codec and bitrate + QString videoCodec = videoCodecComboBox->currentText(); + if (videoCodec != "auto") + encodeParam->set_video_codec_name(videoCodec.toStdString()); + + int videoBitrate = videoBitrateWidget->GetBitrate(); + if (videoBitrate > 0) { + encodeParam->set_video_bit_rate(videoBitrate); + } + + // Set audio codec and bitrate + QString audioCodec = audioCodecComboBox->currentText(); + if (audioCodec != "auto") + encodeParam->set_audio_codec_name(audioCodec.toStdString()); + + int audioBitrate = audioBitrateWidget->GetBitrate(); + if (audioBitrate > 0) { + encodeParam->set_audio_bit_rate(audioBitrate); + } + + return encodeParam; +} + +void AIProcessingPage::RetranslateUi() { + // Update all translatable strings + algorithmGroupBox->setTitle(tr("Algorithm")); + algorithmLabel->setText(tr("Select Algorithm:")); + algorithmComboBox->setItemText(0, tr("Upscaler")); + + algoSettingsGroupBox->setTitle(tr("Algorithm Settings")); + upscaleFactorLabel->setText(tr("Upscale Factor:")); + + videoGroupBox->setTitle(tr("Video Settings")); + videoCodecLabel->setText(tr("Codec:")); + videoBitrateLabel->setText(tr("Bitrate:")); + + audioGroupBox->setTitle(tr("Audio Settings")); + audioCodecLabel->setText(tr("Codec:")); + audioBitrateLabel->setText(tr("Bitrate:")); + + // Update button text based on batch mode + if (batchModeHelper) { + if (inputFileSelector->IsBatchMode()) { + processButton->setText(tr("Add to Queue")); + } else { + processButton->setText(tr("Process / Add to Queue")); + } + } +} diff --git a/src/builder/src/converter_runner.cpp b/src/builder/src/converter_runner.cpp index 00e71bc8..6857693c 100644 --- a/src/builder/src/converter_runner.cpp +++ b/src/builder/src/converter_runner.cpp @@ -113,6 +113,12 @@ bool ConverterRunner::RunConversion(const QString &inputPath, delete processParam; }); + // Set larger stack size for BMF operations (Python/numpy needs more stack) + // Default is 512KB on macOS, increase to 8MB for AI processing + if (transcoderName == "BMF") { + thread->setStackSize(8 * 1024 * 1024); // 8 MB + } + connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); diff --git a/src/builder/src/open_converter.cpp b/src/builder/src/open_converter.cpp index b5119b72..947cb70b 100644 --- a/src/builder/src/open_converter.cpp +++ b/src/builder/src/open_converter.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +59,7 @@ #include "../include/remux_page.h" #include "../include/shared_data.h" #include "../include/transcode_page.h" +#include "../include/ai_processing_page.h" #include "ui_open_converter.h" #include @@ -130,6 +132,27 @@ OpenConverter::OpenConverter(QWidget *parent) languageGroup->addAction(action); } + // Setup Python menu + pythonGroup = new QActionGroup(this); + pythonGroup->setExclusive(true); + QList pythonActions = ui->menuPython->actions(); + for (QAction* action : pythonActions) { + action->setCheckable(true); + pythonGroup->addAction(action); + } + + // Load saved Python setting or default to App Python + QSettings settings("OpenConverter", "OpenConverter"); + QString savedPython = settings.value("python/mode", "pythonAppSupport").toString(); + customPythonPath = settings.value("python/customPath", "").toString(); + + for (QAction* action : pythonActions) { + if (action->objectName() == savedPython) { + action->setChecked(true); + break; + } + } + // Initialize language - default to English (no translation file needed) m_currLang = "english"; m_langPath = ":/"; @@ -151,6 +174,9 @@ OpenConverter::OpenConverter(QWidget *parent) navButtonGroup->addButton(ui->btnCreateGif, 4); navButtonGroup->addButton(ui->btnRemux, 5); navButtonGroup->addButton(ui->btnTranscode, 6); +#if defined(ENABLE_BMF) && defined(ENABLE_GUI) + navButtonGroup->addButton(ui->btnAIProcessing, 7); +#endif // Connect navigation button group connect(navButtonGroup, QOverload::of(&QButtonGroup::idClicked), @@ -171,6 +197,9 @@ OpenConverter::OpenConverter(QWidget *parent) connect(ui->menuTranscoder, SIGNAL(triggered(QAction *)), this, SLOT(SlotTranscoderChanged(QAction *))); + connect(ui->menuPython, SIGNAL(triggered(QAction *)), this, + SLOT(SlotPythonChanged(QAction *))); + // Connect Queue button connect(ui->queueButton, &QPushButton::clicked, this, &OpenConverter::OnQueueButtonClicked); } @@ -235,6 +264,48 @@ void OpenConverter::SlotTranscoderChanged(QAction *action) { } } +// Called every time, when a menu entry of the Python menu is called +void OpenConverter::SlotPythonChanged(QAction *action) { + if (!action) return; + + QString pythonMode = action->objectName(); + QSettings settings("OpenConverter", "OpenConverter"); + + if (pythonMode == "pythonCustom") { + // Show file dialog to select site-packages path + QString dir = QFileDialog::getExistingDirectory( + this, + tr("Select Python site-packages Directory"), + customPythonPath.isEmpty() ? "/opt/homebrew/lib/python3.9/site-packages" : customPythonPath, + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks + ); + + if (!dir.isEmpty()) { + customPythonPath = dir; + settings.setValue("python/mode", pythonMode); + settings.setValue("python/customPath", customPythonPath); + ui->statusBar->showMessage( + tr("Python path set to: %1").arg(customPythonPath)); + } else { + // User cancelled, revert to previous selection + QString savedPython = settings.value("python/mode", "pythonAppSupport").toString(); + QList pythonActions = ui->menuPython->actions(); + for (QAction* act : pythonActions) { + if (act->objectName() == savedPython) { + act->setChecked(true); + break; + } + } + return; + } + } else { + settings.setValue("python/mode", pythonMode); + if (pythonMode == "pythonAppSupport") { + ui->statusBar->showMessage(tr("Using App Python")); + } + } +} + // Called every time, when a menu entry of the language menu is called void OpenConverter::SlotLanguageChanged(QAction *action) { if (0 != action) { @@ -342,6 +413,9 @@ void OpenConverter::InitializePages() { // Advanced section pages.append(new RemuxPage(this)); pages.append(new TranscodePage(this)); +#if defined(ENABLE_BMF) && defined(ENABLE_GUI) + pages.append(new AIProcessingPage(this)); +#endif // Add all pages to the stacked widget for (BasePage *page : pages) { @@ -422,4 +496,35 @@ QString OpenConverter::GetCurrentTranscoderName() const { return "FFMPEG"; } +QString OpenConverter::GetPythonSitePackagesPath() const { + QAction *checkedAction = pythonGroup->checkedAction(); + if (checkedAction) { + QString mode = checkedAction->objectName(); + if (mode == "pythonCustom" && !customPythonPath.isEmpty()) { + return customPythonPath; + } else if (mode == "pythonAppSupport") { + // Python installed in ~/Library/Application Support/OpenConverter/ + QString appSupportPath = QDir::homePath() + + "/Library/Application Support/OpenConverter/Python.framework/lib/python3.9/site-packages"; + return appSupportPath; + } + } + // Default: Bundled or empty (transcoder will use bundled) + return QString(); +} + +QString OpenConverter::GetStoredPythonPath() { + // Static method that can be called from transcoder_bmf without GUI instance + QSettings settings("OpenConverter", "OpenConverter"); + QString pythonMode = settings.value("python/mode", "pythonAppSupport").toString(); + + if (pythonMode == "pythonCustom") { + return settings.value("python/customPath", "").toString(); + } + // Default to App Python (~/Library/Application Support/OpenConverter/) + QString appSupportPath = QDir::homePath() + + "/Library/Application Support/OpenConverter/Python.framework/lib/python3.9/site-packages"; + return appSupportPath; +} + #include "open_converter.moc" diff --git a/src/builder/src/open_converter.ui b/src/builder/src/open_converter.ui index eb39f4af..19b64058 100644 --- a/src/builder/src/open_converter.ui +++ b/src/builder/src/open_converter.ui @@ -175,6 +175,16 @@ QLabel { + + + + AI Processing + + + true + + + @@ -255,8 +265,16 @@ QLabel { Transcoder + + + Python + + + + + @@ -275,6 +293,23 @@ QLabel { Chinese + + + + pythonAppSupport + + + App Python + + + + + pythonCustom + + + Custom Path... + + diff --git a/src/builder/src/python_manager.cpp b/src/builder/src/python_manager.cpp new file mode 100644 index 00000000..bbfcde45 --- /dev/null +++ b/src/builder/src/python_manager.cpp @@ -0,0 +1,542 @@ +/* + * Copyright 2025 Jack Lau + * Email: jacklau1222gm@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "python_manager.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __APPLE__ +#include +#endif + +// Python 3.9 standalone build URL (macOS) +// Using Python Standalone Builds from Astral (formerly Gregory Szorc) +// These are pre-built, relocatable Python frameworks - much easier to extract +#ifdef __APPLE__ +#ifdef __aarch64__ +// macOS ARM64 (Apple Silicon) +const QString PYTHON_DOWNLOAD_URL = "https://github.com/astral-sh/python-build-standalone/releases/download/20251031/cpython-3.9.25+20251031-aarch64-apple-darwin-install_only.tar.gz"; +const QString PYTHON_ARCHIVE_SIZE_MB = "18"; +#else +// macOS x86_64 (Intel) +const QString PYTHON_DOWNLOAD_URL = "https://github.com/astral-sh/python-build-standalone/releases/download/20251031/cpython-3.9.25+20251031-x86_64-apple-darwin-install_only.tar.gz"; +const QString PYTHON_ARCHIVE_SIZE_MB = "18"; +#endif +#endif + +#ifdef __linux__ +#ifdef __aarch64__ +const QString PYTHON_DOWNLOAD_URL = "https://github.com/astral-sh/python-build-standalone/releases/download/20251031/cpython-3.9.25+20251031-aarch64-unknown-linux-gnu-install_only.tar.gz"; +const QString PYTHON_ARCHIVE_SIZE_MB = "18"; +#else +const QString PYTHON_DOWNLOAD_URL = "https://github.com/astral-sh/python-build-standalone/releases/download/20251031/cpython-3.9.25+20251031-x86_64-unknown-linux-gnu-install_only.tar.gz"; +const QString PYTHON_ARCHIVE_SIZE_MB = "18"; +#endif +#endif + +PythonManager::PythonManager(QObject *parent) + : QObject(parent) + , networkManager(new QNetworkAccessManager(this)) + , currentDownload(nullptr) + , installProcess(nullptr) + , status(Status::NotInstalled) + , progress(0) +{ + // Check initial status + // First check embedded Python in app bundle + if (IsPythonInstalled()) { + if (ArePackagesInstalled()) { + SetStatus(Status::Installed, "Python and packages are ready"); + } else { + SetStatus(Status::NotInstalled, "Python installed but packages missing"); + } + } else { +#ifndef NDEBUG + // Debug mode only: check if system Python 3.9 with required packages exists + // This allows developers to use their existing Python environment + if (CheckSystemPython()) { + SetStatus(Status::Installed, "Using system Python 3.9 with required packages"); + } else { + SetStatus(Status::NotInstalled, "Python not installed"); + } +#else + // Release mode: Only use bundled Python, never fall back to system Python + SetStatus(Status::NotInstalled, "Python not installed"); +#endif + } +} + +PythonManager::~PythonManager() { + if (currentDownload) { + currentDownload->abort(); + currentDownload->deleteLater(); + } + if (installProcess) { + installProcess->kill(); + installProcess->deleteLater(); + } +} + +QString PythonManager::GetAppBundlePath() { +#ifdef __APPLE__ + char path[1024]; + uint32_t size = sizeof(path); + if (_NSGetExecutablePath(path, &size) == 0) { + QString exePath = QString::fromUtf8(path); + // Extract app bundle path (everything before .app/Contents/MacOS) + int appIndex = exePath.indexOf(".app/Contents/MacOS"); + if (appIndex != -1) { + return exePath.left(appIndex + 4); // Include .app + } + } +#endif + return QCoreApplication::applicationDirPath(); +} + +QString PythonManager::GetPythonFrameworkPath() { + // Install embedded Python into Application Support on macOS so + // the app bundle remains immutable. Use QStandardPaths to get + // the per-user Application Support directory for the app. + // macOS: ~/Library/Application Support/OpenConverter/Python.framework +#ifdef __APPLE__ + QString appSupportBase = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + // Ensure directory exists + QDir().mkpath(appSupportBase); + return appSupportBase + "/Python.framework"; +#else + // Linux: Use app bundle location (legacy behavior for now) + return GetAppBundlePath() + "/Contents/Frameworks/Python.framework"; +#endif +} + +QString PythonManager::GetPythonPath() { + // Python Standalone Builds use flat structure: python/bin/python3.9 + QString pythonPath = GetPythonFrameworkPath() + "/bin/python3.9"; + if (QFile::exists(pythonPath)) { + return pythonPath; + } + return QString(); +} + +QString PythonManager::GetSitePackagesPath() { + // Python Standalone Builds use flat structure: python/lib/python3.9/site-packages + QString sitePath = GetPythonFrameworkPath() + "/lib/python3.9/site-packages"; + if (QDir(sitePath).exists()) { + return sitePath; + } + return QString(); +} + +QString PythonManager::GetRequirementsPath() { + return GetAppBundlePath() + "/Contents/Resources/requirements.txt"; +} + +bool PythonManager::IsPythonInstalled() { + QString pythonPath = GetPythonPath(); + if (pythonPath.isEmpty()) { + return false; + } + + // Verify Python is executable and correct version + QProcess process; + process.start(pythonPath, QStringList() << "--version"); + if (!process.waitForFinished(3000)) { + return false; + } + + QString output = process.readAllStandardOutput(); + return output.contains("Python 3.9"); +} + +bool PythonManager::ArePackagesInstalled() { + // Fast check: Just verify package directories exist in site-packages + // This is much faster than importing packages (which can take 10+ seconds) + + QString sitePackages = GetSitePackagesPath(); + if (sitePackages.isEmpty()) { + qDebug() << "site-packages directory not found:" << sitePackages; + return false; + } + + // Check if key package directories exist + // Note: BMF is bundled with the app and loaded via PYTHONPATH, not installed here + QStringList requiredPackages = {"torch", "basicsr", "realesrgan"}; + + for (const QString &package : requiredPackages) { + QString packagePath = sitePackages + "/" + package; + if (!QDir(packagePath).exists()) { + qDebug() << "Package directory not found:" << package << "at" << packagePath; + return false; + } + } + + qDebug() << "All required packages found in site-packages"; + return true; +} + +bool PythonManager::CheckSystemPython() { + // Check if system Python 3.9 exists with all required packages + // This is useful in Debug mode to avoid re-downloading Python + // Note: BMF is checked separately since it's bundled with the app + + QStringList pythonCandidates = {"python3.9", "python3"}; + + for (const QString &pythonCmd : pythonCandidates) { + QProcess versionCheck; + versionCheck.start(pythonCmd, QStringList() << "--version"); + if (!versionCheck.waitForFinished(3000)) { + continue; + } + + QString output = versionCheck.readAllStandardOutput(); + if (!output.contains("Python 3.9")) { + continue; + } + + // Found Python 3.9, now check if core AI packages are installed + // BMF is excluded because it's bundled with the app and will be added to PYTHONPATH + QStringList requiredPackages = {"torch", "basicsr", "realesrgan"}; + bool allPackagesFound = true; + + for (const QString &package : requiredPackages) { + QProcess packageCheck; + packageCheck.start(pythonCmd, QStringList() << "-c" << QString("import %1").arg(package)); + if (!packageCheck.waitForFinished(5000) || packageCheck.exitCode() != 0) { + qDebug() << "System Python missing package:" << package; + allPackagesFound = false; + break; + } + } + + if (allPackagesFound) { + qDebug() << "Found system Python 3.9 with required AI packages:" << pythonCmd; + qDebug() << "BMF will be loaded from bundled location"; + return true; + } + } + + return false; +} + +void PythonManager::SetStatus(Status newStatus, const QString &message) { + status = newStatus; + statusMessage = message; + emit StatusChanged(status); + qDebug() << "PythonManager status:" << message; +} + +void PythonManager::SetProgress(int value, const QString &message) { + progress = value; + statusMessage = message; + emit ProgressChanged(progress, statusMessage); +} + +void PythonManager::InstallPython() { + if (status == Status::Installing) { + qWarning() << "Installation already in progress"; + return; + } + + SetStatus(Status::Installing, "Downloading Python 3.9..."); + SetProgress(0, "Starting download..."); + + // Download Python installer + QUrl url(PYTHON_DOWNLOAD_URL); + QNetworkRequest request(url); + + // Follow redirects (GitHub releases redirect to CDN) + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + currentDownload = networkManager->get(request); + + connect(currentDownload, &QNetworkReply::downloadProgress, + this, &PythonManager::OnDownloadProgress); + connect(currentDownload, &QNetworkReply::finished, + this, &PythonManager::OnDownloadFinished); +} + +void PythonManager::OnDownloadProgress(qint64 bytesReceived, qint64 bytesTotal) { + if (bytesTotal > 0) { + int percent = (bytesReceived * 50) / bytesTotal; // 0-50% for download + SetProgress(percent, QString("Downloading Python: %1 MB / %2 MB") + .arg(bytesReceived / 1024 / 1024) + .arg(bytesTotal / 1024 / 1024)); + } +} + +void PythonManager::OnDownloadFinished() { + if (!currentDownload) { + return; + } + + if (currentDownload->error() != QNetworkReply::NoError) { + QString error = currentDownload->errorString(); + currentDownload->deleteLater(); + currentDownload = nullptr; + SetStatus(Status::Error, "Download failed: " + error); + emit InstallationFailed(error); + return; + } + + // Save downloaded file + QString tempDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + downloadedFilePath = tempDir + "/python-3.9.25.tar.gz"; + + QFile file(downloadedFilePath); + if (!file.open(QIODevice::WriteOnly)) { + QString error = "Failed to save archive: " + file.errorString(); + currentDownload->deleteLater(); + currentDownload = nullptr; + SetStatus(Status::Error, error); + emit InstallationFailed(error); + return; + } + + QByteArray data = currentDownload->readAll(); + if (data.isEmpty()) { + QString error = "Downloaded file is empty (0 bytes). Check network connection."; + file.close(); + currentDownload->deleteLater(); + currentDownload = nullptr; + SetStatus(Status::Error, error); + emit InstallationFailed(error); + return; + } + + file.write(data); + file.close(); + + qDebug() << "Downloaded Python archive:" << downloadedFilePath + << "Size:" << (data.size() / 1024 / 1024) << "MB"; + + currentDownload->deleteLater(); + currentDownload = nullptr; + + SetProgress(50, "Download complete. Extracting Python..."); + + // Extract Python from archive + if (!ExtractPythonArchive(downloadedFilePath)) { + QString error = "Failed to extract Python"; + SetStatus(Status::Error, error); + emit InstallationFailed(error); + return; + } + + SetProgress(100, "Python installation complete"); + SetStatus(Status::Installed, "Python installed successfully"); + emit PythonInstalled(); + + // Clean up downloaded file + QFile::remove(downloadedFilePath); +} + +bool PythonManager::ExtractPythonArchive(const QString &archivePath) { + // Extract .tar.gz archive using tar command + // Python Standalone Builds have structure: python/install/... + QString tempDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + QString extractDir = tempDir + "/python_extract"; + + // Clean up any previous extraction + QDir(extractDir).removeRecursively(); + QDir().mkpath(extractDir); + + // Extract tar.gz + QProcess process; + process.start("tar", QStringList() + << "-xzf" + << archivePath + << "-C" + << extractDir); + + if (!process.waitForFinished(120000)) { // 2 minutes timeout + qWarning() << "tar extraction timeout"; + return false; + } + + if (process.exitCode() != 0) { + qWarning() << "tar extraction failed:" << process.readAllStandardError(); + return false; + } + + // Python Standalone Builds extract to: python/ + QString extractedPython = extractDir + "/python"; + if (!QDir(extractedPython).exists()) { + qWarning() << "Extracted Python not found at:" << extractedPython; + return false; + } + + // Move to final location + QString targetPath = GetPythonFrameworkPath(); + QString targetParent = QFileInfo(targetPath).path(); + QDir().mkpath(targetParent); + + // Remove existing Python.framework if it exists + QDir(targetPath).removeRecursively(); + + // Try to rename (fast when same filesystem). If rename fails (different filesystems), + // fall back to recursive copy. + if (QFile::rename(extractedPython, targetPath)) { + QDir(extractDir).removeRecursively(); + qDebug() << "Python extracted successfully to:" << targetPath; + return true; + } + + qWarning() << "Rename failed; attempting recursive copy to:" << targetPath; + // Attempt recursive copy as fallback + if (!CopyDirectoryRecursively(extractedPython, targetPath)) { + qWarning() << "Failed to copy extracted Python to:" << targetPath; + return false; + } + + // Clean up extraction directory + QDir(extractDir).removeRecursively(); + qDebug() << "Python extracted successfully to (copied):" << targetPath; + return true; +} + +void PythonManager::InstallPackages() { + QString pythonPath = GetPythonPath(); + if (pythonPath.isEmpty()) { + QString error = "Python not installed"; + SetStatus(Status::Error, error); + emit InstallationFailed(error); + return; + } + + QString requirementsPath = GetRequirementsPath(); + if (!QFile::exists(requirementsPath)) { + QString error = "requirements.txt not found"; + SetStatus(Status::Error, error); + emit InstallationFailed(error); + return; + } + + SetStatus(Status::Installing, "Installing Python packages..."); + SetProgress(0, "Installing packages from requirements.txt..."); + + // Install packages using pip + installProcess = new QProcess(this); + connect(installProcess, QOverload::of(&QProcess::finished), + this, &PythonManager::OnInstallProcessFinished); + connect(installProcess, &QProcess::readyReadStandardOutput, + this, &PythonManager::OnInstallProcessOutput); + connect(installProcess, &QProcess::readyReadStandardError, + this, &PythonManager::OnInstallProcessOutput); + + // Set working directory to /tmp to avoid macOS permission issues + installProcess->setWorkingDirectory(QStandardPaths::writableLocation(QStandardPaths::TempLocation)); + + // Merge stdout and stderr for better progress tracking + installProcess->setProcessChannelMode(QProcess::MergedChannels); + + installProcess->start(pythonPath, QStringList() + << "-m" << "pip" << "install" + << "-r" << requirementsPath + << "--no-cache-dir" + << "--progress-bar" << "on"); +} + +void PythonManager::OnInstallProcessOutput() { + if (!installProcess) return; + + QString output = installProcess->readAllStandardOutput(); + QStringList lines = output.split('\n', Qt::SkipEmptyParts); + + for (const QString &line : lines) { + // Parse pip progress: "Downloading package-name (X.X MB)" + // or "Installing collected packages: ..." + if (line.contains("Downloading") || line.contains("Installing")) { + // Simple progress estimation based on output + static int packageCount = 0; + packageCount++; + int progress = qMin(90, packageCount * 10); // Cap at 90% until finished + SetProgress(progress, line.trimmed()); + } + } +} + +void PythonManager::OnInstallProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) { + if (exitStatus != QProcess::NormalExit || exitCode != 0) { + QString error = "Package installation failed: " + installProcess->readAll(); + SetStatus(Status::Error, error); + emit InstallationFailed(error); + installProcess->deleteLater(); + installProcess = nullptr; + return; + } + + // Pip install succeeded + // Note: BMF Python bindings are loaded via PYTHONPATH in transcoder_bmf.cpp + // from app bundle's Resources/bmf directory, so no need to copy them here + SetProgress(100, "All packages installed successfully"); + SetStatus(Status::Installed, "Python and packages ready"); + emit PackagesInstalled(); + + installProcess->deleteLater(); + installProcess = nullptr; +} + +void PythonManager::CancelInstallation() { + if (currentDownload) { + currentDownload->abort(); + } + if (installProcess) { + installProcess->kill(); + } + SetStatus(Status::NotInstalled, "Installation cancelled"); +} + +bool PythonManager::CopyDirectoryRecursively(const QString &source, const QString &destination) { + QDir sourceDir(source); + if (!sourceDir.exists()) { + return false; + } + + QDir destDir(destination); + if (!destDir.exists()) { + destDir.mkpath("."); + } + + // Copy all files + QFileInfoList entries = sourceDir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + for (const QFileInfo &entry : entries) { + QString srcPath = entry.absoluteFilePath(); + QString dstPath = destination + "/" + entry.fileName(); + + if (entry.isDir()) { + // Recursively copy subdirectory + if (!CopyDirectoryRecursively(srcPath, dstPath)) { + return false; + } + } else { + // Copy file + if (!QFile::copy(srcPath, dstPath)) { + qWarning() << "Failed to copy file:" << srcPath << "to" << dstPath; + return false; + } + } + } + + return true; +} diff --git a/src/builder/src/transcode_page.cpp b/src/builder/src/transcode_page.cpp index bc80d82f..6fb5ec23 100644 --- a/src/builder/src/transcode_page.cpp +++ b/src/builder/src/transcode_page.cpp @@ -72,7 +72,7 @@ void TranscodePage::SetupUI() { tr("Input File"), FileSelectorWidget::InputFile, tr("Select a media file or click Batch for multiple files..."), - tr("Media Files (*.mp4 *.avi *.mkv *.mov *.flv *.wmv *.webm *.ts *.m4v);;All Files (*.*)"), + tr("All Files (*.*)"), tr("Select Media File"), this ); @@ -159,7 +159,7 @@ void TranscodePage::SetupUI() { formatLabel = new QLabel(tr("Format:"), formatGroupBox); formatComboBox = new QComboBox(formatGroupBox); - formatComboBox->addItems({"mp4", "mkv", "avi", "mov", "flv", "webm", "ts"}); + formatComboBox->addItems({"mp4", "mkv", "avi", "mov", "flv", "webm", "ts", "jpg", "png"}); formatComboBox->setCurrentText("mp4"); connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &TranscodePage::OnFormatChanged); diff --git a/src/common/include/encode_parameter.h b/src/common/include/encode_parameter.h index a2264f51..4d01879c 100644 --- a/src/common/include/encode_parameter.h +++ b/src/common/include/encode_parameter.h @@ -21,6 +21,11 @@ #include #include +enum class AlgoMode { + None, + Upscale, +}; + class EncodeParameter { private: bool available; @@ -42,6 +47,10 @@ class EncodeParameter { double startTime; // in seconds double endTime; // in seconds + AlgoMode algoMode; + + int upscaleFactor; + public: EncodeParameter(); ~EncodeParameter(); @@ -91,6 +100,14 @@ class EncodeParameter { double get_start_time(); double get_end_time(); + + AlgoMode get_algo_mode(); + + void set_algo_mode(AlgoMode am); + + int get_upscale_factor(); + + void set_upscale_factor(int uf); }; #endif // ENCODEPARAMETER_H diff --git a/src/common/src/encode_parameter.cpp b/src/common/src/encode_parameter.cpp index a5bb8854..a98c7b25 100644 --- a/src/common/src/encode_parameter.cpp +++ b/src/common/src/encode_parameter.cpp @@ -34,6 +34,9 @@ EncodeParameter::EncodeParameter() { startTime = -1.0; endTime = -1.0; + algoMode = AlgoMode::None; + upscaleFactor = 2; + available = false; } @@ -131,4 +134,18 @@ double EncodeParameter::get_start_time() { return startTime; } double EncodeParameter::get_end_time() { return endTime; } +void EncodeParameter::set_algo_mode(AlgoMode am) { + algoMode = am; + available = true; +} + +AlgoMode EncodeParameter::get_algo_mode() { return algoMode; } + +void EncodeParameter::set_upscale_factor(int uf) { + upscaleFactor = uf; + available = true; +} + +int EncodeParameter::get_upscale_factor() { return upscaleFactor; } + EncodeParameter::~EncodeParameter() {} diff --git a/src/component/include/python_install_dialog.h b/src/component/include/python_install_dialog.h new file mode 100644 index 00000000..ebb9a115 --- /dev/null +++ b/src/component/include/python_install_dialog.h @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Jack Lau + * Email: jacklau1222gm@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef PYTHON_INSTALL_DIALOG_H +#define PYTHON_INSTALL_DIALOG_H + +#include +#include +#include +#include +#include +#include "python_manager.h" + +/** + * @brief Dialog for installing Python runtime and packages + * + * Shows when user tries to use AI Processing but Python is not installed. + * Provides user-friendly interface for downloading and installing Python. + */ +class PythonInstallDialog : public QDialog { + Q_OBJECT + +public: + explicit PythonInstallDialog(QWidget *parent = nullptr); + ~PythonInstallDialog(); + + /** + * @brief Check if installation was successful + */ + bool WasSuccessful() const { return installSuccess; } + +private slots: + void OnInstallClicked(); + void OnCancelClicked(); + void OnStatusChanged(PythonManager::Status status); + void OnProgressChanged(int progress, const QString &message); + void OnPythonInstalled(); + void OnPackagesInstalled(); + void OnInstallationFailed(const QString &error); + +private: + void SetupUI(); + void RetranslateUi(); + + PythonManager *pythonManager; + + QLabel *titleLabel; + QLabel *descriptionLabel; + QLabel *statusLabel; + QProgressBar *progressBar; + QPushButton *installButton; + QPushButton *cancelButton; + + bool installSuccess; + bool installingPython; // true = installing Python, false = installing packages +}; + +#endif // PYTHON_INSTALL_DIALOG_H diff --git a/src/component/src/python_install_dialog.cpp b/src/component/src/python_install_dialog.cpp new file mode 100644 index 00000000..a36015fa --- /dev/null +++ b/src/component/src/python_install_dialog.cpp @@ -0,0 +1,211 @@ +/* + * Copyright 2025 Jack Lau + * Email: jacklau1222gm@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "python_install_dialog.h" +#include + +PythonInstallDialog::PythonInstallDialog(QWidget *parent) + : QDialog(parent) + , pythonManager(new PythonManager(this)) + , installSuccess(false) + , installingPython(true) +{ + SetupUI(); + RetranslateUi(); + + // Connect signals + connect(pythonManager, &PythonManager::StatusChanged, + this, &PythonInstallDialog::OnStatusChanged); + connect(pythonManager, &PythonManager::ProgressChanged, + this, &PythonInstallDialog::OnProgressChanged); + connect(pythonManager, &PythonManager::PythonInstalled, + this, &PythonInstallDialog::OnPythonInstalled); + connect(pythonManager, &PythonManager::PackagesInstalled, + this, &PythonInstallDialog::OnPackagesInstalled); + connect(pythonManager, &PythonManager::InstallationFailed, + this, &PythonInstallDialog::OnInstallationFailed); + + connect(installButton, &QPushButton::clicked, + this, &PythonInstallDialog::OnInstallClicked); + connect(cancelButton, &QPushButton::clicked, + this, &PythonInstallDialog::OnCancelClicked); +} + +PythonInstallDialog::~PythonInstallDialog() { +} + +void PythonInstallDialog::SetupUI() { + setWindowTitle("Install Python Runtime"); + setMinimumWidth(500); + setModal(true); + + QVBoxLayout *mainLayout = new QVBoxLayout(this); + mainLayout->setSpacing(15); + mainLayout->setContentsMargins(20, 20, 20, 20); + + // Title + titleLabel = new QLabel(this); + QFont titleFont = titleLabel->font(); + titleFont.setPointSize(16); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + mainLayout->addWidget(titleLabel); + + // Description + descriptionLabel = new QLabel(this); + descriptionLabel->setWordWrap(true); + mainLayout->addWidget(descriptionLabel); + + // Status label + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + mainLayout->addWidget(statusLabel); + + // Progress bar + progressBar = new QProgressBar(this); + progressBar->setRange(0, 100); + progressBar->setValue(0); + progressBar->setVisible(false); + mainLayout->addWidget(progressBar); + + mainLayout->addStretch(); + + // Buttons + QHBoxLayout *buttonLayout = new QHBoxLayout(); + buttonLayout->addStretch(); + + installButton = new QPushButton(this); + installButton->setDefault(true); + buttonLayout->addWidget(installButton); + + cancelButton = new QPushButton(this); + buttonLayout->addWidget(cancelButton); + + mainLayout->addLayout(buttonLayout); +} + +void PythonInstallDialog::RetranslateUi() { + titleLabel->setText(tr("AI Processing Setup")); + + descriptionLabel->setText(tr( + "AI Processing requires Python 3.9 and additional packages (PyTorch, BasicSR, Real-ESRGAN).\n\n" + "This will download and install:\n" + "• Python 3.9 runtime (~18 MB)\n" + "• AI processing packages (~500 MB)\n\n" + "The installation is completely isolated and will not affect your system Python." + )); + + statusLabel->setText(tr("Ready to install")); + installButton->setText(tr("Install")); + cancelButton->setText(tr("Cancel")); +} + +void PythonInstallDialog::OnInstallClicked() { + installButton->setEnabled(false); + progressBar->setVisible(true); + + if (installingPython) { + pythonManager->InstallPython(); + } else { + pythonManager->InstallPackages(); + } +} + +void PythonInstallDialog::OnCancelClicked() { + if (pythonManager->GetStatus() == PythonManager::Status::Installing) { + QMessageBox::StandardButton reply = QMessageBox::question( + this, + tr("Cancel Installation"), + tr("Are you sure you want to cancel the installation?"), + QMessageBox::Yes | QMessageBox::No + ); + + if (reply == QMessageBox::Yes) { + pythonManager->CancelInstallation(); + reject(); + } + } else { + reject(); + } +} + +void PythonInstallDialog::OnStatusChanged(PythonManager::Status status) { + switch (status) { + case PythonManager::Status::NotInstalled: + statusLabel->setText(tr("Not installed")); + installButton->setEnabled(true); + break; + + case PythonManager::Status::Installing: + statusLabel->setText(tr("Installing...")); + installButton->setEnabled(false); + cancelButton->setText(tr("Cancel")); + break; + + case PythonManager::Status::Installed: + statusLabel->setText(tr("Installation complete!")); + installButton->setEnabled(false); + cancelButton->setText(tr("Close")); + break; + + case PythonManager::Status::Error: + statusLabel->setText(tr("Installation failed")); + installButton->setEnabled(true); + installButton->setText(tr("Retry")); + break; + } +} + +void PythonInstallDialog::OnProgressChanged(int progress, const QString &message) { + progressBar->setValue(progress); + statusLabel->setText(message); +} + +void PythonInstallDialog::OnPythonInstalled() { + // Python installed, now install packages + installingPython = false; + statusLabel->setText(tr("Python installed. Installing packages...")); + pythonManager->InstallPackages(); +} + +void PythonInstallDialog::OnPackagesInstalled() { + installSuccess = true; + progressBar->setValue(100); + statusLabel->setText(tr("Installation complete! AI Processing is now ready.")); + + QMessageBox::information( + this, + tr("Success"), + tr("Python and all required packages have been installed successfully.\n\n" + "You can now use AI Processing features.") + ); + + accept(); +} + +void PythonInstallDialog::OnInstallationFailed(const QString &error) { + progressBar->setVisible(false); + installButton->setEnabled(true); + installButton->setText(tr("Retry")); + + QMessageBox::critical( + this, + tr("Installation Failed"), + tr("Failed to install Python runtime:\n\n%1\n\n" + "Please check your internet connection and try again.").arg(error) + ); +} diff --git a/src/engine/src/converter.cpp b/src/engine/src/converter.cpp index 4e54fa21..79e2b25e 100644 --- a/src/engine/src/converter.cpp +++ b/src/engine/src/converter.cpp @@ -89,18 +89,6 @@ bool Converter::set_transcoder(std::string transcoderName) { } bool Converter::convert_format(const std::string &src, const std::string &dst) { - if (encodeParameter->get_video_codec_name() == "") { - copyVideo = true; - } else { - copyVideo = false; - } - - if (encodeParameter->get_audio_codec_name() == "") { - copyAudio = true; - } else { - copyAudio = false; - } - return transcoder->transcode(src, dst); } diff --git a/src/main.cpp b/src/main.cpp index 5064d61d..c224d76f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,7 +20,15 @@ static bool is_existing_regular_file(const fs::path &p) { static bool is_valid_output_candidate(const fs::path &p) { if (!p.has_filename()) return false; // reject directory-only paths fs::path parent = p.parent_path(); - if (parent.empty()) parent = fs::current_path(); + if (parent.empty()) { + try { + parent = fs::current_path(); + } catch (const fs::filesystem_error& e) { + // If we can't get current directory, assume current directory is valid + std::cerr << "Warning: Failed to get current directory: " << e.what() << std::endl; + return true; // Assume valid if we can't check + } + } if (fs::exists(p)) return !fs::is_directory(p); // existing file ok (not a dir) return fs::exists(parent) && fs::is_directory(parent); // non-existing file OK if parent dir exists } @@ -38,6 +46,7 @@ void printUsage(const char *programName) { << " -b:a, --bitrate:audio BITRATE Set bitrate for audio codec\n" << " -pix_fmt PIX_FMT Set pixel format for video\n" << " -scale SCALE(w)x(h) Set scale for video (width x height)\n" + << " -upscale FACTOR Enable AI upscaling with factor (e.g., 2, 4) [requires BMF]\n" << " -ss START_TIME Set start time for cutting (format: HH:MM:SS or seconds)\n" << " -to END_TIME Set end time for cutting (format: HH:MM:SS or seconds)\n" << " -t DURATION Set duration for cutting (format: HH:MM:SS or seconds)\n" @@ -164,6 +173,7 @@ bool handleCLI(int argc, char *argv[]) { double startTime = -1.0; double endTime = -1.0; double duration = -1.0; + int upscaleFactor = -1; // Parse command line arguments for (int i = 1; i < argc; i++) { @@ -242,6 +252,14 @@ bool handleCLI(int argc, char *argv[]) { return false; } } + } else if (strcmp(argv[i], "-upscale") == 0) { + if (i + 1 < argc) { + upscaleFactor = std::stoi(argv[++i]); + if (upscaleFactor <= 0) { + std::cerr << "Error: Upscale factor must be positive\n"; + return false; + } + } } else { // positional argument: validate as input (existing) or output (candidate) fs::path p(argv[i]); @@ -300,6 +318,19 @@ bool handleCLI(int argc, char *argv[]) { encodeParam->set_audio_bit_rate(audioBitRate); } + // Handle upscale parameters + if (upscaleFactor > 0) { + encodeParam->set_algo_mode(AlgoMode::Upscale); + encodeParam->set_upscale_factor(upscaleFactor); + std::cout << "AI upscaling enabled with factor: " << upscaleFactor << "\n"; + + // Upscaling requires BMF transcoder + if (transcoderType != "BMF") { + std::cout << "Note: Upscaling requires BMF transcoder. Switching to BMF.\n"; + transcoderType = "BMF"; + } + } + // Handle time parameters with validation if (startTime >= 0.0) { encodeParam->set_start_time(startTime); diff --git a/src/modules/enhance_module.py b/src/modules/enhance_module.py new file mode 100644 index 00000000..7950030d --- /dev/null +++ b/src/modules/enhance_module.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This code is based on the implementation from https://github.com/xinntao/Real-ESRGAN + +from basicsr.archs.rrdbnet_arch import RRDBNet +from basicsr.utils.download_util import load_file_from_url + +from realesrgan import RealESRGANer +from realesrgan.archs.srvgg_arch import SRVGGNetCompact + +from bmf import Module, Log, Timestamp, ProcessResult, LogLevel, Packet, VideoFrame +from bmf.lib._bmf.sdk import ffmpeg + +from bmf import hmp as mp +import numpy as np + +import os + +import torch + +import platform + +def load_model(): + model = SRVGGNetCompact( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_conv=16, + upscale=4, + act_type="prelu", + ) + netscale = 4 + file_url = [ + "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth" + ] + return model, netscale, file_url + + +def prepare_model(model_name, file_url): + # Get the directory where this module is located + ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) + model_path = os.path.join(ROOT_DIR, "weights", model_name + ".pth") + + # Check if model already exists (bundled or previously downloaded) + if not os.path.isfile(model_path): + print(f"Model not found at {model_path}, attempting to download...") + for url in file_url: + # model_path will be updated + model_path = load_file_from_url( + url=url, + model_dir=os.path.join(ROOT_DIR, "weights"), + progress=True, + file_name=None, + ) + else: + print(f"Using bundled model from: {model_path}") + + return model_path + + +class EnhanceModule(Module): + + def __init__(self, node=None, option=None): + self._node = node + if not option: + Log.log_node(LogLevel.ERROR, self._node, "no option") + return + + self.frame_number = 0 + + tile = option.get("tile", 0) + tile_pad = option.get("tile_pad", 10) + pre_pad = option.get("pre_pad", 10) + fp32 = option.get("fp32", False) + gpu_id = option.get("gpu_id", 0) + + self.output_scale = option.get("output_scale", None) + + # Agregar estas líneas para verificar el dispositivo + print("Checking available device...") + if platform.system() == "Darwin" and hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + print("MPS is available - using M1 GPU") + device = torch.device("mps") + else: + print("MPS is not available - auto select by Real-ESRGAN") + device = None + + model, netscale, file_url = load_model() + model_name = "realesr-animevideov3" # x4 VGG-style model (XS size) + model_path = prepare_model(model_name, file_url) + + self.upsampler = RealESRGANer( + scale=netscale, + model_path=model_path, + dni_weight=None, + model=model, + tile=tile, + tile_pad=tile_pad, + pre_pad=pre_pad, + half=not fp32, + gpu_id=gpu_id, + device=device, + ) + + def process(self, task): + output_queue = task.get_outputs().get(0, None) + input_queue = task.get_inputs().get(0, None) + + while not input_queue.empty(): + pkt = input_queue.get() + # process EOS + if pkt.timestamp == Timestamp.EOF: + Log.log_node(LogLevel.INFO, task.get_node(), "Receive EOF") + if output_queue is not None: + output_queue.put(Packet.generate_eof_packet()) + task.timestamp = Timestamp.DONE + return ProcessResult.OK + + video_frame = pkt.get(VideoFrame) + # use ffmpeg + frame = ffmpeg.reformat(video_frame, + "rgb24").frame().plane(0).numpy() + + output, _ = self.upsampler.enhance(frame, self.output_scale) + Log.log_node( + LogLevel.INFO, + self._node, + "enhance output shape: ", + output.shape, + " flags: ", + output.flags, + ) + output = np.ascontiguousarray(output) + rgbformat = mp.PixelInfo(mp.kPF_RGB24) + image = mp.Frame(mp.from_numpy(output), rgbformat) + + output_frame = VideoFrame(image) + Log.log_node(LogLevel.INFO, self._node, "output video frame") + + output_frame.pts = video_frame.pts + output_frame.time_base = video_frame.time_base + output_pkt = Packet(output_frame) + output_pkt.timestamp = pkt.timestamp + if output_queue is not None: + output_queue.put(output_pkt) + if self.callback_ is not None: + self.frame_number += 1 + message = "frame number: " + str(self.frame_number) + self.callback_(0, bytes(message, "utf-8")) + + return ProcessResult.OK diff --git a/src/resources/requirements.txt b/src/resources/requirements.txt new file mode 100644 index 00000000..411fbd6b --- /dev/null +++ b/src/resources/requirements.txt @@ -0,0 +1,12 @@ +# OpenConverter AI Processing Requirements +# Python 3.9 required + +# Core deep learning framework +torchvision<=0.12.0 + +# Image super-resolution +basicsr==1.4.2 +realesrgan==0.3.0 + +# Scientific computing +numpy<2 diff --git a/src/transcoder/include/transcoder_bmf.h b/src/transcoder/include/transcoder_bmf.h index ddc9ced5..73d38b9f 100644 --- a/src/transcoder/include/transcoder_bmf.h +++ b/src/transcoder/include/transcoder_bmf.h @@ -46,6 +46,13 @@ class TranscoderBMF : public Transcoder { nlohmann::json decoder_para; nlohmann::json encoder_para; + + // Helper function to set up Python environment (PYTHONPATH) + // Returns true if setup succeeded, false if App Python is not installed + bool setup_python_environment(); + + // Helper function to get the Python module path + std::string get_python_module_path(); }; #endif // TRANSCODER_BMF_H diff --git a/src/transcoder/src/transcoder_bmf.cpp b/src/transcoder/src/transcoder_bmf.cpp index fa9d8da8..e1643461 100644 --- a/src/transcoder/src/transcoder_bmf.cpp +++ b/src/transcoder/src/transcoder_bmf.cpp @@ -14,6 +14,19 @@ */ #include "../include/transcoder_bmf.h" +#include +#include + +#ifdef ENABLE_GUI +#include +#include +#include +#endif + +#ifdef __APPLE__ +#include +#include +#endif /* Receive pointers from converter */ TranscoderBMF::TranscoderBMF(ProcessParameter *process_parameter, @@ -22,6 +35,265 @@ TranscoderBMF::TranscoderBMF(ProcessParameter *process_parameter, frame_total_number = 0; } +bool TranscoderBMF::setup_python_environment() { + // In Debug mode, use system PYTHONPATH from environment (set by developer/CMake) + // In Release mode, set up PYTHONPATH for bundled BMF and external Python (App Support or Custom) +#ifndef NDEBUG + // Debug mode: Set PYTHONPATH based on BMF_ROOT_PATH from environment or CMake + BMFLOG(BMF_INFO) << "Debug mode: Setting PYTHONPATH from BMF_ROOT_PATH"; + + // Get BMF_ROOT_PATH from environment or CMake + const char* bmf_root_env = std::getenv("BMF_ROOT_PATH"); + std::string bmf_root; + + if (bmf_root_env) { + bmf_root = std::string(bmf_root_env); + BMFLOG(BMF_INFO) << "Using BMF_ROOT_PATH from environment: " << bmf_root; + } +#ifdef BMF_ROOT_PATH_STR + else { + bmf_root = BMF_ROOT_PATH_STR; + BMFLOG(BMF_INFO) << "Using BMF_ROOT_PATH from CMake: " << bmf_root; + } +#endif + + if (!bmf_root.empty()) { + // Normalize BMF_ROOT_PATH to include /output/bmf if needed + if (bmf_root.find("output/bmf") == std::string::npos) { + bmf_root += "/output/bmf"; + } + + // Set PYTHONPATH: BMF_ROOT_PATH/lib:BMF_ROOT_PATH (parent of /output/bmf) + std::string bmf_lib_path = bmf_root + "/lib"; + size_t output_pos = bmf_root.find("/output/bmf"); + std::string bmf_output_path; + if (output_pos != std::string::npos) { + bmf_output_path = bmf_root.substr(0, output_pos) + "/output"; + } else { + bmf_output_path = bmf_root; + } + + // Get existing PYTHONPATH + std::string current_pythonpath; + const char* existing_pythonpath = std::getenv("PYTHONPATH"); + if (existing_pythonpath) { + current_pythonpath = existing_pythonpath; + } + + // Set PYTHONPATH: bmf/lib:bmf/output:existing + std::string new_pythonpath = bmf_lib_path + ":" + bmf_output_path; + if (!current_pythonpath.empty()) { + new_pythonpath += ":" + current_pythonpath; + } + + setenv("PYTHONPATH", new_pythonpath.c_str(), 1); + BMFLOG(BMF_INFO) << "Set PYTHONPATH: " << new_pythonpath; + + // Set BMF_MODULE_CONFIG_PATH + setenv("BMF_MODULE_CONFIG_PATH", bmf_root.c_str(), 1); + BMFLOG(BMF_INFO) << "Set BMF_MODULE_CONFIG_PATH: " << bmf_root; + } else { + BMFLOG(BMF_WARNING) << "BMF_ROOT_PATH not set. Please set it in environment or CMake."; + BMFLOG(BMF_WARNING) << "Example: export BMF_ROOT_PATH=/path/to/bmf"; + } + + return true; // Debug mode always succeeds (uses system Python) +#endif + + // Release mode: Set up PYTHONPATH for bundled BMF libraries and external Python + std::string bmf_lib_path; + std::string bmf_output_path; + std::string bmf_config_path; + std::string python_site_packages; + +#ifdef ENABLE_GUI + // Get Python path from QSettings (default to App Python in Application Support) + QSettings settings("OpenConverter", "OpenConverter"); + QString pythonMode = settings.value("python/mode", "pythonAppSupport").toString(); + + if (pythonMode == "pythonCustom") { + QString customPath = settings.value("python/customPath", "").toString(); + if (!customPath.isEmpty()) { + python_site_packages = customPath.toStdString(); + BMFLOG(BMF_INFO) << "Using custom Python site-packages: " << python_site_packages; + } + } + + // Default to App Python if not custom + if (python_site_packages.empty()) { + QString appSupportDir = QDir::homePath() + "/Library/Application Support/OpenConverter"; + QString pythonHome = appSupportDir + "/Python.framework"; + QString sitePackages = pythonHome + "/lib/python3.9/site-packages"; + + // Check if App Python exists - NO FALLBACK, App Python is required + if (QDir(sitePackages).exists()) { + python_site_packages = sitePackages.toStdString(); + BMFLOG(BMF_INFO) << "Using App Python site-packages: " << python_site_packages; + // PYTHONHOME is set later when we detect bundled Python stdlib in Frameworks/Python + } else { + BMFLOG(BMF_ERROR) << "App Python not found at: " << sitePackages.toStdString(); + BMFLOG(BMF_ERROR) << "Please install App Python via Python menu -> Install Python"; + return false; // Don't continue without App Python - no fallback to system Python + } + } +#endif + +#ifdef __APPLE__ + // Check if running from app bundle + char exe_path[1024]; + uint32_t size = sizeof(exe_path); + if (_NSGetExecutablePath(exe_path, &size) == 0) { + std::string exe_dir = std::string(exe_path); + size_t last_slash = exe_dir.find_last_of('/'); + if (last_slash != std::string::npos) { + exe_dir = exe_dir.substr(0, last_slash); + + // Check if we're in an app bundle (path contains .app/Contents/MacOS) + if (exe_dir.find(".app/Contents/MacOS") != std::string::npos) { + size_t app_pos = exe_dir.find(".app/Contents/MacOS"); + std::string app_bundle = exe_dir.substr(0, app_pos + 4); // Include .app + + // Check if BMF libraries are bundled (Release build) + std::string bundled_bmf_lib = app_bundle + "/Contents/Frameworks/lib"; + std::string bundled_config = app_bundle + "/Contents/Frameworks/BUILTIN_CONFIG.json"; + std::ifstream bmf_check(bundled_config); + + if (bmf_check.good()) { + // BMF libraries are bundled (Release build) + bmf_lib_path = bundled_bmf_lib; + bmf_output_path = app_bundle + "/Contents/Frameworks"; + bmf_config_path = app_bundle + "/Contents/Frameworks"; + BMFLOG(BMF_INFO) << "Using bundled BMF libraries from: " << bmf_lib_path; + + // Set PYTHONHOME to bundled Python stdlib + std::string bundled_python_home = app_bundle + "/Contents/Frameworks/Python"; + std::string bundled_python_stdlib = bundled_python_home + "/lib/python3.9"; + std::ifstream python_check(bundled_python_stdlib + "/os.py"); + if (python_check.good()) { + setenv("PYTHONHOME", bundled_python_home.c_str(), 1); + BMFLOG(BMF_INFO) << "Set PYTHONHOME to bundled Python: " << bundled_python_home; + } + python_check.close(); + } else { + // App bundle exists but BMF not bundled (should not happen in Release) + BMFLOG(BMF_WARNING) << "App bundle detected but BMF not bundled"; + } + bmf_check.close(); + + // Check for bundled BMF Python package in Resources/bmf/ + // The bmf package is at Resources/bmf/, so we add Resources/ to PYTHONPATH + std::string bundled_bmf_python = app_bundle + "/Contents/Resources/bmf"; + std::string resources_dir = app_bundle + "/Contents/Resources"; + std::ifstream bmf_python_check(bundled_bmf_python + "/__init__.py"); + if (bmf_python_check.good()) { + // Add Resources directory to bmf_output_path so 'import bmf' finds Resources/bmf/ + if (!bmf_output_path.empty()) { + bmf_output_path = resources_dir + ":" + bmf_output_path; + } else { + bmf_output_path = resources_dir; + } + BMFLOG(BMF_INFO) << "Found bundled BMF Python package at: " << bundled_bmf_python; + BMFLOG(BMF_INFO) << "Added to PYTHONPATH: " << resources_dir; + } + bmf_python_check.close(); + } + } + } +#endif + + // Add Python site-packages to PYTHONPATH (App Python or Custom) + if (!python_site_packages.empty()) { + if (!bmf_output_path.empty()) { + bmf_output_path = python_site_packages + ":" + bmf_output_path; + } else { + bmf_output_path = python_site_packages; + } + } + + // Build PYTHONPATH - in Release mode, do NOT append existing PYTHONPATH + // to avoid conflicts with development paths + std::string new_pythonpath = bmf_lib_path + ":" + bmf_output_path; + + // Set PYTHONPATH environment variable (overwrite, don't append) + setenv("PYTHONPATH", new_pythonpath.c_str(), 1); + BMFLOG(BMF_INFO) << "Set PYTHONPATH: " << new_pythonpath; + + // Set BMF_MODULE_CONFIG_PATH to point to BUILTIN_CONFIG.json + setenv("BMF_MODULE_CONFIG_PATH", bmf_config_path.c_str(), 1); + BMFLOG(BMF_INFO) << "Set BMF_MODULE_CONFIG_PATH: " << bmf_config_path; + + return true; // Environment setup succeeded +} + +std::string TranscoderBMF::get_python_module_path() { + std::string module_path; + +#ifdef __APPLE__ + // For macOS app bundle + char exe_path[1024]; + uint32_t size = sizeof(exe_path); + if (_NSGetExecutablePath(exe_path, &size) == 0) { + char *real_path = realpath(exe_path, nullptr); + if (real_path) { + std::filesystem::path exe_dir = std::filesystem::path(real_path).parent_path(); + free(real_path); + + // Check if running from app bundle + // Path structure: OpenConverter.app/Contents/MacOS/OpenConverter + if (exe_dir.filename() == "MacOS") { + std::filesystem::path resources_dir = exe_dir.parent_path() / "Resources" / "modules"; + if (std::filesystem::exists(resources_dir)) { + module_path = resources_dir.string(); + BMFLOG(BMF_INFO) << "Using app bundle module path: " << module_path; + return module_path; + } + } + + // Check build directory (for development) + std::filesystem::path build_modules = exe_dir / "modules"; + if (std::filesystem::exists(build_modules)) { + module_path = build_modules.string(); + BMFLOG(BMF_INFO) << "Using build directory module path: " << module_path; + return module_path; + } + + // Check parent directory (for CLI build) + std::filesystem::path parent_modules = exe_dir.parent_path() / "modules"; + if (std::filesystem::exists(parent_modules)) { + module_path = parent_modules.string(); + BMFLOG(BMF_INFO) << "Using parent directory module path: " << module_path; + return module_path; + } + } + } +#else + // For Linux/Windows + // Try current directory first + try { + std::filesystem::path current_modules = std::filesystem::current_path() / "modules"; + if (std::filesystem::exists(current_modules)) { + module_path = current_modules.string(); + BMFLOG(BMF_INFO) << "Using current directory module path: " << module_path; + return module_path; + } + } catch (const std::filesystem::filesystem_error& e) { + BMFLOG(BMF_WARNING) << "Failed to get current directory: " << e.what(); + } +#endif + + // Fallback: use a safe default path + try { + module_path = std::filesystem::current_path().string(); + BMFLOG(BMF_WARNING) << "Module path not found, using current directory: " << module_path; + } catch (const std::filesystem::filesystem_error& e) { + // If we can't get current directory, use /tmp as last resort + module_path = "/tmp"; + BMFLOG(BMF_ERROR) << "Failed to get current directory: " << e.what(); + BMFLOG(BMF_ERROR) << "Using fallback path: " << module_path; + } + return module_path; +} + bmf_sdk::CBytes TranscoderBMF::decoder_callback(bmf_sdk::CBytes input) { std::string str_info; str_info.assign(reinterpret_cast(input.buffer), input.size); @@ -60,7 +332,7 @@ bmf_sdk::CBytes TranscoderBMF::encoder_callback(bmf_sdk::CBytes input) { } } else { - BMFLOG(BMF_WARNING) << "Failed to extract frame number"; + BMFLOG(BMF_WARNING) << "Failed to extract frame number from: " << str_info; } uint8_t bytes[] = {97, 98, 99, 100, 101, 0}; @@ -70,13 +342,13 @@ bmf_sdk::CBytes TranscoderBMF::encoder_callback(bmf_sdk::CBytes input) { bool TranscoderBMF::prepare_info(std::string input_path, std::string output_path) { // decoder init - if (encode_parameter->get_video_codec_name() == "") { + if (encode_parameter->get_video_codec_name() == "copy") { copy_video = true; } else { copy_video = false; } - if (encode_parameter->get_audio_codec_name() == "") { + if (encode_parameter->get_audio_codec_name() == "copy") { copy_audio = true; } else { copy_audio = false; @@ -103,7 +375,11 @@ bool TranscoderBMF::prepare_info(std::string input_path, nlohmann::json video_params = nlohmann::json::object(); // Always add codec and bitrate - video_params["codec"] = encode_parameter->get_video_codec_name(); + std::string video_codec_name = encode_parameter->get_video_codec_name(); + if (!video_codec_name.empty()) + video_params["codec"] = video_codec_name; + else + video_params["codec"] = "libx264"; video_params["bit_rate"] = encode_parameter->get_video_bit_rate(); // Only add width if it's set (> 0) @@ -132,7 +408,11 @@ bool TranscoderBMF::prepare_info(std::string input_path, // Build audio_params object nlohmann::json audio_params = nlohmann::json::object(); - audio_params["codec"] = encode_parameter->get_audio_codec_name(); + std::string audio_codec_name = encode_parameter->get_audio_codec_name(); + if (!audio_codec_name.empty()) + audio_params["codec"] = audio_codec_name; + else + audio_params["codec"] = "aac"; audio_params["bit_rate"] = encode_parameter->get_audio_bit_rate(); encoder_para = {{"output_path", output_path}, @@ -142,18 +422,93 @@ bool TranscoderBMF::prepare_info(std::string input_path, } bool TranscoderBMF::transcode(std::string input_path, std::string output_path) { + // Set up Python environment (PYTHONPATH) for BMF Python modules + // Returns false if App Python is not installed (no fallback to system Python) + if (!setup_python_environment()) { + BMFLOG(BMF_ERROR) << "Failed to set up Python environment. Transcoding aborted."; + return false; + } + + // Set a valid working directory to prevent BMF's internal getcwd() calls from failing + // When app is launched from Finder, there's no valid current working directory + try { + std::filesystem::current_path(); // Test if current path is valid + } catch (const std::filesystem::filesystem_error& e) { + // Current directory is invalid, set to a safe location + try { +#ifdef __APPLE__ + // Try to use app bundle's Resources directory + char exe_path[1024]; + uint32_t size = sizeof(exe_path); + if (_NSGetExecutablePath(exe_path, &size) == 0) { + char *real_path = realpath(exe_path, nullptr); + if (real_path) { + std::filesystem::path exe_dir = std::filesystem::path(real_path).parent_path(); + free(real_path); + + // If in app bundle, use Resources directory + if (exe_dir.filename() == "MacOS") { + std::filesystem::path resources_dir = exe_dir.parent_path() / "Resources"; + if (std::filesystem::exists(resources_dir)) { + std::filesystem::current_path(resources_dir); + BMFLOG(BMF_INFO) << "Set working directory to: " << resources_dir.string(); + } else { + // Fallback to /tmp + std::filesystem::current_path("/tmp"); + BMFLOG(BMF_INFO) << "Set working directory to: /tmp"; + } + } else { + // Not in app bundle, use executable directory + std::filesystem::current_path(exe_dir); + BMFLOG(BMF_INFO) << "Set working directory to: " << exe_dir.string(); + } + } + } +#else + // For Linux/Windows, use /tmp or C:\Temp + std::filesystem::current_path("/tmp"); + BMFLOG(BMF_INFO) << "Set working directory to: /tmp"; +#endif + } catch (const std::filesystem::filesystem_error& e2) { + BMFLOG(BMF_ERROR) << "Failed to set working directory: " << e2.what(); + // Continue anyway, BMF might still work + } + } prepare_info(input_path, output_path); int scheduler_cnt = 0; + AlgoMode algo_mode = encode_parameter->get_algo_mode(); + bmf::builder::Node *algo_node = nullptr; auto graph = bmf::builder::Graph(bmf::builder::NormalMode); auto decoder = - graph.Decode(bmf_sdk::JsonParam(decoder_para), "", scheduler_cnt++); + graph.Decode(bmf_sdk::JsonParam(decoder_para), "", scheduler_cnt); + + if (algo_mode == AlgoMode::Upscale) { + int upscale_factor = encode_parameter->get_upscale_factor(); + std::string module_path = get_python_module_path(); + + nlohmann::json enhance_option = { + {"fp32", true}, + {"output_scale", upscale_factor}, + }; + + BMFLOG(BMF_INFO) << "Loading enhance module from: " << module_path; + + algo_node = new bmf::builder::Node( + graph.Module({decoder["video"]}, "", bmf::builder::Python, + bmf_sdk::JsonParam(enhance_option), "", + module_path, + "enhance_module.EnhanceModule", + bmf::builder::Immediate, + scheduler_cnt)); + } auto encoder = - graph.Encode(decoder["video"], decoder["audio"], - bmf_sdk::JsonParam(encoder_para), "", scheduler_cnt++); + graph.Encode(algo_mode == AlgoMode::Upscale ? *algo_node : decoder["video"], + decoder["audio"], + bmf_sdk::JsonParam(encoder_para), "", scheduler_cnt); auto de_callback = std::bind(&TranscoderBMF::decoder_callback, this, std::placeholders::_1); @@ -162,15 +517,23 @@ bool TranscoderBMF::transcode(std::string input_path, std::string output_path) { decoder.AddCallback( 0, std::function(de_callback)); - encoder.AddCallback( - 0, std::function(en_callback)); + if (algo_mode != AlgoMode::None) { + algo_node->AddCallback( + 0, std::function(en_callback)); + } else { + encoder.AddCallback( + 0, std::function(en_callback)); + } nlohmann::json graph_para = {{"dump_graph", 1}}; graph.SetOption(bmf_sdk::JsonParam(graph_para)); - if (graph.Run() == 0) { - return true; - } else { - return false; + int result = graph.Run(); + + // Clean up allocated memory + if (algo_node != nullptr) { + delete algo_node; } + + return (result == 0); } diff --git a/src/transcoder/src/transcoder_fftool.cpp b/src/transcoder/src/transcoder_fftool.cpp index 6cec14a1..eab73915 100644 --- a/src/transcoder/src/transcoder_fftool.cpp +++ b/src/transcoder/src/transcoder_fftool.cpp @@ -53,13 +53,13 @@ std::string escape_windows_path(const std::string &path) { bool TranscoderFFTool::prepared_opt() { - if (encode_parameter->get_video_codec_name() == "") { + if (encode_parameter->get_video_codec_name() == "copy") { copy_video = true; } else { copy_video = false; } - if (encode_parameter->get_audio_codec_name() == "") { + if (encode_parameter->get_audio_codec_name() == "copy") { copy_audio = true; } else { copy_audio = false; diff --git a/tool/download_models.sh b/tool/download_models.sh new file mode 100755 index 00000000..83798f17 --- /dev/null +++ b/tool/download_models.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Copyright 2025 Jack Lau +# Email: jacklau1222gm@gmail.com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Script to download AI model weights for OpenConverter +# This script is called during the build process to download required model files + +set -e # Exit on error + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WEIGHTS_DIR="$PROJECT_ROOT/src/modules/weights" + +# Create weights directory if it doesn't exist +mkdir -p "$WEIGHTS_DIR" + +echo "=========================================" +echo "Downloading AI Model Weights" +echo "=========================================" + +# Define models as separate arrays (compatible with older bash versions) +MODEL_NAME="realesr-animevideov3.pth" +MODEL_URL="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth" +MODEL_PATH="$WEIGHTS_DIR/$MODEL_NAME" + +if [ -f "$MODEL_PATH" ]; then + echo "✓ $MODEL_NAME already exists, skipping download" +else + echo "⬇ Downloading $MODEL_NAME..." + if command -v curl &> /dev/null; then + curl -L -o "$MODEL_PATH" "$MODEL_URL" + elif command -v wget &> /dev/null; then + wget -O "$MODEL_PATH" "$MODEL_URL" + else + echo "Error: Neither curl nor wget is available. Please install one of them." + exit 1 + fi + + if [ -f "$MODEL_PATH" ]; then + echo "✓ Successfully downloaded $MODEL_NAME" + else + echo "✗ Failed to download $MODEL_NAME" + exit 1 + fi +fi + +echo "=========================================" +echo "All model weights are ready!" +echo "=========================================" diff --git a/tool/fix_macos_libs.sh b/tool/fix_macos_libs.sh index 82fd48ee..8204450b 100755 --- a/tool/fix_macos_libs.sh +++ b/tool/fix_macos_libs.sh @@ -14,14 +14,25 @@ NC='\033[0m' # No Color echo -e "${GREEN}=== OpenConverter macOS Library Fixer ===${NC}" -# Check if app bundle exists -if [ ! -d "build/OpenConverter.app" ]; then - echo -e "${RED}Error: OpenConverter.app not found in build/ directory${NC}" +# Auto-detect build directory +BUILD_DIR="" +if [ -d "build-release/OpenConverter.app" ]; then + BUILD_DIR="build-release" +elif [ -d "build/OpenConverter.app" ]; then + BUILD_DIR="build" +elif [ -d "../build-release/OpenConverter.app" ]; then + BUILD_DIR="../build-release" +elif [ -d "../build/OpenConverter.app" ]; then + BUILD_DIR="../build" +else + echo -e "${RED}Error: OpenConverter.app not found${NC}" + echo "Searched in: build-release/, build/, ../build-release/, ../build/" echo "Please build the app first with: cd src && cmake -B build && cd build && make" exit 1 fi -cd build +echo -e "${GREEN}Found app bundle in: $BUILD_DIR${NC}" +cd "$BUILD_DIR" APP_DIR="OpenConverter.app" APP_FRAMEWORKS="$APP_DIR/Contents/Frameworks" @@ -48,6 +59,101 @@ fi FFMPEG_LIB_DIR="$FFMPEG_PREFIX/lib" +echo -e "${YELLOW}Step 2.5: Bundling BMF libraries (if available)...${NC}" +# Check if BMF_ROOT_PATH is set +if [ -n "$BMF_ROOT_PATH" ]; then + # If BMF_ROOT_PATH doesn't end with /output/bmf, append it (same logic as CMake) + if [[ ! "$BMF_ROOT_PATH" =~ output/bmf$ ]]; then + BMF_ROOT_PATH="$BMF_ROOT_PATH/output/bmf" + fi + + if [ -d "$BMF_ROOT_PATH" ]; then + echo -e "${GREEN}Found BMF at: $BMF_ROOT_PATH${NC}" + + # Create lib/ subdirectory for builtin modules (BMF hardcoded path) + mkdir -p "$APP_FRAMEWORKS/lib" + else + echo -e "${YELLOW}BMF_ROOT_PATH set but directory not found: $BMF_ROOT_PATH${NC}" + echo " Skipping BMF bundling" + BMF_ROOT_PATH="" + fi +else + echo -e "${YELLOW}BMF_ROOT_PATH not set, skipping BMF bundling${NC}" + echo " To bundle BMF libraries, set: export BMF_ROOT_PATH=/path/to/bmf" +fi + +if [ -n "$BMF_ROOT_PATH" ] && [ -d "$BMF_ROOT_PATH" ]; then + + # Copy builtin modules to lib/ subdirectory + echo " Copying BMF builtin modules to Frameworks/lib/..." + for module in libbuiltin_modules.dylib libcopy_module.dylib libcvtcolor.dylib; do + if [ -f "$BMF_ROOT_PATH/lib/$module" ]; then + cp "$BMF_ROOT_PATH/lib/$module" "$APP_FRAMEWORKS/lib/" 2>/dev/null || true + chmod +w "$APP_FRAMEWORKS/lib/$module" 2>/dev/null || true + echo " Copied: $module" + fi + done + + # Copy other BMF libraries to Frameworks/ + echo " Copying BMF core libraries to Frameworks/..." + for lib in libbmf_py_loader.dylib libbmf_module_sdk.dylib libengine.dylib libhmp.dylib _bmf.cpython-39-darwin.so _hmp.cpython-39-darwin.so; do + if [ -f "$BMF_ROOT_PATH/lib/$lib" ]; then + cp "$BMF_ROOT_PATH/lib/$lib" "$APP_FRAMEWORKS/" 2>/dev/null || true + chmod +w "$APP_FRAMEWORKS/$lib" 2>/dev/null || true + echo " Copied: $lib" + fi + done + + # Copy BMF config + if [ -f "$BMF_ROOT_PATH/BUILTIN_CONFIG.json" ]; then + cp "$BMF_ROOT_PATH/BUILTIN_CONFIG.json" "$APP_FRAMEWORKS/" 2>/dev/null || true + echo " Copied: BUILTIN_CONFIG.json" + fi + + # Bundle BMF Python package to Resources/bmf/ (named 'bmf' for Python import) + echo " Copying BMF Python package to Resources/bmf/..." + rm -rf "$APP_DIR/Contents/Resources/bmf" + cp -R "$BMF_ROOT_PATH" "$APP_DIR/Contents/Resources/bmf" 2>/dev/null || true + echo " Copied BMF Python package as 'bmf'" + + echo -e "${GREEN}BMF libraries bundled successfully${NC}" +fi + +# Bundle Homebrew Python stdlib (required for embedded Python in BMF) +echo -e "${YELLOW}Step 2.6: Bundling Python stdlib from Homebrew...${NC}" +PYTHON_PREFIX=$(brew --prefix python@3.9 2>/dev/null || echo "") +if [ -n "$PYTHON_PREFIX" ]; then + PYTHON_FRAMEWORK="$PYTHON_PREFIX/Frameworks/Python.framework/Versions/3.9" + PYTHON_STDLIB="$PYTHON_FRAMEWORK/lib/python3.9" + PYTHON_LIB="$PYTHON_FRAMEWORK/Python" + + if [ -d "$PYTHON_STDLIB" ] && [ -f "$PYTHON_LIB" ]; then + # Create Python directory structure in Frameworks + mkdir -p "$APP_FRAMEWORKS/Python/lib" + + # Copy libpython + echo " Copying libpython..." + cp "$PYTHON_LIB" "$APP_FRAMEWORKS/Python/lib/libpython3.9.dylib" 2>/dev/null || true + chmod +w "$APP_FRAMEWORKS/Python/lib/libpython3.9.dylib" 2>/dev/null || true + + # Copy Python stdlib (excluding site-packages and test directories to save space) + echo " Copying Python stdlib (this may take a moment)..." + mkdir -p "$APP_FRAMEWORKS/Python/lib/python3.9" + rsync -a --exclude='site-packages' --exclude='test' --exclude='tests' --exclude='__pycache__' \ + "$PYTHON_STDLIB/" "$APP_FRAMEWORKS/Python/lib/python3.9/" 2>/dev/null || true + + # Create empty site-packages directory + mkdir -p "$APP_FRAMEWORKS/Python/lib/python3.9/site-packages" + + echo -e "${GREEN}Python stdlib bundled successfully${NC}" + echo " Size: $(du -sh "$APP_FRAMEWORKS/Python" | cut -f1)" + else + echo -e "${RED}Warning: Homebrew Python 3.9 not found at expected location${NC}" + fi +else + echo -e "${RED}Warning: Homebrew Python 3.9 not installed${NC}" +fi + echo -e "${YELLOW}Step 3: Checking if dylibbundler is available...${NC}" if ! command -v dylibbundler &> /dev/null; then echo -e "${YELLOW}dylibbundler not found, installing via Homebrew...${NC}" @@ -71,12 +177,21 @@ copy_lib_if_needed() { return 1 } +# Pre-bundle libavdevice which is needed by BMF's libbuiltin_modules but not bundled by macdeployqt +if [ -f "$FFMPEG_LIB_DIR/libavdevice.59.dylib" ]; then + if [ ! -f "$APP_FRAMEWORKS/libavdevice.59.dylib" ]; then + echo " Pre-copying libavdevice.59.dylib (needed by BMF builtin modules)" + cp "$FFMPEG_LIB_DIR/libavdevice.59.dylib" "$APP_FRAMEWORKS/" + chmod +w "$APP_FRAMEWORKS/libavdevice.59.dylib" + fi +fi + # Iterate multiple times to catch transitive dependencies for iteration in 1 2 3; do echo "Pass $iteration: Scanning for missing dependencies..." - # Get all dylib files in Frameworks folder - ALL_LIBS=$(find "$APP_FRAMEWORKS" -name "*.dylib" -type f) + # Get all dylib and .so files in Frameworks folder (including BMF Python modules) + ALL_LIBS=$(find "$APP_FRAMEWORKS" -type f \( -name "*.dylib" -o -name "*.so" \)) new_libs_copied=0 @@ -129,9 +244,9 @@ for iteration in 1 2 3; do new_libs_copied=$((new_libs_copied + 1)) fi elif [[ "$dep" == @rpath/* ]]; then - # @rpath - try Homebrew locations + # @rpath - try Homebrew and BMF locations dep_basename=$(basename "$dep") - # Search in multiple Homebrew locations + # Search in multiple Homebrew and BMF locations search_dirs=( "$FFMPEG_LIB_DIR" "$(brew --prefix)/lib" @@ -141,6 +256,10 @@ for iteration in 1 2 3; do "$(brew --prefix x264 2>/dev/null)/lib" "$(brew --prefix x265 2>/dev/null)/lib" ) + # Add BMF library directories if BMF_ROOT_PATH is set + if [ -n "$BMF_ROOT_PATH" ]; then + search_dirs+=("$BMF_ROOT_PATH/lib") + fi for search_dir in "${search_dirs[@]}"; do if [ -z "$search_dir" ]; then continue @@ -165,7 +284,12 @@ for iteration in 1 2 3; do # Fix the library's own ID if it's an absolute path or @rpath lib_id=$(otool -D "$lib_path" 2>/dev/null | tail -n +2 | head -n 1 || true) if [[ ! -z "$lib_id" ]] && [[ "$lib_id" != @executable_path* ]] && [[ "$lib_id" != /usr/lib* ]] && [[ "$lib_id" != /System* ]]; then - install_name_tool -id "@executable_path/../Frameworks/$lib_name" "$lib_path" 2>/dev/null || true + # For libraries in lib/ subdirectory, use the correct path + if [[ "$lib_path" == *"/Frameworks/lib/"* ]]; then + install_name_tool -id "@executable_path/../Frameworks/lib/$lib_name" "$lib_path" 2>/dev/null || true + else + install_name_tool -id "@executable_path/../Frameworks/$lib_name" "$lib_path" 2>/dev/null || true + fi fi done @@ -179,8 +303,35 @@ done echo "" echo -e "${YELLOW}Step 5: Re-signing the app bundle...${NC}" -codesign --force --deep --sign - "$APP_DIR" 2>&1 || { - echo -e "${RED}Warning: Code signing failed. App may not run.${NC}" + +# Sign all dylibs and .so files individually first +echo " Signing individual libraries..." +find "$APP_FRAMEWORKS" -type f \( -name "*.dylib" -o -name "*.so" \) | while read lib; do + codesign --force --sign - "$lib" 2>/dev/null || true +done + +# Sign all .so files in Python stdlib +if [ -d "$APP_FRAMEWORKS/Python/lib/python3.9/lib-dynload" ]; then + echo " Signing Python stdlib extensions..." + find "$APP_FRAMEWORKS/Python/lib/python3.9/lib-dynload" -type f -name "*.so" | while read lib; do + codesign --force --sign - "$lib" 2>/dev/null || true + done +fi + +# Sign plugins +echo " Signing plugins..." +find "$APP_DIR/Contents/PlugIns" -type f \( -name "*.dylib" -o -name "*.so" \) 2>/dev/null | while read lib; do + codesign --force --sign - "$lib" 2>/dev/null || true +done + +# Sign the main executable +echo " Signing main executable..." +codesign --force --sign - "$APP_EXECUTABLE" 2>/dev/null || true + +# Finally sign the whole app bundle +echo " Signing app bundle..." +codesign --force --sign - "$APP_DIR" 2>&1 || { + echo -e "${RED}Warning: App bundle signing failed, but individual components are signed.${NC}" } echo ""