diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eec88f957..8cd0f11aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,3 +143,77 @@ jobs: if: ${{ steps.coverage.outputs.value }} with: file: build/coverage.info + + # Supplemental native Arm64 presubmit. Its output is never a release + # artifact; GitLab remains the production artifact chain. + build-arm64-presubmit: + runs-on: windows-11-arm + continue-on-error: true + steps: + - name: Preserve repository line endings + run: git config --global core.autocrlf input + + - uses: actions/checkout@v4 + + - name: Checkout OpenShotAudio + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.owner.login || github.repository_owner }}/libopenshot-audio + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + path: audio + + - name: Checkout Catch2 + uses: actions/checkout@v4 + with: + repository: catchorg/Catch2 + ref: v3.8.1 + path: Catch2 + + - uses: msys2/setup-msys2@v2 + with: + msystem: CLANGARM64 + update: true + + - name: Install exact CLANGARM64 package versions + shell: msys2 {0} + run: | + mapfile -t packages < <(sed -n '/^[^#[:space:]][^=]*=/s/,[^,]*$//p' ci/windows-arm64-packages.lock) + pacman --noconfirm -S --needed -- "${packages[@]}" + + - name: Build (CLANGARM64, presubmit only) + shell: msys2 {0} + run: | + cmake -B Catch2/build -S Catch2 -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$PWD/catch2-install" \ + -DCATCH_BUILD_TESTING=OFF \ + -DCATCH_INSTALL_DOCS=OFF + cmake --build Catch2/build + cmake --install Catch2/build + cmake -B audio/build -S audio -G Ninja \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_INSTALL_PREFIX="$PWD/audio/install-arm64" \ + -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_AUDIO_DOCS=OFF + cmake --build audio/build + cmake --install audio/build + cmake -B build -S . -G Ninja \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DUSE_QT6=ON \ + -DOpenShotAudio_ROOT="$PWD/audio/install-arm64" \ + -DCatch2_DIR="$PWD/catch2-install/lib/cmake/Catch2" \ + -DCMAKE_INSTALL_PREFIX="$PWD/install-arm64" \ + -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_LIB_DOCS=OFF + cmake --build build + ctest --test-dir build --output-on-failure -VV + cmake --install build + python -m unittest discover -s ci -p "test_*.py" -v + python ci/validate_arm64_architecture.py \ + --require-native-arm64 \ + --package-lock ci/windows-arm64-packages.lock \ + --payload-root install-arm64 \ + --require-payload \ + --json-report build/arm64-presubmit-report.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4ba22c0be..21bfa0835 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,6 +122,42 @@ windows-builder-x64: tags: - windows +windows-builder-arm64: + stage: build-libopenshot + artifacts: + expire_in: 6 months + paths: + - build\install-arm64\* + - build\arm64-architecture-report.json + script: + - try { Invoke-WebRequest -Uri "https://gitlab.openshot.org/OpenShot/libopenshot-audio/-/jobs/artifacts/$CI_COMMIT_REF_NAME/download?job=windows-builder-arm64" -Headers @{"PRIVATE-TOKEN"="$ACCESS_TOKEN"} -OutFile "artifacts.zip" } catch { $_.Exception.Response.StatusCode.Value__ } + - if (-not (Test-Path "artifacts.zip")) { Invoke-WebRequest -Uri "https://gitlab.openshot.org/OpenShot/libopenshot-audio/-/jobs/artifacts/develop/download?job=windows-builder-arm64" -Headers @{"PRIVATE-TOKEN"="$ACCESS_TOKEN"} -OutFile "artifacts.zip" } + - Expand-Archive -Path artifacts.zip -DestinationPath . + - $env:MSYSTEM = "CLANGARM64" + - $env:Path = "C:\msys64\clangarm64\bin;C:\msys64\usr\bin;" + $env:Path; + - cmake -B build -S . -DCMAKE_VERBOSE_MAKEFILE:BOOL=ON -D"CMAKE_C_COMPILER=clang" -D"CMAKE_CXX_COMPILER=clang++" -D"CMAKE_INSTALL_PREFIX:PATH=$CI_PROJECT_DIR\build\install-arm64" -D"OpenShotAudio_ROOT=$CI_PROJECT_DIR\build\install-arm64" -D"PYTHON_MODULE_PATH=python" -D"USE_QT6=ON" -D"OPENSHOT_QT_API=pyqt6" -G Ninja -D"CMAKE_BUILD_TYPE:STRING=Release" + - cmake --build build --parallel $([Environment]::ProcessorCount) + - ctest --test-dir build --output-on-failure -VV + - cmake --install build + - python -m unittest discover -s ci -p "test_*.py" -v + - python ci\validate_arm64_architecture.py --require-native-arm64 --package-lock ci\windows-arm64-packages.lock --payload-root build\install-arm64 --require-payload --json-report build\arm64-architecture-report.json + - $PROJECT_VERSION = (Select-String -Path "CMakeLists.txt" -Pattern '^set\(PROJECT_VERSION_FULL "(.*)\"' | %{$_.Matches.Groups[1].value}) + - $PROJECT_SO = (Select-String -Path "CMakeLists.txt" -Pattern '^set\(PROJECT_SO_VERSION (.*)\)' | %{$_.Matches.Groups[1].value}) + - New-Item -path "build/install-arm64/share/" -Name "$CI_PROJECT_NAME.env" -Value "CI_PROJECT_NAME:$CI_PROJECT_NAME`nCI_COMMIT_REF_NAME:$CI_COMMIT_REF_NAME`nCI_COMMIT_SHA:$CI_COMMIT_SHA`nCI_JOB_ID:$CI_JOB_ID`nCI_PIPELINE_ID:$CI_PIPELINE_ID`nVERSION:$PROJECT_VERSION`nSO:$PROJECT_SO`nTARGET_TRIPLET:aarch64-w64-mingw32`nPE_MACHINE:0xAA64" -ItemType file -force + - $PREV_GIT_LABEL=(git describe --tags --abbrev=0 '@^') + - git log "$PREV_GIT_LABEL..@" --oneline --pretty=format:"- %C(auto,yellow)%h%C(auto,magenta)% %C(auto,blue)%>(12,trunc)%ad %C(auto,green)%<(25,trunc)%aN%C(auto,reset)%s%C(auto,red)% gD% D" --date=short > "build/install-arm64/share/$CI_PROJECT_NAME.log" + when: always + rules: + - if: '$ENABLE_WINDOWS_ARM64 == "1" && $CI_COMMIT_TAG == null' + - when: never + tags: + - windows-arm64 + # Requires PR A's published windows-builder-arm64 artifact/digest and a + # native/virtual Windows Arm64 GitLab runner (design-spec.md + # release-infrastructure surface). Does not weaken or replace the + # existing windows-builder-x64/x86 jobs above. + allow_failure: true + windows-builder-x86: stage: build-libopenshot artifacts: diff --git a/CMakeLists.txt b/CMakeLists.txt index 634784791..7a260acdf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -291,6 +291,7 @@ if(DEFINED UNIT_TEST_TARGETS AND NOT TARGET coverage) DEPENDS openshot openshot-${_t}-test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMENT "Running unit tests for ${_t} class (coverage disabled)" + VERBATIM ) endforeach() endif() diff --git a/ci/test_validate_arm64_architecture.py b/ci/test_validate_arm64_architecture.py new file mode 100644 index 000000000..3eacab5ff --- /dev/null +++ b/ci/test_validate_arm64_architecture.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 OpenShot Studios, LLC +# SPDX-License-Identifier: LGPL-3.0-or-later + +import contextlib +import importlib.util +import io +import os +import struct +import sys +import tempfile +import unittest +from unittest import mock + +VALIDATOR_PATH = os.path.join(os.path.dirname(__file__), "validate_arm64_architecture.py") +SPEC = importlib.util.spec_from_file_location("validate_arm64_architecture", VALIDATOR_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("Unable to load validator from %s" % VALIDATOR_PATH) +validator = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validator) + + +def write_pe(path, machine): + data = bytearray(0x80) + data[:2] = b"MZ" + struct.pack_into(" +#if CV_VERSION_MAJOR >= 5 +#include +#else +#include +#endif #undef uint64 #undef int64 diff --git a/src/CVStabilization.cpp b/src/CVStabilization.cpp index 79a4890df..548035adc 100644 --- a/src/CVStabilization.cpp +++ b/src/CVStabilization.cpp @@ -21,6 +21,16 @@ #include "stabilizedata.pb.h" #include +#if CV_VERSION_MAJOR >= 5 +#define int64 opencv_broken_int +#define uint64 opencv_broken_uint +#include +#undef uint64 +#undef int64 +#else +#include +#endif + using namespace std; using namespace openshot; using google::protobuf::util::TimeUtil; diff --git a/src/FFmpegWriter.cpp b/src/FFmpegWriter.cpp index 4ae40ec50..64c3800ec 100644 --- a/src/FFmpegWriter.cpp +++ b/src/FFmpegWriter.cpp @@ -1142,15 +1142,29 @@ AVStream *FFmpegWriter::add_audio_stream() { #endif // Set valid sample rate (or throw error) - if (codec->supported_samplerates) { - int i; - for (i = 0; codec->supported_samplerates[i] != 0; i++) - if (info.sample_rate == codec->supported_samplerates[i]) { + const int *supported_samplerates = nullptr; + int supported_samplerate_count = 0; +#if LIBAVCODEC_VERSION_MAJOR >= 62 + const void *supported_samplerates_config = nullptr; + avcodec_get_supported_config(c, codec, AV_CODEC_CONFIG_SAMPLE_RATE, 0, + &supported_samplerates_config, &supported_samplerate_count); + supported_samplerates = static_cast(supported_samplerates_config); +#else + supported_samplerates = codec->supported_samplerates; + if (supported_samplerates) + while (supported_samplerates[supported_samplerate_count] != 0) + ++supported_samplerate_count; +#endif + if (supported_samplerates) { + bool sample_rate_supported = false; + for (int i = 0; i < supported_samplerate_count; ++i) + if (info.sample_rate == supported_samplerates[i]) { // Set the valid sample rate c->sample_rate = info.sample_rate; + sample_rate_supported = true; break; } - if (codec->supported_samplerates[i] == 0) + if (!sample_rate_supported) throw InvalidSampleRate("An invalid sample rate was detected for this codec.", path); } else // Set sample rate @@ -1164,15 +1178,31 @@ AVStream *FFmpegWriter::add_audio_stream() { // Set a valid number of channels (or throw error) AVChannelLayout ch_layout; av_channel_layout_from_mask(&ch_layout, info.channel_layout); - if (codec->ch_layouts) { - int i; - for (i = 0; av_channel_layout_check(&codec->ch_layouts[i]); i++) - if (av_channel_layout_compare(&ch_layout, &codec->ch_layouts[i])) { + const AVChannelLayout *supported_channel_layouts = nullptr; + int supported_channel_layout_count = 0; +#if LIBAVCODEC_VERSION_MAJOR >= 62 + const void *supported_channel_layouts_config = nullptr; + avcodec_get_supported_config(c, codec, AV_CODEC_CONFIG_CHANNEL_LAYOUT, 0, + &supported_channel_layouts_config, &supported_channel_layout_count); + supported_channel_layouts = + static_cast(supported_channel_layouts_config); +#else + supported_channel_layouts = codec->ch_layouts; + if (supported_channel_layouts) + while (av_channel_layout_check( + &supported_channel_layouts[supported_channel_layout_count])) + ++supported_channel_layout_count; +#endif + if (supported_channel_layouts) { + bool channel_layout_supported = false; + for (int i = 0; i < supported_channel_layout_count; ++i) + if (av_channel_layout_compare(&ch_layout, &supported_channel_layouts[i]) == 0) { // Set valid channel layout av_channel_layout_copy(&c->ch_layout, &ch_layout); + channel_layout_supported = true; break; } - if (!av_channel_layout_check(&codec->ch_layouts[i])) + if (!channel_layout_supported) throw InvalidChannels("An invalid channel layout was detected (i.e. MONO / STEREO).", path); } else // Set valid channel layout @@ -1195,13 +1225,22 @@ AVStream *FFmpegWriter::add_audio_stream() { #endif // Choose a valid sample_fmt - if (codec->sample_fmts) { - for (int i = 0; codec->sample_fmts[i] != AV_SAMPLE_FMT_NONE; i++) { - // Set sample format to 1st valid format (and then exit loop) - c->sample_fmt = codec->sample_fmts[i]; - break; - } - } + const AVSampleFormat *supported_sample_formats = nullptr; + int supported_sample_format_count = 0; +#if LIBAVCODEC_VERSION_MAJOR >= 62 + const void *supported_sample_formats_config = nullptr; + avcodec_get_supported_config(c, codec, AV_CODEC_CONFIG_SAMPLE_FORMAT, 0, + &supported_sample_formats_config, &supported_sample_format_count); + supported_sample_formats = + static_cast(supported_sample_formats_config); +#else + supported_sample_formats = codec->sample_fmts; + if (supported_sample_formats) + while (supported_sample_formats[supported_sample_format_count] != AV_SAMPLE_FMT_NONE) + ++supported_sample_format_count; +#endif + if (supported_sample_formats && supported_sample_format_count > 0) + c->sample_fmt = supported_sample_formats[0]; if (c->sample_fmt == AV_SAMPLE_FMT_NONE) { // Default if no sample formats found c->sample_fmt = AV_SAMPLE_FMT_S16; @@ -1401,12 +1440,24 @@ AVStream *FFmpegWriter::add_video_stream() { #endif // Find all supported pixel formats for this codec - const PixelFormat *supported_pixel_formats = codec->pix_fmts; - while (supported_pixel_formats != NULL && *supported_pixel_formats != PIX_FMT_NONE) { + const PixelFormat *supported_pixel_formats = nullptr; + int supported_pixel_format_count = 0; +#if LIBAVCODEC_VERSION_MAJOR >= 62 + const void *supported_pixel_formats_config = nullptr; + avcodec_get_supported_config(c, codec, AV_CODEC_CONFIG_PIX_FORMAT, 0, + &supported_pixel_formats_config, &supported_pixel_format_count); + supported_pixel_formats = + static_cast(supported_pixel_formats_config); +#else + supported_pixel_formats = codec->pix_fmts; + if (supported_pixel_formats) + while (supported_pixel_formats[supported_pixel_format_count] != PIX_FMT_NONE) + ++supported_pixel_format_count; +#endif + for (int i = 0; supported_pixel_formats && i < supported_pixel_format_count; ++i) { // Assign the 1st valid pixel format (if one is missing) if (c->pix_fmt == PIX_FMT_NONE) - c->pix_fmt = *supported_pixel_formats; - ++supported_pixel_formats; + c->pix_fmt = supported_pixel_formats[i]; } // Codec doesn't have any pix formats? diff --git a/src/effects/Stabilizer.cpp b/src/effects/Stabilizer.cpp index 998730fb7..3a1de1977 100644 --- a/src/effects/Stabilizer.cpp +++ b/src/effects/Stabilizer.cpp @@ -21,6 +21,16 @@ #include +#if CV_VERSION_MAJOR >= 5 +#define int64 opencv_broken_int +#define uint64 opencv_broken_uint +#include +#undef uint64 +#undef int64 +#else +#include +#endif + using namespace std; using namespace openshot; using google::protobuf::util::TimeUtil; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6815c9246..77255eb89 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,7 +14,10 @@ if(POLICY CMP0110) endif() # Test media path, used by unit tests for input data -file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/examples/" TEST_MEDIA_PATH) +file(TO_CMAKE_PATH "${PROJECT_SOURCE_DIR}/examples/" TEST_MEDIA_PATH) +if(NOT TEST_MEDIA_PATH MATCHES "/$") + string(APPEND TEST_MEDIA_PATH "/") +endif() # Benchmark executable add_executable(openshot-benchmark Benchmark.cpp BenchmarkOptions.cpp) @@ -49,6 +52,7 @@ set(OPENSHOT_TESTS QtPlayer QtImageReader ReaderBase + NativeArm64ProcessOracle Settings ScreenCaptureReader SphericalMetadata diff --git a/tests/NativeArm64ProcessOracle.cpp b/tests/NativeArm64ProcessOracle.cpp new file mode 100644 index 000000000..f18bffc88 --- /dev/null +++ b/tests/NativeArm64ProcessOracle.cpp @@ -0,0 +1,91 @@ +/** + * @file + * @brief Unit tests for the Windows native Arm64 process/payload + * architecture oracle (design-spec.md G2/G3/G8/G11, + * design-amendment-A1). + * @author OpenShot Studios, LLC + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "openshot_catch.h" +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +// This test validates design-amendment-A1's approved native-process oracle +// semantics directly against the running test host: +// - pNativeMachine must equal IMAGE_FILE_MACHINE_ARM64 (0xAA64) for a +// native Arm64 host. +// - pProcessMachine must equal IMAGE_FILE_MACHINE_UNKNOWN (0x0) for a +// process that is running natively (not under WOW64/emulation). +// - Any nonzero pProcessMachine indicates WOW/emulated execution and is +// reported, never silently treated as a pass. +// +// This test intentionally does NOT assert host architecture except in a +// native Arm64 build. It captures observed values for assertion diagnostics: on this +// AMD64 development/CI host it demonstrates the API and reports +// native_machine == AMD64 (not ARM64), which is expected and does not +// constitute an Arm64 release claim. Only on an actual native Arm64 host +// would native_arm64_ok become true. +TEST_CASE( "NativeArm64ProcessOracle_A1", "[libopenshot][windows][arm64]" ) +{ +#if defined(_WIN32) + // IsWow64Process2 requires Windows 10 1809 (build 17763) or later. + HMODULE kernel32 = ::GetModuleHandleW(L"kernel32.dll"); + REQUIRE(kernel32 != nullptr); + + using IsWow64Process2Fn = BOOL (WINAPI*)(HANDLE, USHORT*, USHORT*); + auto pIsWow64Process2 = reinterpret_cast( + ::GetProcAddress(kernel32, "IsWow64Process2")); + + if (!pIsWow64Process2) { + WARN("IsWow64Process2 is unavailable on this Windows build " + "(requires 10.0.17763+); native-process oracle skipped."); + return; + } + + USHORT processMachine = IMAGE_FILE_MACHINE_UNKNOWN; + USHORT nativeMachine = IMAGE_FILE_MACHINE_UNKNOWN; + ::SetLastError(ERROR_SUCCESS); + BOOL ok = pIsWow64Process2(::GetCurrentProcess(), &processMachine, &nativeMachine); + const DWORD lastError = ::GetLastError(); + INFO("GetLastError=" << lastError); + REQUIRE(ok); + + const bool isWowOrEmulated = (processMachine != IMAGE_FILE_MACHINE_UNKNOWN); + const bool nativeArm64Ok = + (nativeMachine == IMAGE_FILE_MACHINE_ARM64) && + (processMachine == IMAGE_FILE_MACHINE_UNKNOWN); + + INFO("process_machine=0x" << std::hex << processMachine); + INFO("native_machine=0x" << std::hex << nativeMachine); + INFO("is_wow_or_emulated=" << isWowOrEmulated); + INFO("native_arm64_ok=" << nativeArm64Ok); + if (isWowOrEmulated) { + WARN("Process is running under WOW/emulation."); + } + // On an Arm64 host, any nonzero process machine is WOW/emulated and must + // fail. Other hosts only prove that they are not native Arm64. + if (nativeMachine == IMAGE_FILE_MACHINE_ARM64) { + REQUIRE_FALSE(isWowOrEmulated); + REQUIRE(nativeArm64Ok); + } else { + CHECK_FALSE(nativeArm64Ok); + } +#else + WARN("IsWow64Process2 is a Windows-only API; native-process oracle skipped on this platform."); +#endif +} diff --git a/tests/ObjectMask.cpp b/tests/ObjectMask.cpp index 8b6432e5a..b7a277b4e 100644 --- a/tests/ObjectMask.cpp +++ b/tests/ObjectMask.cpp @@ -27,7 +27,10 @@ #include #include #include -#include +#include + +#include +#include using namespace openshot; @@ -38,12 +41,13 @@ static std::shared_ptr make_object_mask_frame(int64_t number, int width, } static std::string temp_object_mask_path() { - char path[] = "/tmp/libopenshot_object_mask_XXXXXX"; - int fd = mkstemp(path); - REQUIRE(fd != -1); - close(fd); - std::remove(path); - return std::string(path) + ".data"; + QTemporaryFile file(QDir::tempPath() + "/libopenshot_object_mask_XXXXXX.data"); + file.setAutoRemove(false); + if (!file.open()) + throw std::runtime_error("Unable to reserve a unique ObjectMask test path"); + const std::string path = file.fileName().toStdString(); + file.close(); + return path; } static void append_varint(std::string& output, uint64_t value) {