From d8851eda7a04065f5a0832818db652d108222c82 Mon Sep 17 00:00:00 2001 From: defisym Date: Wed, 4 Nov 2026 04:52:55 +0000 Subject: [PATCH 01/12] FFMpeg: use split shared helpers without duplicate utility headers Replace the GeneralDefinition umbrella with the existing arithmetic, string and code-page helpers. Remove a dead probe call-site comment. Keep the original helper contracts; do not introduce FFMpegUtilities or SDLAudioDefinition copies. --- Extensions/_3rdLib/FFMpeg.h | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 5185d4fbb..5563526bc 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -27,7 +27,15 @@ #include "PacketQueue.h" #include "HoldHelper.h" -#include "GeneralDefinition.h" +#include "ArithmeticUtilities.h" +#include "StringUtilities.h" +#include "StringConversion.h" +#include +#include +#include +#include +#include +#include // FFMpeg extern "C" { @@ -755,7 +763,6 @@ class FFMpeg { *ppFormatContext = avformat_alloc_context(); if (!*ppFormatContext) { return false; } - //auto iformat = init_probe(nullptr, 0, pFilePath); // convert to UTF-8 to avoid crash in some versions if (avformat_open_input(ppFormatContext, pFilePath, fmt, nullptr) != 0) { From 808a2bbd9e04a8581f9db02cc71d2213bbe17194 Mon Sep 17 00:00:00 2001 From: defisym Date: Wed, 4 Nov 2026 04:52:55 +0000 Subject: [PATCH 02/12] FFMpeg: remove compiler-specific assumptions from the shared build Use a standard exception constructor and fixed-width/standard integer types. Guard the D3D include on Windows and qualify Lock template references hidden by member names. Error flags and Windows public entry points are retained. Validate by compiling and decoding the first frame on a Linux host. --- Extensions/_3rdLib/FFMpeg.h | 10 +++++----- Extensions/_3rdLib/FFMpegAdapter.cpp | 2 ++ Extensions/_DeLib/LockHelper.h | 6 ++++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 5563526bc..218a1576b 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -119,7 +119,7 @@ constexpr auto FFMpegException_DecodeFailed = -3; constexpr auto FFMpegException_HWDecodeFailed = -4; constexpr auto FFMpegException_FilterInitFailed = -5; -struct FFMpegException final :std::exception { +struct FFMpegException final :std::runtime_error { int _flag = 0; static const char* GetInfo(int errorCode) { switch (errorCode) { @@ -138,7 +138,7 @@ struct FFMpegException final :std::exception { } } - explicit FFMpegException(const int flag) :std::exception(GetInfo(flag)), _flag(flag) {} + explicit FFMpegException(const int flag) :std::runtime_error(GetInfo(flag)), _flag(flag) {} }; #ifdef _DEBUG @@ -223,7 +223,7 @@ class FFMpegOptions { } public: - DWORD flag = FFMpegFlag_Default; + uint32_t flag = FFMpegFlag_Default; std::string videoCodecName; std::string audioCodecName; int threadCount = 0; @@ -730,7 +730,7 @@ class FFMpeg { #endif // AUDIO_TEMPO // probe video format - static inline const AVInputFormat* init_probe(BYTE* pBuffer, const size_t bfSz, const LPCSTR pFileName = "") { + static inline const AVInputFormat* init_probe(uint8_t* pBuffer, const size_t bfSz, const char* pFileName = "") { AVProbeData probeData = { }; probeData.buf = pBuffer; probeData.buf_size = static_cast((std::min)(static_cast(MEM_BUFFER_SIZE), bfSz)); @@ -1420,7 +1420,7 @@ class FFMpeg { const bool& bBlockState, PacketQueue& queue, AVCodecContext* pCodecContext, AVPacket* pPacket, AVFrame* pFrame, AVFrame* pLastFrame, Decoder decoder) { - BOOL bRespond = true; + int bRespond = true; if (!bSendEAgain) { bRespond = queue.get(pPacket, bBlockState); diff --git a/Extensions/_3rdLib/FFMpegAdapter.cpp b/Extensions/_3rdLib/FFMpegAdapter.cpp index 41882e731..3fb1f6321 100644 --- a/Extensions/_3rdLib/FFMpegAdapter.cpp +++ b/Extensions/_3rdLib/FFMpegAdapter.cpp @@ -1,6 +1,8 @@ #include "FFMpegAdapter.h" +#ifdef _WIN32 #include "FFMpegAdapterD3D11.h" +#endif bool FFMpegAdapterSupport(const AVHWDeviceType type) { do { diff --git a/Extensions/_DeLib/LockHelper.h b/Extensions/_DeLib/LockHelper.h index 7685fffd0..5ca3b8a97 100644 --- a/Extensions/_DeLib/LockHelper.h +++ b/Extensions/_DeLib/LockHelper.h @@ -9,7 +9,9 @@ #include #include +#ifdef _WIN32 #pragma comment(lib, "SDL2.lib") +#endif #include @@ -118,7 +120,7 @@ struct ManualLockHelper { SpinLockHelper* pHelper = nullptr; public: explicit ManualLockHelper(SDL_SpinLock* plock) :_plock(plock) { } - explicit ManualLockHelper(Lock& lock) + explicit ManualLockHelper(::Lock& lock) :ManualLockHelper(lock.GetLock()) { } ~ManualLockHelper() { Unlock(); } @@ -136,7 +138,7 @@ struct ManualLockHelper { MutexLockHelper* pHelper = nullptr; public: explicit ManualLockHelper(SDL_mutex* plock) :_plock(plock) { } - explicit ManualLockHelper(Lock& lock) + explicit ManualLockHelper(::Lock& lock) :ManualLockHelper(lock.GetLock()) { } ~ManualLockHelper() { Unlock(); } From 058e5d391cde29354256a9ecba7e8b06f9598416 Mon Sep 17 00:00:00 2001 From: defisym Date: Wed, 4 Nov 2026 05:31:25 +0000 Subject: [PATCH 03/12] FFMpeg: separate borrowed memory views from AVIO buffer ownership Replace MemBuf with a non-owning, read-only MemView and allocate the AVIO working buffer independently with av_malloc. Free the current AVIO buffer explicitly, bound memory seeks and answer size queries. Let avformat probe through custom IO and remove the obsolete manual probe helper. Delegate constructors so partial initialization is destructed; keep the destructor adaptor guard because opening/probing/video-stream discovery can fail before the factory runs. Normal initialized players always have an adaptor, including software playback. Delegate the UTF-8 path overload to the existing wide-path constructor via the shared code-page conversion. Do not change forwardFrame packet unref behavior in this ownership commit. Host first-frame, memory-source playback/seek and failed-open checks pass. --- Extensions/FFMpeg/FFMpeg.vcxproj | 2 +- Extensions/FFMpeg/FFMpeg.vcxproj.filters | 2 +- Extensions/_3rdLib/FFMpeg.h | 138 ++++++++++------------- Extensions/_3rdLib/MemBuf.h | 91 --------------- Extensions/_3rdLib/MemView.h | 50 ++++++++ 5 files changed, 113 insertions(+), 170 deletions(-) delete mode 100644 Extensions/_3rdLib/MemBuf.h create mode 100644 Extensions/_3rdLib/MemView.h diff --git a/Extensions/FFMpeg/FFMpeg.vcxproj b/Extensions/FFMpeg/FFMpeg.vcxproj index a7c1b75b1..71fccc7de 100644 --- a/Extensions/FFMpeg/FFMpeg.vcxproj +++ b/Extensions/FFMpeg/FFMpeg.vcxproj @@ -306,7 +306,7 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ - + diff --git a/Extensions/FFMpeg/FFMpeg.vcxproj.filters b/Extensions/FFMpeg/FFMpeg.vcxproj.filters index 76c248aad..712176c95 100644 --- a/Extensions/FFMpeg/FFMpeg.vcxproj.filters +++ b/Extensions/FFMpeg/FFMpeg.vcxproj.filters @@ -196,7 +196,7 @@ Header Files - + FFMpeg diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 218a1576b..5e1776f8c 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -22,7 +22,7 @@ #include #include -#include "MemBuf.h" +#include "MemView.h" #include "LockHelper.h" #include "PacketQueue.h" @@ -45,6 +45,7 @@ extern "C" { #include #include #include +#include #include #include @@ -93,6 +94,9 @@ constexpr auto ATEMPO_DEFAULT = 1.0f; constexpr auto ATEMPO_MAX = 32.0f; #endif +// Custom IO working buffer +constexpr auto AVIO_BUFFER_SIZE = 4 * 1024; + // Probe constexpr auto PROBE_SIZE = 32 * 4096; @@ -327,8 +331,8 @@ class FFMpeg { AVIOContext* pAvioContext = nullptr; AVIOContext* pSeekAvioContext = nullptr; - MemBuf* pMemBuf = nullptr; - MemBuf* pSeekMemBuf = nullptr; + MemView* pMemView = nullptr; + MemView* pSeekMemView = nullptr; AVFormatContext* pFormatContext = nullptr; AVFormatContext* pSeekFormatContext = nullptr; @@ -729,29 +733,6 @@ class FFMpeg { } #endif // AUDIO_TEMPO - // probe video format - static inline const AVInputFormat* init_probe(uint8_t* pBuffer, const size_t bfSz, const char* pFileName = "") { - AVProbeData probeData = { }; - probeData.buf = pBuffer; - probeData.buf_size = static_cast((std::min)(static_cast(MEM_BUFFER_SIZE), bfSz)); - probeData.filename = pFileName; - - // Determine the input-format: - while (true) { - const auto iformat = av_probe_input_format(&probeData, 1); - - if (iformat != nullptr) { - return iformat; - } - - if (static_cast(probeData.buf_size + 1) > bfSz) { - return nullptr; - } - - probeData.buf_size = static_cast((std::min)(static_cast(2 * probeData.buf_size), bfSz)); - } - } - // from file static inline bool init_formatContext(AVFormatContext** ppFormatContext, const std::wstring& filePath, const AVInputFormat* fmt = nullptr) { @@ -772,45 +753,35 @@ class FFMpeg { return true; } - // from memory - static inline bool init_formatContext(AVFormatContext** ppFormatContext, AVIOContext** ppAvioContext, - MemBuf** ppBuf, unsigned char* pBuffer, const size_t bfSz, - const AVInputFormat* fmt = nullptr) { - if (pBuffer == nullptr) { return false; } - - (*ppBuf) = new MemBuf(pBuffer, static_cast(bfSz)); - - *ppAvioContext = avio_alloc_context((*ppBuf)->get(), (*ppBuf)->getSize(), 0, (*ppBuf), - [] (void* opaque, uint8_t* buf, const int buf_size) { - const auto pMemBuf = static_cast(opaque); - return pMemBuf->read(buf, buf_size); - }, - nullptr, - [] (void* opaque, const int64_t offset, const int whence) { - const auto pMemBuf = static_cast(opaque); - return pMemBuf->seek(offset, whence); - }); - - if (!*ppAvioContext) { return false; } - - *ppFormatContext = avformat_alloc_context(); - if (!*ppFormatContext) { return false; } - - if (!fmt) { fmt = init_probe(pBuffer, bfSz); } - if (!fmt) { return false; } - - // might crash in avformat_open_input due to access violation if not set - // https://www.codeproject.com/Tips/489450/Creating-Custom-FFmpeg-IO-Context - (*ppFormatContext)->iformat = fmt; - (*ppFormatContext)->pb = *ppAvioContext; - (*ppFormatContext)->flags |= AVFMT_FLAG_CUSTOM_IO; - - if (avformat_open_input(ppFormatContext, nullptr, nullptr, nullptr) < 0) { - return false; - } + // From memory: borrow source data and manage the AVIO working buffer separately. + static inline bool init_formatContext(AVFormatContext** ppFormatContext, AVIOContext** ppAvioContext, + MemView** ppView, const unsigned char* source, const size_t size, + const AVInputFormat* fmt = nullptr) { + if (!source || size == 0 || size > INT_MAX) return false; + + *ppView = new MemView(source, static_cast(size)); + auto buffer = static_cast(av_malloc(AVIO_BUFFER_SIZE)); + if (!buffer) return false; + *ppAvioContext = avio_alloc_context(buffer, AVIO_BUFFER_SIZE, 0, *ppView, + [](void* opaque, uint8_t* destination, int count) { + return static_cast(opaque)->read(destination, count); + }, nullptr, + [](void* opaque, int64_t offset, int whence) { + return static_cast(opaque)->seek(offset, whence); + }); + if (!*ppAvioContext) { + av_free(buffer); + return false; + } - return true; - } + *ppFormatContext = avformat_alloc_context(); + if (!*ppFormatContext) return false; + (*ppFormatContext)->pb = *ppAvioContext; + (*ppFormatContext)->flags |= AVFMT_FLAG_CUSTOM_IO; + // With no format hint, avformat performs probing through custom IO using + // its own zero-padded probe storage. Cleanup frees the current AVIO buffer. + return avformat_open_input(ppFormatContext, nullptr, fmt, nullptr) >= 0; + } // init the given AVCodecContext by pVCodec, which must be valid before calling this inline int init_videoCodecContext(AVCodecContext** ppVCodecContext, AVCodecCtxOpaque* pOpaque = nullptr) { @@ -1786,9 +1757,12 @@ class FFMpeg { return response; } + FFMpeg() = default; public: - //Load from file - explicit FFMpeg(const std::wstring& filePath, const FFMpegOptions& opt = FFMpegOptions()) { + FFMpeg(const FFMpeg&) = delete; + FFMpeg& operator=(const FFMpeg&) = delete; + //Load from file + explicit FFMpeg(const std::wstring& filePath, const FFMpegOptions& opt = FFMpegOptions()) : FFMpeg() { bFromMem = false; this->options = opt; @@ -1799,13 +1773,16 @@ class FFMpeg { if (const auto ret = init_general(); ret != 0) { throw FFMpegException(ret); } } + explicit FFMpeg(const std::string& filePath, const FFMpegOptions& opt = FFMpegOptions()) + : FFMpeg(to_wide_string(filePath, CP_UTF8), opt) {} + //Load from memory //https://blog.csdn.net/leixiaohua1020/article/details/12980423 - FFMpeg(unsigned char* pBuffer, const size_t bfSz, const FFMpegOptions& opt = FFMpegOptions()) { + FFMpeg(unsigned char* pBuffer, const size_t bfSz, const FFMpegOptions& opt = FFMpegOptions()) : FFMpeg() { bFromMem = true; this->options = opt; - if (!init_formatContext(&pFormatContext, &pAvioContext, &pMemBuf, pBuffer, bfSz)) { + if (!init_formatContext(&pFormatContext, &pAvioContext, &pMemView, pBuffer, bfSz)) { throw FFMpegException(FFMpegException_InitFailed); } @@ -1856,9 +1833,14 @@ class FFMpeg { sws_freeContext(swsContext); swr_free(&swrContext); + av_channel_layout_uninit(&targetChannelLayout); - pAdapter->FreeContext(VCodecCtxQpaque.hTextureCtx); - pAdapter->FreeContext(VGetCodecCtxQpaque.hTextureCtx); + // Delegated construction also runs this destructor when opening/probing + // or codec initialization fails before the adaptor factory is reached. + if (pAdapter) { + pAdapter->FreeContext(VCodecCtxQpaque.hTextureCtx); + pAdapter->FreeContext(VGetCodecCtxQpaque.hTextureCtx); + } avcodec_free_context(&pVCodecContext); avcodec_free_context(&pVGetCodecContext); @@ -1872,11 +1854,13 @@ class FFMpeg { #endif // HW_DECODE if (bFromMem) { - delete pMemBuf; - pMemBuf = nullptr; - delete pSeekMemBuf; - pSeekMemBuf = nullptr; + delete pMemView; + pMemView = nullptr; + delete pSeekMemView; + pSeekMemView = nullptr; + if (pAvioContext) av_freep(&pAvioContext->buffer); + if (pSeekAvioContext) av_freep(&pSeekAvioContext->buffer); avio_context_free(&pAvioContext); avio_context_free(&pSeekAvioContext); } @@ -1943,11 +1927,11 @@ class FFMpeg { return nullptr; } - if (pMemBuf == nullptr) { + if (pMemView == nullptr) { return nullptr; } - const auto pSrc = pMemBuf->getSrc(); + const auto pSrc = pMemView->getSrc(); return pSrc; } @@ -2162,7 +2146,7 @@ class FFMpeg { if (!pSeekFormatContext) { const auto bSuccess = bFromMem ? init_formatContext(&pSeekFormatContext, &pSeekAvioContext, - &pSeekMemBuf, const_cast(pMemBuf->getSrc()), pMemBuf->getSrcSize(), + &pSeekMemView, pMemView->getSrc(), pMemView->getSrcSize(), pFormatContext->iformat) : init_formatContext(&pSeekFormatContext, pFormatContext->url, pFormatContext->iformat); if (!bSuccess) { return FFMpegException_InitFailed; } diff --git a/Extensions/_3rdLib/MemBuf.h b/Extensions/_3rdLib/MemBuf.h deleted file mode 100644 index 2ca2b6370..000000000 --- a/Extensions/_3rdLib/MemBuf.h +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -// #include - -extern "C" { -#include -} - -// 4KB -constexpr auto MEM_BUFFER_SIZE = 4 * 1024; - -class MemBuf { -private: - int64_t bufferIndex = 0; - - uint8_t* pBuf = nullptr; - int bufSize = 0; - - uint8_t* pSrcBuf = nullptr; - int srcBufSize = 0; - -public: - MemBuf(uint8_t* pSrcBuf, int srcBufSize, int bufSize = MEM_BUFFER_SIZE) { - this->pSrcBuf = pSrcBuf; - this->srcBufSize = srcBufSize; - - this->pBuf = new uint8_t[bufSize]; - this->bufSize = bufSize; - } - - ~MemBuf() { - delete[] this->pBuf; - } - - inline const uint8_t* getSrc() const { - return this->pSrcBuf; - } - - inline int getSrcSize() const { - return srcBufSize; - } - - inline uint8_t* get() const { - return pBuf; - } - - inline int getSize() const { - return bufSize; - } - - inline int read(uint8_t* buf, int buf_size) { - auto dataLeft = srcBufSize - bufferIndex; - //memset(buf, 0, buf_size); - - if (dataLeft >= buf_size) { - memcpy(buf, this->pSrcBuf + bufferIndex, buf_size); - bufferIndex += buf_size; - - return buf_size; - } - else if (dataLeft > 0) { - memcpy(buf, this->pSrcBuf + bufferIndex, (size_t)dataLeft); - bufferIndex += dataLeft; - - return (size_t)dataLeft; - } - else { - return AVERROR_EOF; - } - } - - inline int64_t seek(int64_t offset, int whence) { - switch (whence) { - case SEEK_SET: - bufferIndex = 0 + offset; - - return bufferIndex; - case SEEK_CUR: - bufferIndex = bufferIndex + offset; - - return bufferIndex; - case SEEK_END: - bufferIndex = srcBufSize + offset; - - return bufferIndex; - case AVSEEK_SIZE: - default: - return -1; - } - } -}; \ No newline at end of file diff --git a/Extensions/_3rdLib/MemView.h b/Extensions/_3rdLib/MemView.h new file mode 100644 index 000000000..eefe066d0 --- /dev/null +++ b/Extensions/_3rdLib/MemView.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +// Read/seek cursor over caller-owned media data. The source must outlive the view. +class MemView { +private: + int64_t bufferIndex = 0; + const uint8_t* pSrcBuf = nullptr; + int srcBufSize = 0; + +public: + MemView(const uint8_t* source, int size) : pSrcBuf(source), srcBufSize(size) {} + + const uint8_t* getSrc() const { return pSrcBuf; } + int getSrcSize() const { return srcBufSize; } + + int read(uint8_t* buffer, int size) { + if (size <= 0) return AVERROR(EINVAL); + const auto remaining = srcBufSize - bufferIndex; + if (!remaining) return AVERROR_EOF; + const int count = static_cast((std::min)(remaining, static_cast(size))); + std::memcpy(buffer, pSrcBuf + bufferIndex, count); + bufferIndex += count; + return count; + } + + int64_t seek(int64_t offset, int whence) { + if (whence == AVSEEK_SIZE) return srcBufSize; + const int origin = whence & ~AVSEEK_FORCE; + int64_t base = 0; + switch (origin) { + case SEEK_SET: break; + case SEEK_CUR: base = bufferIndex; break; + case SEEK_END: base = srcBufSize; break; + default: return AVERROR(EINVAL); + } + if (offset < -base || offset > srcBufSize - base) return AVERROR(EINVAL); + bufferIndex = base + offset; + return bufferIndex; + } +}; From 9788a907d1173231b90a19bcc1dda380bf917b58 Mon Sep 17 00:00:00 2001 From: defisym Date: Wed, 4 Nov 2026 05:47:53 +0000 Subject: [PATCH 04/12] FFMpeg: size software output from decoded frame properties Cache swscale by actual frame properties and allocate/report the output buffer and stride produced by FFmpeg. Expose the output format as an option while preserving the BGRA default and Windows row alignment. Host software-conversion stride/frame checks pass. Name the converted frame storage pConvertedFrameBuffer because callers may request BGRA, BGR24 or RGBA output. --- Extensions/_3rdLib/FFMpeg.h | 54 ++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 5e1776f8c..597748c83 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -231,6 +231,7 @@ class FFMpegOptions { std::string videoCodecName; std::string audioCodecName; int threadCount = 0; + AVPixelFormat outputFormat = PIXEL_FORMAT; FFMpegOptions() = default; @@ -426,7 +427,7 @@ class FFMpeg { int64_t frameCount = 0; // buffer for format conversion - uint8_t* p_global_bgr_buffer = nullptr; + uint8_t* pConvertedFrameBuffer = nullptr; // conversion buffer size int num_bytes = 0; @@ -1282,38 +1283,29 @@ class FFMpeg { } } - // convert bitmap frame pixel format to PIXEL_FORMAT + // Convert a bitmap frame to the requested output pixel format. inline void convert_bitmapFrame(AVCodecContext* pCodecContext, AVFrame* pFrame, const FrameDataCallBack& callBack) { - // Convert data to Bitmap data - // https://zhuanlan.zhihu.com/p/53305541 - - // allocate buffer - if (p_global_bgr_buffer == nullptr) { - num_bytes = av_image_get_buffer_size(PIXEL_FORMAT, pFrame->linesize[0], pFrame->height, 1); - p_global_bgr_buffer = new uint8_t[num_bytes]; - } - - // allocate sws - if (swsContext == nullptr) { - //auto pFormat = pFrame->format; - - swsContext = sws_getContext(pFrame->width, pFrame->height, - static_cast(pFrame->format), - pFrame->width, pFrame->height, - PIXEL_FORMAT, - NULL, nullptr, nullptr, nullptr); + // DWORD row alignment also preserves the Windows BGR24 bitmap contract. + const auto size = av_image_get_buffer_size(options.outputFormat, pFrame->width, pFrame->height, 4); + if (size < 0) throw FFMpegException(FFMpegException_DecodeFailed); + if (size != num_bytes) { + auto buffer = std::make_unique(size); + delete[] pConvertedFrameBuffer; + pConvertedFrameBuffer = buffer.release(); + num_bytes = size; } - - // make sure the sws_scale output is point to start. - const int linesize[8] = { abs(pFrame->linesize[0] * PIXEL_BYTE) }; - - uint8_t* bgr_buffer[8] = { p_global_bgr_buffer }; - - sws_scale(swsContext, pFrame->data, pFrame->linesize, 0, pFrame->height, bgr_buffer, linesize); - - //bgr_buffer[0] is the BGR raw data, aka p_global_bgr_buffer - callBack(bgr_buffer[0], linesize[0], pFrame->height); + swsContext = sws_getCachedContext(swsContext, pFrame->width, pFrame->height, + static_cast(pFrame->format), pFrame->width, pFrame->height, + options.outputFormat, SWS_BILINEAR, nullptr, nullptr, nullptr); + if (!swsContext) throw FFMpegException(FFMpegException_DecodeFailed); + uint8_t* output[4] = {}; + int strides[4] = {}; + if (av_image_fill_arrays(output, strides, pConvertedFrameBuffer, + options.outputFormat, pFrame->width, pFrame->height, 4) < 0) + throw FFMpegException(FFMpegException_DecodeFailed); + sws_scale(swsContext, pFrame->data, pFrame->linesize, 0, pFrame->height, output, strides); + callBack(output[0], strides[0], pFrame->height); } inline void convert_frame(AVCodecContext* pCodecContext, @@ -1865,7 +1857,7 @@ class FFMpeg { avio_context_free(&pSeekAvioContext); } - delete[] p_global_bgr_buffer; + delete[] pConvertedFrameBuffer; SDL_AtomicUnlock(&audioLock); } From 393def8cd2077ff33d3dfeff205e0bb43b34fe55 Mon Sep 17 00:00:00 2001 From: defisym Date: Wed, 4 Nov 2026 06:19:16 +0000 Subject: [PATCH 05/12] FFMpeg: preserve fractional seek targets and source-relative timestamps Convert milliseconds using floating-point seconds and normalize decoded best-effort timestamps against the format start time. Avoid rounding a subsecond seek down to zero. Host fractional seek and frame-position checks pass. Cache the timeline origin once after avformat_find_stream_info succeeds, then reuse that per-player value for seek and frame-clock conversions. Read the initialized timelineOriginSeconds member directly in seek, video and audio conversions; remove the redundant getter. --- Extensions/_3rdLib/FFMpeg.h | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 597748c83..165231685 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -423,6 +423,7 @@ class FFMpeg { double totalTime = 0; int64_t totalTimeInMs = 0; int64_t totalDuration = 0; + double timelineOriginSeconds = 0.0; int64_t frameCount = 0; @@ -845,6 +846,8 @@ class FFMpeg { if (avformat_find_stream_info(pFormatContext, nullptr) < 0) { return FFMpegException_InitFailed; } + timelineOriginSeconds = pFormatContext->start_time == AV_NOPTS_VALUE ? 0.0 + : pFormatContext->start_time / static_cast(AV_TIME_BASE); //--------------------- // Video @@ -1028,9 +1031,9 @@ class FFMpeg { // Seek // ------------------------------------ - inline int64_t get_protectedTimeInSecond(const int64_t ms) const { + inline double get_protectedTimeInSecond(const int64_t ms) const { const auto protectedTimeInMs = Range(ms, static_cast(0), totalTimeInMs); - const auto protectedTimeInSecond = protectedTimeInMs / 1000; + const auto protectedTimeInSecond = protectedTimeInMs / 1000.0; return protectedTimeInSecond; } @@ -1047,7 +1050,7 @@ class FFMpeg { // If flags contain AVSEEK_FLAG_FRAME, then all timestamps are in frames in the stream with stream_index // (this may not be supported by all demuxers). if ((flags & AVSEEK_FLAG_FRAME) == AVSEEK_FLAG_FRAME) { - const auto protectedTimeStamp = static_cast(get_protectedTimeInSecond(ms)) / av_q2d(pVideoStream->time_base); + const auto protectedTimeStamp = static_cast(get_protectedTimeInSecond(ms) + timelineOriginSeconds) / av_q2d(pVideoStream->time_base); return static_cast(protectedTimeStamp); } @@ -1055,7 +1058,7 @@ class FFMpeg { // default // all timestamps are in units of the stream selected by stream_index or if stream_index is -1, in AV_TIME_BASE units // av_rescale_q(int64_t(ms * 1000.0), time_base_q, pFormatContext->streams[stream_index]->time_base); - const auto protectedTimeStamp = static_cast(get_protectedTimeInSecond(ms)) / av_q2d(pVideoStream->time_base); + const auto protectedTimeStamp = static_cast(get_protectedTimeInSecond(ms) + timelineOriginSeconds) / av_q2d(pVideoStream->time_base); return static_cast(protectedTimeStamp); } @@ -1454,22 +1457,9 @@ class FFMpeg { return response; } - videoPts = static_cast(pFrame->best_effort_timestamp); - - if (pVPacket != nullptr) { - if (pVPacket->dts == AV_NOPTS_VALUE - && pFrame->opaque - && *static_cast(pFrame->opaque) != AV_NOPTS_VALUE) { - videoPts = static_cast(*static_cast(pFrame->opaque)); - } - else { - videoPts = static_cast(pVPacket->dts != AV_NOPTS_VALUE - ? pFrame->best_effort_timestamp - : 0); - } - } - - videoPts *= av_q2d(pVideoStream->time_base); + videoPts = pFrame->best_effort_timestamp == AV_NOPTS_VALUE ? videoClock + : static_cast(pFrame->best_effort_timestamp) * av_q2d(pVideoStream->time_base) + - timelineOriginSeconds; #ifdef _CONSOLE if (bJumped) { From 9761ce96d47cddbde115e7a64161c100dbf943f9 Mon Sep 17 00:00:00 2001 From: defisym Date: Wed, 4 Nov 2026 06:19:16 +0000 Subject: [PATCH 06/12] FFMpeg: bound each PCM mix to the available decoded bytes Pass wt_stream_len instead of the remaining whole request to the mixer. Otherwise a request spanning PCM blocks mixes bytes beyond the current block and can mix the following region twice. Add a real SDL additive-mixing regression comparing a 100000-byte request with four-byte fragments. It fails immediately before this fix and passes afterward. --- Extensions/FFMpeg/tests/PCMFragmentTest.cpp | 23 +++++++++++++++++++++ Extensions/_3rdLib/FFMpeg.h | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 Extensions/FFMpeg/tests/PCMFragmentTest.cpp diff --git a/Extensions/FFMpeg/tests/PCMFragmentTest.cpp b/Extensions/FFMpeg/tests/PCMFragmentTest.cpp new file mode 100644 index 000000000..87f519dfd --- /dev/null +++ b/Extensions/FFMpeg/tests/PCMFragmentTest.cpp @@ -0,0 +1,23 @@ +#include "FFMpeg.h" +#include +#include +#include + +// Output must not depend on how SDL divides a request across decoded PCM blocks. +int main(int argc, char** argv) { + if (argc != 2) throw std::runtime_error("Audio/video fixture required"); + FFMpeg large(to_wide_string(argv[1])), fragmented(to_wide_string(argv[1])); + large.set_pause(false); fragmented.set_pause(false); + std::vector actual(100000), expected(actual.size()); + auto mix = [](void* dst, const void* src, size_t count, int volume) { + SDL_MixAudioFormat(static_cast(dst), static_cast(src), + AUDIO_S16SYS, static_cast(count), volume); + }; + large.audio_fillData(actual.data(), static_cast(actual.size()), std::memset, mix); + for (size_t i = 0; i < expected.size(); i += 4) + fragmented.audio_fillData(expected.data()+i, 4, std::memset, mix); + if (std::all_of(expected.begin(), expected.end(), [](uint8_t value) { return value == 0; })) + throw std::runtime_error("Fixture produced no audible PCM"); + if (actual != expected) throw std::runtime_error("PCM changes with callback fragmentation"); + std::cout << "PASS: PCM callback fragmentation preserves sample bytes\n"; +} diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 165231685..146881891 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -2314,7 +2314,7 @@ class FFMpeg { if (wt_stream_len > len) { wt_stream_len = len; } // mix audio steam from given index - mixer(stream, audio_buf + audio_buf_index, len, volume); + mixer(stream, audio_buf + audio_buf_index, wt_stream_len, volume); // update len -= wt_stream_len; From 4685fd99ac92413419780cc5aaba054c66fb192e Mon Sep 17 00:00:00 2001 From: defisym Date: Wed, 4 Nov 2026 07:04:56 +0000 Subject: [PATCH 07/12] FFMpeg: keep Send First decoding and distinguish waiting from errors Preserve the original Send First branches, queue-wait early return, pending-packet retry and packet unref placement. Send the already computed nullable input pointer at EOF and keep the existing packet argument passed to receive wrappers. Use AVERROR_EOF for decoder completion and a nonnegative queue waiting state, keeping EIO/EPERM errors distinct. Propagate read failures and defer a source EAGAIN to the next video tick without spinning or reporting playback failure. Reset pending/finish state on seek. Audio timestamp and filter/resampler changes remain in the audio-pipeline commit; this commit keeps the original wrapper contract and timestamp placement. Add real FFmpeg-call regressions for Send First ordering, queue-wait early return without codec calls, source EAGAIN recovery, IO EIO and codec EPERM propagation. Shared playback/EOF, audio tails, delayed audio and memory-source regressions pass under ASan/UBSan. --- .../FFMpeg/tests/DecoderProtocolTest.cpp | 82 +++++++++++++++++++ Extensions/_3rdLib/FFMpeg.h | 14 +++- Extensions/_3rdLib/PacketQueue.h | 3 +- 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 Extensions/FFMpeg/tests/DecoderProtocolTest.cpp diff --git a/Extensions/FFMpeg/tests/DecoderProtocolTest.cpp b/Extensions/FFMpeg/tests/DecoderProtocolTest.cpp new file mode 100644 index 000000000..891441e16 --- /dev/null +++ b/Extensions/FFMpeg/tests/DecoderProtocolTest.cpp @@ -0,0 +1,82 @@ +#include "FFMpeg.h" +#include +#include +#include +#include + +// Linker wrappers inject source/codec errors without adding production test hooks. +struct Probe { + bool active = false, queuedVideo = false, receivedBeforeSend = false; + int readError = 0, sendError = 0, reads = 0, sends = 0, receives = 0; +} probe; + +extern "C" int __real_av_read_frame(AVFormatContext*, AVPacket*); +extern "C" int __real_avcodec_send_packet(AVCodecContext*, const AVPacket*); +extern "C" int __real_avcodec_receive_frame(AVCodecContext*, AVFrame*); + +extern "C" int __wrap_av_read_frame(AVFormatContext* context, AVPacket* packet) { + if (probe.active) { + ++probe.reads; + if (probe.readError) return std::exchange(probe.readError, 0); + } + const int result = __real_av_read_frame(context, packet); + if (probe.active && result >= 0 + && context->streams[packet->stream_index]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) + probe.queuedVideo = true; + return result; +} + +extern "C" int __wrap_avcodec_send_packet(AVCodecContext* context, const AVPacket* packet) { + if (probe.active && context->codec_type == AVMEDIA_TYPE_VIDEO) { + ++probe.sends; + probe.queuedVideo = false; + if (probe.sendError) return std::exchange(probe.sendError, 0); + } + return __real_avcodec_send_packet(context, packet); +} + +extern "C" int __wrap_avcodec_receive_frame(AVCodecContext* context, AVFrame* frame) { + if (probe.active && context->codec_type == AVMEDIA_TYPE_VIDEO) { + ++probe.receives; + if (probe.queuedVideo) probe.receivedBeforeSend = true; + } + return __real_avcodec_receive_frame(context, frame); +} + +void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +int main(int argc, char** argv) { + check(argc == 2, "audio/video fixture required"); + FFMpegOptions options; + options.flag = FFMpegFlag_ForceNoAudio; + options.SetThreadCount(2); + for (int scenario = 0; scenario < 4; ++scenario) { + probe = {}; + FFMpeg player(std::string(argv[1]), options); + player.set_audioTempo(1); + int frames = 0; + auto frame = [&](const unsigned char*, int, int) { ++frames; }; + probe.readError = scenario == 1 ? AVERROR(EAGAIN) : scenario == 2 ? AVERROR(EIO) : 0; + probe.sendError = scenario == 3 ? AVERROR(EPERM) : 0; + probe.active = true; + const int result = player.get_nextFrame(frame); + if (scenario == 1) { + check(result == 0 && frames == 0 && probe.reads == 1 + && probe.sends == 0 && probe.receives == 0 && !player.get_finishState(), + "temporary source starvation defers one tick without finishing or spinning"); + } else if (scenario >= 2) { + const int error = scenario == 2 ? AVERROR(EIO) : AVERROR(EPERM); + check(result == error && result != END_OF_QUEUE && frames == 0 && !player.get_finishState(), + "real IO/codec errors remain distinct from queue states and EOF"); + } + if (scenario <= 1) { + for (int budget = 0; frames == 0 && budget < 100; ++budget) player.get_nextFrame(frame); + check(frames == 1 && probe.sends > 0 && !probe.receivedBeforeSend, + "queued packets are submitted before receiving; playback resumes after EAGAIN"); + } + probe.active = false; + } + std::cout << "PASS: Send First, deferred input, IO/codec error propagation and EOF separation\n"; +} diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 146881891..781c96c15 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -109,7 +109,7 @@ constexpr auto MAX_AUDIOQ_SIZE = 5 * 16 * 1024; constexpr auto MAX_VIDEOQ_SIZE = 5 * 256 * 1024; // Queue state -constexpr auto END_OF_QUEUE = -5; +constexpr auto END_OF_QUEUE = AVERROR_EOF; // time base constexpr AVRational time_base_q = { 1, AV_TIME_BASE }; @@ -1193,7 +1193,8 @@ class FFMpeg { int response = decode(); if (response == QUEUE_WAITING) { - fill_queueonce(); + const int read = fill_queueonce(); + if (read < 0 && read != AVERROR_EOF) return read; continue; } @@ -1352,6 +1353,9 @@ class FFMpeg { return decode_videoFrame(pVLastFrame); }); + // The source can temporarily have no data. Retry on the next tick. + if (response == AVERROR(EAGAIN)) return 0; + if (response < 0) { //auto e1 = AVERROR_EOF; //auto e2 = AVERROR(EINVAL); @@ -1387,7 +1391,7 @@ class FFMpeg { AVCodecContext* pCodecContext, AVPacket* pPacket, AVFrame* pFrame, AVFrame* pLastFrame, Decoder decoder) { int bRespond = true; - + if (!bSendEAgain) { bRespond = queue.get(pPacket, bBlockState); @@ -1401,7 +1405,7 @@ class FFMpeg { // Supply raw packet data as input to a decoder // https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html#ga58bc4bf1e0ac59e27362597e467efff3 - int response = avcodec_send_packet(pCodecContext, pPacket); + int response = avcodec_send_packet(pCodecContext, pInputPacket); bSendEAgain = (response == AVERROR(EAGAIN)); if (response < 0 && !bSendEAgain && response != AVERROR_EOF) { @@ -1711,6 +1715,8 @@ class FFMpeg { bReadFinish = false; bAudioFinish = false; bVideoFinish = false; + bVideoSendEAgain = bAudioSendEAgain = false; + bFinish = false; } static inline double get_curTime() { diff --git a/Extensions/_3rdLib/PacketQueue.h b/Extensions/_3rdLib/PacketQueue.h index 5a885b92c..9864fc413 100644 --- a/Extensions/_3rdLib/PacketQueue.h +++ b/Extensions/_3rdLib/PacketQueue.h @@ -9,7 +9,8 @@ extern "C" { #include #include -constexpr auto QUEUE_WAITING = -1; +// get(): 0 = finished, 1 = packet, 2 = waiting. Keep states distinct from errors. +constexpr auto QUEUE_WAITING = 2; #define QUEUE_SPINLOCK From 6dab60ebf4d8be105f709741c52bb725747350fe Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 5 Nov 2026 22:13:56 +0800 Subject: [PATCH 08/12] FFMpeg: drain audio filters and resampling across EOF and seek Keep receive-frame, submit-filter, then collect-output ordering and KEEP_REF. Append all available PCM, send filter EOF once, drain the resampler and grow storage for accumulated samples. Reset DSP and buffered PCM on seek. Keep forwardFrame's frame-grabbing policy: discard audio packets without inserting them into the playback queue. Create the initial filter graph when absent and explicitly request rebuilds on seek, without assigning a sentinel tempo. Serialize the actual tempo so the filter and sample clock agree. Distinguish byte offsets from millisecond targets, including the negative-flags avformat_seek_file convention. Byte seeks disable time-based preroll trimming and anchor synchronization to the first valid decoded timestamp; independent preview contexts do not consume that pending main-playback anchor. Add standalone audio-seek regressions for native byte seeks, time seek units, default initialization, same-tempo seek after EOF, grab queue isolation and fractional tempo. The original c63af8fd fails four targeted negative checks. The amended audio stage and all four later stages pass ASan/UBSan, as do the final existing PCM, decoder-protocol and shared-core regressions. Distinguish sample-clock startup from a valid timestamp anchor. If initial audio frames lack PTS, account for their input duration and accept the first later valid timestamp. Reset both states on seek. A wrapped real-decoder regression confirms byte-seek anchoring recovers without losing PCM; the previous revision fails this case. --- Extensions/FFMpeg/tests/AudioSeekTest.cpp | 163 +++++++++++++++ Extensions/FFMpeg/tests/run_audio_seek.py | 72 +++++++ Extensions/_3rdLib/FFMpeg.h | 243 ++++++++++++++-------- 3 files changed, 391 insertions(+), 87 deletions(-) create mode 100644 Extensions/FFMpeg/tests/AudioSeekTest.cpp create mode 100644 Extensions/FFMpeg/tests/run_audio_seek.py diff --git a/Extensions/FFMpeg/tests/AudioSeekTest.cpp b/Extensions/FFMpeg/tests/AudioSeekTest.cpp new file mode 100644 index 000000000..3bb3611da --- /dev/null +++ b/Extensions/FFMpeg/tests/AudioSeekTest.cpp @@ -0,0 +1,163 @@ +#include "FFMpeg.h" +#include +#include +#include + +namespace { +bool grabbing = false, checkGrabbed = false; +std::set grabbedAudio; +int submittedGrabbed = 0; +int64_t seekTarget = -1; +bool dropFirstAudioPts = false; +int laterAudioPts = 0; +void check(bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} +int pcm(FFMpeg& player, std::vector& output) { + return player.audio_fillData(output.data(), static_cast(output.size()), std::memset, + [](void* dst, const void* src, size_t size, int) { std::memcpy(dst, src, size); }); +} +bool audible(const std::vector& output) { + return std::any_of(output.begin(), output.end(), [](uint8_t value) { return value != 0; }); +} +} + +extern "C" int __real_av_read_frame(AVFormatContext*, AVPacket*); +extern "C" int __real_avcodec_send_packet(AVCodecContext*, const AVPacket*); +extern "C" int __real_avcodec_receive_frame(AVCodecContext*, AVFrame*); +extern "C" int __real_avformat_seek_file(AVFormatContext*, int, int64_t, int64_t, int64_t, int); +extern "C" int __wrap_av_read_frame(AVFormatContext* context, AVPacket* packet) { + const int result = __real_av_read_frame(context, packet); + if (grabbing && result >= 0 && packet->pos >= 0 + && context->streams[packet->stream_index]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) + grabbedAudio.insert(packet->pos); + return result; +} +extern "C" int __wrap_avcodec_send_packet(AVCodecContext* context, const AVPacket* packet) { + if (checkGrabbed && packet && context->codec_type == AVMEDIA_TYPE_AUDIO + && grabbedAudio.contains(packet->pos)) ++submittedGrabbed; + return __real_avcodec_send_packet(context, packet); +} +extern "C" int __wrap_avcodec_receive_frame(AVCodecContext* context, AVFrame* frame) { + const int result = __real_avcodec_receive_frame(context, frame); + if (result >= 0 && context->codec_type == AVMEDIA_TYPE_AUDIO) { + if (dropFirstAudioPts) { + frame->best_effort_timestamp = AV_NOPTS_VALUE; + dropFirstAudioPts = false; + } else if (frame->best_effort_timestamp != AV_NOPTS_VALUE) ++laterAudioPts; + } + return result; +} +extern "C" int __wrap_avformat_seek_file(AVFormatContext* context, int stream, + int64_t min, int64_t target, int64_t max, int flags) { + seekTarget = target; + return __real_avformat_seek_file(context, stream, min, target, max, flags); +} + +int main(int argc, char** argv) { + check(argc == 4 || argc == 5, "A/V MP4, MPEG-TS, byte offset and optional case required"); + const std::string scenario = argc == 5 ? argv[4] : "all"; + const auto run = [&](const char* name) { return scenario == "all" || scenario == name; }; + av_log_set_level(AV_LOG_ERROR); + FFMpegOptions options; + options.SetThreadCount(1); + const auto frame = [](const unsigned char*, int, int) {}; + std::vector block(4096); + + // Negative flags select a different seek API, not different timestamp units. + if (run("units")) { + FFMpeg player(std::string(argv[1]), options); + check(player.set_videoPosition(500, -AVSEEK_FLAG_BACKWARD) >= 0, "negative-flags time seek"); + AVFormatContext* context = nullptr; + check(avformat_open_input(&context, argv[1], nullptr, nullptr) >= 0, "open reference"); + check(avformat_find_stream_info(context, nullptr) >= 0, "reference stream info"); + const int video = av_find_best_stream(context, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); + const double origin = context->start_time == AV_NOPTS_VALUE ? 0.0 + : static_cast(context->start_time) / AV_TIME_BASE; + const auto expected = static_cast((0.5 + origin) / av_q2d(context->streams[video]->time_base)); + avformat_close_input(&context); + check(seekTarget == expected, "time seek must convert milliseconds to stream ticks"); + } + + // A real byte seek must return audible samples, not discard them until byteOffset/1000 seconds. + if (run("bytes")) for (int flags : {AVSEEK_FLAG_BYTE, -AVSEEK_FLAG_BYTE}) { + FFMpeg player(std::string(argv[2]), options); + player.set_pause(false); + check(pcm(player, block) > 0 && audible(block), "initial filter graph exists at default tempo"); + check(player.set_videoPosition(std::stoll(argv[3]), flags) >= 0, "byte seek accepted"); + const auto provisionalPosition = player.get_videoPosition(); + check(pcm(player, block) > 0 && audible(block), "byte seek retains audible PCM"); + check(provisionalPosition == 0, "byte offset must not be exposed as milliseconds"); + check(player.get_videoPosition() >= 0 && player.get_videoPosition() < 4000, + "byte seek clock anchors to decoded timestamp"); + check(player.set_videoPosition(0, AVSEEK_FLAG_BACKWARD) >= 0, "time rewind after byte seek"); + check(pcm(player, block) > 0 && audible(block), "rewind recreates the filter graph"); + } + + if (run("late-pts")) { + int64_t reference = 0; + for (bool missing : {false, true}) { + FFMpeg player(std::string(argv[2]), options); + player.set_pause(false); + check(player.set_videoPosition(std::stoll(argv[3]), AVSEEK_FLAG_BYTE) >= 0, + "byte seek before missing-PTS test"); + dropFirstAudioPts = missing; + laterAudioPts = 0; + for (int i = 0; i < 10; ++i) check(pcm(player, block) > 0, "missing PTS preserves PCM"); + check(laterAudioPts > 0, "fixture supplies later valid audio timestamps"); + if (!missing) reference = player.get_videoPosition(); + else check(reference > 100 && std::abs(player.get_videoPosition() - reference) <= 1, + "later valid audio PTS must recover the byte-seek anchor"); + } + } + + // Forward grabbing intentionally discards audio, even when it uses the main demuxer. + if (run("grab")) { + FFMpeg player(std::string(argv[1]), options); + check(player.set_videoPosition(0, AVSEEK_FLAG_BACKWARD) >= 0, "rewind before grab"); + grabbing = true; + check(player.goto_videoPosition(1000, frame) >= 0, "grab target frame"); + grabbing = false; + check(!grabbedAudio.empty(), "grab fixture must cross audio packets"); + player.set_pause(false); + checkGrabbed = true; + bool resumed = false; + // Later clock commits can insert silence before the first post-grab sample. + for (int budget = 0; budget < 128 && !resumed; ++budget) + resumed = pcm(player, block) > 0 && audible(block); + check(resumed, "playback resumes after grab"); + checkGrabbed = false; + check(submittedGrabbed == 0, "grabbed audio must not enter the playback queue"); + } + + // Drain fully, then seek with unchanged tempo: an EOF filter must be recreated. + if (run("eof")) { + FFMpeg player(std::string(argv[1]), options); + player.set_pause(false); + bool ended = false; + for (int budget = 0; budget < 1000; ++budget) { + if (pcm(player, block) == 0) { ended = true; break; } + } + check(ended, "audio reaches EOF"); + check(player.set_videoPosition(500, AVSEEK_FLAG_BACKWARD) >= 0, "time seek after EOF"); + check(pcm(player, block) > 0 && audible(block), "EOF seek resumes audible audio"); + } + if (run("tempo")) { + FFMpeg player(std::string(argv[1]), options); + player.set_audioTempo(1.23f); + player.set_pause(false); + int64_t bytes = 0; + bool ended = false; + for (int budget = 0; budget < 1000; ++budget) { + const int count = pcm(player, block); + if (!count) { ended = true; break; } + bytes += count; + } + check(ended, "fractional-tempo audio reaches EOF"); + // The fixture is four seconds. Allow codec padding and atempo window rounding, + // but reject rounding the requested 1.23 to 1.2 (about 3,600 extra samples). + check(std::abs(bytes / 4.0 - 4.0 * TARGET_SAMPLE_RATE / 1.23) < 2048, + "filter must use the same fractional tempo as the audio clock"); + } + std::cout << "PASS: audio seek regression (" << scenario << ")\n"; +} diff --git a/Extensions/FFMpeg/tests/run_audio_seek.py b/Extensions/FFMpeg/tests/run_audio_seek.py new file mode 100644 index 000000000..0f028f0f9 --- /dev/null +++ b/Extensions/FFMpeg/tests/run_audio_seek.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Exercise the audio-pipeline commit independently of later playback-control APIs.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +p = argparse.ArgumentParser(description=__doc__) +p.add_argument('--ffmpeg-include', required=True, type=Path) +p.add_argument('--ffmpeg-lib', required=True, type=Path) +p.add_argument('--sdl-include', required=True, type=Path) +p.add_argument('--sdl-lib', required=True, type=Path) +p.add_argument('--build', type=Path, default=Path('/tmp/ffmpeg-audio-seek-tests')) +p.add_argument('--baseline-include', type=Path, + help='Optional directory containing the original c63af8fd FFMpeg.h for negative checks') +a = p.parse_args() +root = Path(__file__).resolve().parents[3] +build = a.build.resolve() +build.mkdir(parents=True, exist_ok=True) +env = dict(os.environ, ASAN_OPTIONS='detect_leaks=0') + + +def run(command, **kwargs): + return subprocess.run(list(map(str, command)), check=True, **kwargs) + + +media, transport = build / 'av.mp4', build / 'av.ts' +for output, video, audio in [(media, 'mpeg4', 'aac'), (transport, 'mpeg2video', 'mp2')]: + run(['ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', + 'testsrc2=size=64x48:rate=10:duration=4', '-f', 'lavfi', '-i', + 'sine=frequency=1000:sample_rate=48000:duration=4', '-c:v', video, + '-g', '10', '-bf', '2', '-c:a', audio, '-shortest', output]) +packets = json.loads(run(['ffprobe', '-v', 'error', '-select_streams', 'v:0', + '-show_packets', '-show_entries', 'packet=pts_time,pos,flags', + '-of', 'json', transport], capture_output=True, text=True).stdout)['packets'] +offset = next(int(packet['pos']) for packet in packets + if 'K' in packet['flags'] and float(packet['pts_time']) >= 2.5) + + +def compile_test(name, baseline=None): + binary = build / name + includes = ([f'-I{baseline}'] if baseline else []) + [ + f'-I{root / "Extensions/_3rdLib"}', f'-I{root / "Extensions/_DeLib"}', + f'-I{a.ffmpeg_include}', f'-I{a.sdl_include}'] + wrappers = ['-Wl,--wrap=' + symbol for symbol in + ['av_read_frame', 'avcodec_send_packet', 'avcodec_receive_frame', 'avformat_seek_file']] + run(['c++', '-std=c++20', '-g', '-fsanitize=address,undefined', '-fno-omit-frame-pointer', + *includes, Path(__file__).with_name('AudioSeekTest.cpp'), + root / 'Extensions/_3rdLib/FFMpegAdapter.cpp', f'-L{a.ffmpeg_lib}', f'-L{a.sdl_lib}', + '-lavformat', '-lavcodec', '-lavfilter', '-lavutil', '-lswscale', '-lswresample', + '-lSDL2', f'-Wl,-rpath,{a.ffmpeg_lib}', f'-Wl,-rpath,{a.sdl_lib}', + *wrappers, '-o', binary]) + return binary + + +binary = compile_test('AudioSeekTest') +run([binary, media, transport, offset], timeout=30, env=env) +if a.baseline_include: + baseline = compile_test('AudioSeekBefore', a.baseline_include) + expected = { + 'units': 'time seek must convert milliseconds to stream ticks', + 'bytes': 'byte seek retains audible PCM', + 'grab': 'grabbed audio must not enter the playback queue', + 'tempo': 'filter must use the same fractional tempo as the audio clock', + } + for case, message in expected.items(): + result = subprocess.run(list(map(str, [baseline, media, transport, offset, case])), + timeout=30, env=env, capture_output=True, text=True) + if result.returncode == 0 or message not in result.stderr: + raise RuntimeError(f'Unexpected baseline result for {case}: {result.stderr}') + print(f'CONFIRMED: original commit fails {case}: {message}', flush=True) diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 781c96c15..58711e365 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -73,11 +73,8 @@ extern "C" { // SDL #include #include -#include // SDL audio -constexpr auto SDL_AUDIO_BUFFER_SIZE = SDLGeneral_BufferSize; - constexpr auto MAX_AUDIO_FRAME_SIZE = 192000; constexpr auto AUDIO_BUFFER_SIZE = (MAX_AUDIO_FRAME_SIZE * 3) / 2; @@ -370,7 +367,7 @@ class FFMpeg { AVFrame* pAFilterFrame = nullptr; #endif // AUDIO_TEMPO - float atempo = -1; + float atempo = ATEMPO_DEFAULT; double tempoTimer = 0.0; const AVCodec* pVCodec = nullptr; @@ -408,6 +405,13 @@ class FFMpeg { SwsContext* swsContext = nullptr; bool bUpdateSwr = true; + bool audioFilterDraining = false; + bool audioClockStarted = false; + bool audioTimestampAnchored = false; + double audioUnanchoredInputDuration = 0; + double audioDiscardBefore = 0; + bool audioDiscardEnabled = true; + bool seekClockPending = false; SwrContext* swrContext = nullptr; AVChannelLayout targetChannelLayout = {}; @@ -439,6 +443,7 @@ class FFMpeg { uint8_t* audio_buf = nullptr; // decoded audio data length unsigned int audio_buf_size = 0; + int audio_buf_capacity = AUDIO_BUFFER_SIZE; // total audio data write into stream unsigned int audio_buf_index = 0; @@ -594,9 +599,7 @@ class FFMpeg { this->bAudioCallbackPause = true; this->audioQueue.pause(); - SDL_AtomicLock(&audioLock); - - int ret; + int ret = 0; // release avfilter_graph_free(&filter_graph); @@ -706,7 +709,10 @@ class FFMpeg { } memset(audio_buf, 0, AUDIO_BUFFER_SIZE); - bUpdateSwr = true; + bUpdateSwr = true; + audioFilterDraining = false; + audio_buf_size = audio_buf_index = 0; + swr_free(&swrContext); /* Print summary of the sink buffer * Note: args buffer is reused to store channel layout string */ @@ -726,9 +732,7 @@ class FFMpeg { avfilter_inout_free(&inputs); avfilter_inout_free(&outputs); - SDL_AtomicUnlock(&audioLock); - - this->audioQueue.restore(); + this->audioQueue.restore(); this->bAudioCallbackPause = false; return ret; @@ -1019,8 +1023,8 @@ class FFMpeg { audio_buf = new uint8_t [AUDIO_BUFFER_SIZE]; memset(audio_buf, 0, AUDIO_BUFFER_SIZE); - set_audioTempo(ATEMPO_DEFAULT); - av_channel_layout_default(&targetChannelLayout, TARGET_CHANNEL_NUMBER); + av_channel_layout_default(&targetChannelLayout, TARGET_CHANNEL_NUMBER); + set_audioTempo(ATEMPO_DEFAULT); } #pragma endregion @@ -1039,17 +1043,19 @@ class FFMpeg { } inline int64_t get_protectedTimeStamp(const int64_t ms, const int flags = SeekFlags) const { + // Negative flags select avformat_seek_file; inspect the actual flag bits. + const int seekFlags = flags < 0 ? -flags : flags; // seek by byte, do nothing // If flags contain AVSEEK_FLAG_BYTE, then all timestamps are in bytes and are the file position // (this may not be supported by all demuxers). - if ((flags & AVSEEK_FLAG_BYTE) == AVSEEK_FLAG_BYTE) { + if ((seekFlags & AVSEEK_FLAG_BYTE) == AVSEEK_FLAG_BYTE) { return ms; } // seek by frame, do conversion // If flags contain AVSEEK_FLAG_FRAME, then all timestamps are in frames in the stream with stream_index // (this may not be supported by all demuxers). - if ((flags & AVSEEK_FLAG_FRAME) == AVSEEK_FLAG_FRAME) { + if ((seekFlags & AVSEEK_FLAG_FRAME) == AVSEEK_FLAG_FRAME) { const auto protectedTimeStamp = static_cast(get_protectedTimeInSecond(ms) + timelineOriginSeconds) / av_q2d(pVideoStream->time_base); return static_cast(protectedTimeStamp); @@ -1464,6 +1470,11 @@ class FFMpeg { videoPts = pFrame->best_effort_timestamp == AV_NOPTS_VALUE ? videoClock : static_cast(pFrame->best_effort_timestamp) * av_q2d(pVideoStream->time_base) - timelineOriginSeconds; + if (seekClockPending && pVCodecContext == this->pVCodecContext + && pFrame->best_effort_timestamp != AV_NOPTS_VALUE) { + reset_sync(videoPts); + seekClockPending = false; + } #ifdef _CONSOLE if (bJumped) { @@ -1497,97 +1508,139 @@ class FFMpeg { AVFrame* pFrame, AVFrame* pLastFrame) { int response = avcodec_receive_frame(pACodecContext, pFrame); - if (response == AVERROR(EAGAIN) || response == AVERROR_EOF) { + if (response == AVERROR(EAGAIN)) { av_frame_unref(pFrame); return response; } - if (response < 0) { + if (response < 0 && response != AVERROR_EOF) { av_frame_unref(pFrame); return response; } + const bool decoderEnd = response == AVERROR_EOF; + if (!decoderEnd && !audioTimestampAnchored) { + if (!audioClockStarted) { + audioClock = audioDiscardBefore; + audioClockStarted = true; + } + if (pFrame->best_effort_timestamp != AV_NOPTS_VALUE) { + // A missing first PTS must not prevent a later frame anchoring the clock. + // Account for input already buffered by the filter before that valid PTS. + const double firstPts = pFrame->best_effort_timestamp * av_q2d(pAudioStream->time_base) + - timelineOriginSeconds - audioUnanchoredInputDuration; + audioClock += firstPts - audioDiscardBefore; + audioTimestampAnchored = true; + if (seekClockPending) { + reset_sync(audioClock); + seekClockPending = false; + } + } else { + audioUnanchoredInputDuration += static_cast(pFrame->nb_samples) / pFrame->sample_rate; + } + } + // frame to process auto pBaseFrame = pFrame; + int totalAudioSize = 0; - // a while loop is wrapped in, `av_buffersink_get_frame` to get filtered frame + // Submit the decoded frame first, then collect all available filter output. #ifdef AUDIO_TEMPO pBaseFrame = pAFilterFrame; - response = av_buffersrc_add_frame_flags(buffersrc_ctx, pFrame, AV_BUFFERSRC_FLAG_KEEP_REF); + if (!audioFilterDraining) { + response = av_buffersrc_add_frame_flags(buffersrc_ctx, + decoderEnd ? nullptr : pFrame, AV_BUFFERSRC_FLAG_KEEP_REF); - if (response < 0) { - av_frame_unref(pFrame); - return response; + if (response < 0) { + av_frame_unref(pFrame); + return response; + } + audioFilterDraining = decoderEnd; } +#endif // AUDIO_TEMPO while (true) { +#ifdef AUDIO_TEMPO response = av_buffersink_get_frame(buffersink_ctx, pAFilterFrame); - if (response == AVERROR(EAGAIN) || response == AVERROR_EOF) { - break; - } - - if (response < 0) { + if (response == AVERROR(EAGAIN)) { break; } + const bool end = response == AVERROR_EOF; + if (response < 0 && !end) { av_frame_unref(pAFilterFrame); av_frame_unref(pFrame); + return response; } -#endif // AUDIO_TEMPO - - if (pAPacket != nullptr && pAPacket->pts != AV_NOPTS_VALUE) { - audioClock = av_q2d(pAudioStream->time_base) * static_cast(pAPacket->pts); -#ifdef _CONSOLE - if (bJumped) { - printf("Cur audio clock: %f\n", audioClock); - } -#endif - } +#else + const bool end = decoderEnd; +#endif // AUDIO_TEMPO + if (end && !swrContext) { break; } // update context to avoid crash - if (bUpdateSwr) { + if (!end && bUpdateSwr) { init_swrContext(&pBaseFrame->ch_layout, static_cast(pBaseFrame->format), pBaseFrame->sample_rate); } - // sample after convert: a * b / c - // https://ffmpeg.org/doxygen/7.0/resample_audio_8c-example.html - const int dst_nb_samples = - static_cast(av_rescale_rnd(swr_get_delay(swrContext, pBaseFrame->sample_rate) - + pBaseFrame->nb_samples, - TARGET_SAMPLE_RATE, pBaseFrame->sample_rate, static_cast(1))); + const auto channels = targetChannelLayout.nb_channels; + const auto pcm_bytes = channels * av_get_bytes_per_sample(TARGET_SAMPLE_FORMAT); + const int dst_nb_samples = end ? 4096 : swr_get_out_samples(swrContext, pBaseFrame->nb_samples); + if (dst_nb_samples < 0 || dst_nb_samples > (INT_MAX - totalAudioSize) / pcm_bytes) { + av_frame_unref(pBaseFrame); + av_frame_unref(pFrame); + return AVERROR(EINVAL); + } + const int required = (std::max)(totalAudioSize + dst_nb_samples * pcm_bytes, 4); + if (required > audio_buf_capacity) { + auto next = std::make_unique(required); + std::memcpy(next.get(), audio_buf, totalAudioSize); + delete[] audio_buf; + audio_buf = next.release(); + audio_buf_capacity = required; + } - // return number of samples output per channel - const int nb = swr_convert(swrContext, &audio_buf, dst_nb_samples, - pBaseFrame->data, pBaseFrame->nb_samples); + // Append each output block; flush remaining resampler samples at filter EOF. + auto pOutput = audio_buf + totalAudioSize; + const int nb = swr_convert(swrContext, &pOutput, dst_nb_samples, + end ? nullptr : pBaseFrame->extended_data, end ? 0 : pBaseFrame->nb_samples); +#ifdef AUDIO_TEMPO + av_frame_unref(pAFilterFrame); +#endif // AUDIO_TEMPO + if (nb < 0) { + av_frame_unref(pFrame); + return nb; + } + if (nb == 0 && end) { break; } - const auto channels = targetChannelLayout.nb_channels; - const auto pcm_bytes = 2 * channels; const auto audioSize = channels * nb * av_get_bytes_per_sample(TARGET_SAMPLE_FORMAT); - const auto clockOffset = static_cast(audioSize) / (static_cast(pcm_bytes) * static_cast(pACodecContext->sample_rate)); - - audioPts = audioClock; - audioClock += clockOffset; + const auto clockOffset = static_cast(nb) * atempo / TARGET_SAMPLE_RATE; + const double blockPts = audioClock; + if (totalAudioSize == 0) { audioPts = blockPts; } + audioClock += clockOffset; #ifdef UPDATE_AUDIOCLOCK_WHEN_SEEKING - prevAudioClockOffset = clockOffset; + prevAudioClockOffset = clockOffset; +#endif + const double step = static_cast(atempo) / TARGET_SAMPLE_RATE; + const int discard = audioDiscardEnabled + ? static_cast(Range(std::ceil((audioDiscardBefore - blockPts) / step), + 0.0, static_cast(nb))) : 0; + const int bytes = audioSize - discard * pcm_bytes; + if (discard) { + std::memmove(pOutput, pOutput + discard * pcm_bytes, bytes); + if (totalAudioSize == 0) { audioPts += discard * step; } + } + totalAudioSize += bytes; +#ifndef AUDIO_TEMPO + if (!end) { break; } #endif - -#ifdef AUDIO_TEMPO - av_frame_unref(pAFilterFrame); -#endif // AUDIO_TEMPO - - av_frame_unref(pFrame); - - return audioSize; - -#ifdef AUDIO_TEMPO } +#ifdef AUDIO_TEMPO av_frame_unref(pAFilterFrame); -#endif // AUDIO_TEMPO - +#endif // AUDIO_TEMPO av_frame_unref(pFrame); - - return -1; + if (totalAudioSize > 0) { return totalAudioSize; } + return response == 0 ? AVERROR(EAGAIN) : response; } // ------------------------------------ @@ -2059,11 +2112,11 @@ class FFMpeg { const int steam_index = get_streamIndex(); if (steam_index == -1) { return -1; } - // protection - ms = (flags & AVSEEK_FLAG_BYTE) != AVSEEK_FLAG_BYTE - ? Range(ms, static_cast(0), get_videoDuration()) - : ms; - const auto targetPts = static_cast(ms) / 1000.0; + const int seekFlags = flags < 0 ? -flags : flags; + const bool byteSeek = (seekFlags & AVSEEK_FLAG_BYTE) != 0; + if (!byteSeek) ms = Range(ms, static_cast(0), get_videoDuration()); + // A byte offset has no known time. Anchor it to the first decoded PTS. + const double targetPts = byteSeek ? 0.0 : static_cast(ms) / 1000.0; int response = seekFrame(pFormatContext, steam_index, ms, flags); @@ -2079,8 +2132,16 @@ class FFMpeg { avcodec_flush_buffers(pACodecContext); } - reset_sync(targetPts); - reset_finishState(); + audio_buf_size = audio_buf_index = 0; + audioDiscardBefore = targetPts; + audioDiscardEnabled = !byteSeek; + seekClockPending = byteSeek; + audioClockStarted = false; + audioTimestampAnchored = false; + audioUnanchoredInputDuration = 0; + if (!bNoAudio) update_audioTempo(atempo, true); + reset_sync(targetPts); + reset_finishState(); #ifdef _CONSOLE bJumped = true; @@ -2222,7 +2283,12 @@ class FFMpeg { // Audio control // ------------------------------------ - inline void set_audioTempo(float atempo) { + inline void set_audioTempo(float value) { + SpinLockHelper lockHelper(&audioLock); + update_audioTempo(value); + } +private: + inline void update_audioTempo(float atempo, bool rebuild = false) { #ifndef AUDIO_TEMPO this->atempo = ATEMPO_DEFAULT; #else @@ -2231,7 +2297,8 @@ class FFMpeg { : ATEMPO_DEFAULT; // won't update if param is the same - if (NearlyEqualFLT(this->atempo, newTempo)) { + if (!rebuild && NearlyEqualFLT(this->atempo, newTempo) + && (bNoAudio || filter_graph != nullptr)) { return; } @@ -2259,16 +2326,18 @@ class FFMpeg { } while ((tempo *= 2) < 0.5f); } - fliters += std::format("atempo={:1.1f}", tempo); + fliters += std::format("atempo={}", tempo); if (strlen(filters_descr) != 0) { fliters += std::format(",{}", filters_descr); } - init_audioFilters(pFormatContext, pACodecContext, filter_graph, fliters.c_str()); + const int result = init_audioFilters(pFormatContext, pACodecContext, filter_graph, fliters.c_str()); + if (result < 0) throw FFMpegException(result); #endif // AUDIO_TEMPO } +public: inline float get_audioTempo() const { return this->atempo; } @@ -2302,14 +2371,14 @@ class FFMpeg { }); // If error, output silence. - if (response < 0) { - audio_buf_size = SDL_AUDIO_BUFFER_SIZE; - memset(audio_buf, 0, AUDIO_BUFFER_SIZE); - } + if (response < 0) { + audio_buf_size = audio_buf_index = 0; + return static_cast(audio_stream_len) - len; + } else { // return the audio data length - audio_buf_size = response; - } + audio_buf_size = response; + } // reset write index audio_buf_index = 0; @@ -2328,6 +2397,6 @@ class FFMpeg { audio_buf_index += wt_stream_len; } - return 0; - } + return static_cast(audio_stream_len) - len; + } }; From 1895bc010e72a5ec1b3760f182f7ec32659fe44e Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 5 Nov 2026 22:14:02 +0800 Subject: [PATCH 09/12] FFMpeg: keep playback clocks continuous through pause and tempo changes Use monotonic time and the submitted PCM timeline, including delayed-audio silence. Keep decoder/filter lookahead out of the playback clock. Clamp interpolation to the actual submitted source-time span, including partial and empty callbacks at EOF or starvation, rather than the requested buffer size. Snapshot each callback's tempo so a later tempo change cannot retime PCM already submitted to the audio device. Preserve the remaining callback span across pause/resume. This remains a software estimate; device latency requires platform playback-position feedback. Retain set_pause(..., false) as a state-only update. Publish seek callbacks outside the audio lock so synchronized controls can reenter. Remove obsolete timer fields. Add deterministic clock regressions at 0.25x, 1x, 1.23x and 4x with 4/512/4096 byte callbacks, partial/empty EOF callbacks and tempo changes during a callback. The previous clocks revision fails the EOF-bound and tempo-continuity tests. All four affected stages and the existing final shared suite pass ASan/UBSan. --- Extensions/FFMpeg/tests/AudioClockTest.cpp | 82 ++++++++++ Extensions/FFMpeg/tests/run_audio_clock.py | 59 +++++++ Extensions/_3rdLib/FFMpeg.h | 170 ++++++++++----------- 3 files changed, 220 insertions(+), 91 deletions(-) create mode 100644 Extensions/FFMpeg/tests/AudioClockTest.cpp create mode 100644 Extensions/FFMpeg/tests/run_audio_clock.py diff --git a/Extensions/FFMpeg/tests/AudioClockTest.cpp b/Extensions/FFMpeg/tests/AudioClockTest.cpp new file mode 100644 index 000000000..a7e3a5ad4 --- /dev/null +++ b/Extensions/FFMpeg/tests/AudioClockTest.cpp @@ -0,0 +1,82 @@ +#include "FFMpeg.h" +#include +#include + +// The runner permits access to the internal clock for this test only; production +// headers gain no test hooks. A deterministic timer avoids scheduler-dependent checks. +int64_t testTime = 1000000; +extern "C" int64_t __wrap_av_gettime_relative() { return testTime; } +void check(bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} +void near(double actual, double expected, const char* message) { + if (std::abs(actual - expected) > 1e-6) { + std::cerr << "actual=" << actual << " expected=" << expected << '\n'; + throw std::runtime_error(message); + } +} +int fill(FFMpeg& player, std::vector& block) { + return player.audio_fillData(block.data(), static_cast(block.size()), std::memset, + [](void* dst, const void* src, size_t count, int) { std::memcpy(dst, src, count); }); +} + +int main(int argc, char** argv) { + check(argc == 2 || argc == 3, "four-second A/V fixture and optional scenario required"); + const std::string scenario = argc == 3 ? argv[2] : "all"; + const auto run = [&](const char* name) { return scenario == "all" || scenario == name; }; + FFMpegOptions options; + options.SetThreadCount(1); + const double bytesPerSecond = TARGET_SAMPLE_RATE * TARGET_CHANNEL_NUMBER * 2; + + if (run("buffered")) for (float tempo : {0.25f, 1.0f, 1.23f, 4.0f}) { + for (int size : {4, 512, 4096}) { + FFMpeg player(std::string(argv[1]), options); + player.set_audioTempo(tempo); + player.set_pause(false); + std::vector block(size); + double submitted = 0; + for (int i = 0; i < 5; ++i) { + const int count = fill(player, block); + check(count == size, "fixture must fill callback"); + near(player.get_audioClock(), submitted, "buffered PCM must not advance playback clock"); + submitted += count * tempo / bytesPerSecond; + testTime += 1000000; + near(player.get_audioClock(), submitted, "clock stops at submitted samples"); + } + } + } + + if (run("partial")) { + FFMpeg player(std::string(argv[1]), options); + player.set_pause(false); + std::vector block(1000000); + const int count = fill(player, block); + check(count > 0 && count < static_cast(block.size()), "callback must end partially filled at EOF"); + const double end = count / bytesPerSecond; + testTime += 10000000; + near(player.get_audioClock(), end, "unfilled EOF tail must not advance media clock"); + check(fill(player, block) == 0, "EOF callback returns no PCM"); + testTime += 10000000; + near(player.get_audioClock(), end, "empty callback must not advance media clock"); + } + + if (run("tempo-pause")) { + FFMpeg player(std::string(argv[1]), options); + player.set_pause(false); + std::vector block(4096); + check(fill(player, block) == 4096, "initial callback"); + testTime += 5000; + near(player.get_audioClock(), 0.005, "initial interpolation"); + player.set_audioTempo(2.0f); + near(player.get_audioClock(), 0.005, "tempo must not retime already submitted PCM"); + testTime += 5000; + near(player.get_audioClock(), 0.010, "old callback retains its tempo"); + player.set_pause(true); + testTime += 500000; + near(player.get_audioClock(), 0.010, "pause freezes clock"); + player.set_pause(false); + testTime += 5000; + near(player.get_audioClock(), 0.015, "resume preserves remaining callback duration"); + } + std::cout << "PASS: submitted-audio clock (" << scenario << ")\n"; +} diff --git a/Extensions/FFMpeg/tests/run_audio_clock.py b/Extensions/FFMpeg/tests/run_audio_clock.py new file mode 100644 index 000000000..efdee8157 --- /dev/null +++ b/Extensions/FFMpeg/tests/run_audio_clock.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Test the clocks commit's estimator without a physical audio device.""" +import argparse +import os +from pathlib import Path +import subprocess + +p = argparse.ArgumentParser(description=__doc__) +for name in ['ffmpeg-include', 'ffmpeg-lib', 'sdl-include', 'sdl-lib']: + p.add_argument('--' + name, required=True, type=Path) +p.add_argument('--build', type=Path, default=Path('/tmp/ffmpeg-audio-clock-tests')) +p.add_argument('--baseline-include', type=Path, + help='Optional directory containing the previous clocks-commit FFMpeg.h') +a = p.parse_args() +root = Path(__file__).resolve().parents[3] +build = a.build.resolve() +build.mkdir(parents=True, exist_ok=True) +env = dict(os.environ, ASAN_OPTIONS='detect_leaks=0') + + +def run(command, **kwargs): + return subprocess.run(list(map(str, command)), check=True, **kwargs) + + +media = build / 'clock.mp4' +run(['ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', + 'testsrc2=size=64x48:rate=10:duration=4', '-f', 'lavfi', '-i', + 'sine=frequency=1000:sample_rate=48000:duration=4', '-c:v', 'mpeg4', + '-bf', '2', '-c:a', 'aac', '-shortest', media]) + + +def compile_test(name, baseline=None): + binary = build / name + includes = ([f'-I{baseline}'] if baseline else []) + [ + f'-I{root / "Extensions/_3rdLib"}', f'-I{root / "Extensions/_DeLib"}', + f'-I{a.ffmpeg_include}', f'-I{a.sdl_include}'] + # Expose the internal estimator to tests without adding a production API. + run(['c++', '-std=c++20', '-fno-access-control', '-g', '-fsanitize=address,undefined', + '-fno-omit-frame-pointer', *includes, Path(__file__).with_name('AudioClockTest.cpp'), + root / 'Extensions/_3rdLib/FFMpegAdapter.cpp', f'-L{a.ffmpeg_lib}', f'-L{a.sdl_lib}', + '-lavformat', '-lavcodec', '-lavfilter', '-lavutil', '-lswscale', '-lswresample', + '-lSDL2', f'-Wl,-rpath,{a.ffmpeg_lib}', f'-Wl,-rpath,{a.sdl_lib}', + '-Wl,--wrap=av_gettime_relative', '-o', binary]) + return binary + + +binary = compile_test('AudioClockTest') +run([binary, media], timeout=30, env=env) +if a.baseline_include: + baseline = compile_test('AudioClockBefore', a.baseline_include) + for case, message in { + 'partial': 'unfilled EOF tail must not advance media clock', + 'tempo-pause': 'tempo must not retime already submitted PCM', + }.items(): + result = subprocess.run(list(map(str, [baseline, media, case])), timeout=30, + env=env, capture_output=True, text=True) + if result.returncode == 0 or message not in result.stderr: + raise RuntimeError(f'Unexpected baseline result for {case}: {result.stderr}') + print(f'CONFIRMED: previous clocks commit fails {case}: {message}', flush=True) diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index 58711e365..ff3ef2f62 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -292,7 +292,6 @@ class FFMpeg { double audioClock = 0; double videoClock = 0; - double frameTimer = -1; double frameLastPts = 0; double frameLastDelay = 40e-3; @@ -368,7 +367,8 @@ class FFMpeg { #endif // AUDIO_TEMPO float atempo = ATEMPO_DEFAULT; - double tempoTimer = 0.0; + double externalPosition = 0.0; + double externalTimer = 0.0; const AVCodec* pVCodec = nullptr; AVCodecParameters* pVCodecParameters = nullptr; @@ -448,6 +448,8 @@ class FFMpeg { unsigned int audio_buf_index = 0; double audioCallbackStartPts = 0; + double audioOutputClock = 0; + float audioCallbackTempo = ATEMPO_DEFAULT; double audioCallbackStartTimer = 0; // output @@ -1664,49 +1666,19 @@ class FFMpeg { } inline double get_externalClock() { - const auto curTime = get_curTime(); - const auto pausedTime = get_pausedTime(); - - // reset frameTimer with an extimate time - if (NearlyEqualDBL(frameTimer, -1.0)) { - frameTimer = curTime - videoPts; - tempoTimer = curTime; - } - - const auto timeTempo = curTime - pausedTime - tempoTimer; - - return (tempoTimer - frameTimer) + timeTempo * static_cast(this->atempo); - } - - inline double get_audioClock() { - double pts = audioClock; /* maintained in the audio thread */ - auto bytes_per_sec = 0; - - if (pAudioStream) { - bytes_per_sec = (TARGET_SAMPLE_RATE * TARGET_CHANNEL_NUMBER) * 2; - } - - if (bytes_per_sec) { -#define TIMER_OFFSET - -#ifdef TIMER_OFFSET - const auto maxPtsOffset = static_cast(this->audio_stream_len) / bytes_per_sec; - - const auto curTime = get_curTime(); - const auto pausedTime = get_pausedTime(); - - const auto timePlayed = curTime - pausedTime - audioCallbackStartTimer; - const auto protectedTimePlayed = Range(timePlayed, 0.0, maxPtsOffset); - - pts = audioCallbackStartPts + protectedTimePlayed; -#else - auto hw_buf_size = audio_buf_size - audio_buf_index; - pts -= static_cast(hw_buf_size) / bytes_per_sec; -#endif - } + if (externalTimer == 0) externalTimer = get_curTime(); + return externalPosition + (bPause ? 0.0 : (get_curTime() - externalTimer) * atempo); + } - return pts; - } + inline double get_audioClock() { + if (!pAudioStream) return audioClock; + // Only interpolate through audio actually submitted in this callback. + // Decoded-but-buffered PCM and an unfilled EOF/starvation tail are not played. + const double submitted = (std::max)(0.0, audioOutputClock - audioCallbackStartPts); + const double elapsed = bPause ? 0.0 + : (std::max)(0.0, get_curTime() - audioCallbackStartTimer); + return audioCallbackStartPts + (std::min)(elapsed * audioCallbackTempo, submitted); + } inline SyncState get_syncState() { auto delay = videoPts - frameLastPts; @@ -1750,18 +1722,21 @@ class FFMpeg { } inline void reset_sync(const double targetPts = 0) { - videoPts = targetPts; + externalPosition = targetPts; + externalTimer = get_curTime(); + videoPts = targetPts; audioPts = targetPts; videoClock = targetPts; audioClock = targetPts; - frameTimer = -1; frameLastPts = 0; frameLastDelay = 40e-3; reset_pausePts(); - audioCallbackStartPts = 0; + audioOutputClock = audioCallbackStartPts = targetPts; + audioCallbackTempo = atempo; + audioCallbackStartTimer = get_curTime(); } inline void reset_finishState() { @@ -1773,7 +1748,7 @@ class FFMpeg { } static inline double get_curTime() { - return static_cast(av_gettime()) / 1000000.0; + return static_cast(av_gettime_relative()) / 1000000.0; } static inline double get_ptsMS(const AVPacket* pPacket, const AVStream* pStream) { @@ -1920,16 +1895,17 @@ class FFMpeg { } inline void set_pause(const bool bPause, const bool bUpdatePts = true) { - this->bPause = bPause; - if (!bUpdatePts) { return; } - - if (bPause) { - pausePts = get_curTime(); - } - else { - unPausePts = get_curTime(); - } - } + SpinLockHelper lockHelper(&audioLock); + if (this->bPause == bPause) return; + if (!bUpdatePts) { this->bPause = bPause; return; } + externalPosition = get_externalClock(); + externalTimer = get_curTime(); + audioCallbackStartPts = get_audioClock(); + audioCallbackStartTimer = get_curTime(); + this->bPause = bPause; + if (bPause) pausePts = get_curTime(); + else unPausePts = get_curTime(); + } inline double get_pausedTime(bool bReset = true) { const auto pausedTime = unPausePts - pausePts; @@ -2155,39 +2131,34 @@ class FFMpeg { // call set_videoPosition first to seek to target frame // then call this to decode to next valid frame if needed - inline int goto_videoPosition(const size_t ms, const FrameDataCallBack& callBack) { - // As audio thread can affect the main format context, lock it here - SpinLockHelper lockHelper(&audioLock); - // clear video queue to clear old data, e.g., filled by audio thread with old context - // which may break decoder then return bad frames - if (video_stream_index >= 0) { videoQueue.flush(); } - - // clear audio queue - if (audio_stream_index >= 0) { - audioQueue.flush(); - // video packet will be used for decode while audio packet are dropped - // so flush is needed here - avcodec_flush_buffers(pACodecContext); - } - - const auto targetPts = ms / 1000.0; - const auto response = forwardFrame(pFormatContext, pVCodecContext, targetPts, callBack); - - // reset sync, or video will be treat as faster and playback won't continue - // until audio thread catches up, which causes a "jump" of video frame finally - // the frame will change in a very fast speed which is not demanded + inline int goto_videoPosition(const size_t ms, const FrameDataCallBack& callBack) { + const unsigned char* presented = nullptr; + int stride = 0, height = 0, response = 0; + bool updated = false; + { + SpinLockHelper lockHelper(&audioLock); + if (video_stream_index >= 0) videoQueue.flush(); + if (audio_stream_index >= 0) { + audioQueue.flush(); + avcodec_flush_buffers(pACodecContext); + } + response = forwardFrame(pFormatContext, pVCodecContext, ms / 1000.0, + [&](const unsigned char* data, int pitch, int rows) { + presented = data; stride = pitch; height = rows; updated = true; + }); #ifndef UPDATE_AUDIOCLOCK_WHEN_SEEKING - audioPts = videoPts; - audioClock = videoClock; + audioPts = videoPts; + audioClock = videoClock; #else #ifndef DECODE_AUDIO_WHEN_SEEKING - audioPts = seekingAudioClock; - audioClock = seekingAudioClock + prevAudioClockOffset; + audioPts = seekingAudioClock; + audioClock = seekingAudioClock + prevAudioClockOffset; #endif #endif - - return response; - } + } + if (updated) callBack(presented, stride, height); + return response; + } // get video frame of given time stamp inline int get_videoFrame(const size_t ms, const bool bAccurateSeek, const FrameDataCallBack& callBack) { @@ -2261,6 +2232,7 @@ class FFMpeg { // range: 0 ~ 100 inline void set_volume(const int volume) { + SpinLockHelper lockHelper(&audioLock); this->volume = static_cast((Range(volume, 0, 100) / 100.0) * 128); } @@ -2271,6 +2243,7 @@ class FFMpeg { // range: 0 ~ 128 inline void set_sdl_volume(const int volume) { + SpinLockHelper lockHelper(&audioLock); this->volume = Range(volume, 0, 128); } @@ -2302,12 +2275,12 @@ class FFMpeg { return; } - this->atempo = newTempo; + externalPosition = get_externalClock(); + externalTimer = get_curTime(); + this->atempo = newTempo; // make sure member value is updated if (this->bNoAudio) { - // reset timer, also trigger tempoTimer update - frameTimer = -1; return; } @@ -2355,7 +2328,9 @@ class FFMpeg { bool bValid = !bNoAudio && !bExit && !bPause && !bSeeking; if (!bValid) { return 0; } - this->audioCallbackStartPts = audioClock; + this->audioCallbackStartPts = audioOutputClock; + // Tempo changes must not retroactively retime samples already submitted. + this->audioCallbackTempo = atempo; this->audioCallbackStartTimer = get_curTime(); // check the lenth remain in audio cache @@ -2384,6 +2359,18 @@ class FFMpeg { audio_buf_index = 0; } + // Preserve a source's delayed audio start as silence on the output + // timeline instead of jumping the video master clock to the first sample. + const double step = static_cast(atempo) / (TARGET_SAMPLE_RATE * TARGET_CHANNEL_NUMBER * 2); + const double bufferedPts = audioPts + audio_buf_index * step; + if (bufferedPts > audioOutputClock + step * 4) { + const int silence = static_cast((std::min)(static_cast(len / 4), + std::ceil((bufferedPts - audioOutputClock) / (step * 4)))) * 4; + if (silence) { + len -= silence; stream += silence; audioOutputClock += silence * step; + continue; + } + } // calculate the remain length of decode cache, protect if exceeds int wt_stream_len = static_cast(audio_buf_size - audio_buf_index); if (wt_stream_len > len) { wt_stream_len = len; } @@ -2394,7 +2381,8 @@ class FFMpeg { // update len -= wt_stream_len; stream += wt_stream_len; - audio_buf_index += wt_stream_len; + audio_buf_index += wt_stream_len; + audioOutputClock += wt_stream_len * step; } return static_cast(audio_stream_len) - len; From ca3aa1eba4017e0229daf21693a9286315e20604 Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 5 Nov 2026 22:14:09 +0800 Subject: [PATCH 10/12] FFMpeg: share seek and preview policy across Fusion bindings Move the existing Windows seek/preview flags and policy into the shared class, then call it from the Windows wrapper. Add unclocked preview, still-image handling and explicit playback/drain queries. Publish frame and seek callbacks after releasing the audio lock so control calls can reenter. Keep the existing Windows video-only loop policy. Add a standalone shared-core runner and regressions for seek, drain, AVIO, pause, Unicode paths and callback reentry; ASan/UBSan pass. --- Extensions/FFMpeg/Func.h | 36 +------ Extensions/FFMpeg/tests/SharedCoreTest.cpp | 104 +++++++++++++++++++++ Extensions/FFMpeg/tests/run.py | 18 ++++ Extensions/_3rdLib/FFMpeg.h | 76 ++++++++++++--- 4 files changed, 188 insertions(+), 46 deletions(-) create mode 100644 Extensions/FFMpeg/tests/SharedCoreTest.cpp create mode 100644 Extensions/FFMpeg/tests/run.py diff --git a/Extensions/FFMpeg/Func.h b/Extensions/FFMpeg/Func.h index 7b78a8c45..0671d2118 100644 --- a/Extensions/FFMpeg/Func.h +++ b/Extensions/FFMpeg/Func.h @@ -105,9 +105,6 @@ inline bool VideoSingleFrame(LPRDATA rdPtr) { return rdPtr->pFFMpeg->get_videoFrameCount() == 1; } -constexpr auto SeekFlag_NoGoto = 0x0001 << 16; // do not decode to next valid frame -constexpr auto SeekFlag_NoRevert = 0x0002 << 16; // do not revert if timestamp is 0 - // seek video to given position, return false on decode error // by default, it will decode to first valid frame (accurate seek enabled & not `AVSEEK_FLAG_BYTE`) // and revert back if target timestamp is 0 @@ -115,37 +112,12 @@ constexpr auto SeekFlag_NoRevert = 0x0002 << 16; // do not revert if timestamp i inline bool SetPositionGeneral(LPRDATA rdPtr, int ms, int flags = SeekFlags) { if (!rdPtr->bOpen) { return true; } - // add protection for minus position - ms = (std::max)(ms, 0); - - // in case of only one frame, aka static picture, only use goto - // as seek for picture won't reset the context and cannot decode next time - const auto bSingleFrame = VideoSingleFrame(rdPtr); - - int response = 0; - - //auto pos = rdPtr->pFFMpeg->get_videoPosition(); - if (!bSingleFrame) { response = rdPtr->pFFMpeg->set_videoPosition(ms, flags); } - if (response < 0) { return false; } - - const bool bGoto = rdPtr->bAccurateSeek && (flags & AVSEEK_FLAG_BYTE) != AVSEEK_FLAG_BYTE; - if (!(bGoto || bSingleFrame) || flags & SeekFlag_NoGoto) { return true; } - response = rdPtr->pFFMpeg->goto_videoPosition(ms, - [&] (const unsigned char* pData, const int stride, const int height) { - // to avoid green screen at start - if (rdPtr->pFFMpeg->get_copyToTextureState()) { rdPtr->pFFMpeg->gpu_wait(); } - + return rdPtr->pFFMpeg->seek(ms, flags, rdPtr->bAccurateSeek, + [&](const unsigned char* pData, const int stride, const int height) { + if (rdPtr->pFFMpeg->get_copyToTextureState()) rdPtr->pFFMpeg->gpu_wait(); rdPtr->pCopyAdapter->CopyTexture(rdPtr->pDisplaySf, pData, stride, height); ReDisplay(rdPtr); - }); - if (response < 0) { return false; } - - // revert - if (ms != 0 || flags & SeekFlag_NoRevert) { return true; } - if (!bSingleFrame) { response = rdPtr->pFFMpeg->set_videoPosition(ms, flags); } - if (response < 0) { return false; } - - return true; + }) >= 0; } inline bool GetVideoPlayState(LPRDATA rdPtr) { diff --git a/Extensions/FFMpeg/tests/SharedCoreTest.cpp b/Extensions/FFMpeg/tests/SharedCoreTest.cpp new file mode 100644 index 000000000..f36c91ed7 --- /dev/null +++ b/Extensions/FFMpeg/tests/SharedCoreTest.cpp @@ -0,0 +1,104 @@ +// Compile the original Windows playback class, without JNI or the Android engine. +#include "FFMpeg.h" +#include +#include +#include +#include +void check(bool ok, const char* message) { if (!ok) throw std::runtime_error(message); } +int main(int argc, char** argv) { + check(argc == 3, "video and delayed audio fixtures required"); + FFMpegOptions options; + options.outputFormat = AV_PIX_FMT_RGBA; + options.SetThreadCount(2); + std::vector block(4096); + auto pcm = [&](FFMpeg& player) { + return player.audio_fillData(block.data(), block.size(), std::memset, + [](void* dst, const void* src, size_t count, int) { std::memcpy(dst, src, count); }); + }; + FFMpeg player(std::string(argv[1]), options); + int count = 0; + size_t bytes = 0; + const auto frame = [&](const unsigned char* data, int stride, int height) { + check(data && stride == 64*4 && height == 48, "tightly packed shared software conversion"); ++count; + }; + player.set_pause(false); + for (int budget = 0; budget < 1000; ++budget) { + player.get_nextFrame(frame, false); + bytes += pcm(player); + if (player.get_finishState() && player.audio_finished()) break; + } + check(count == 12, "shared decoder drains delayed video frames"); + check(bytes >= 44100*4, "shared filter and resampler drain"); + player.set_pause(true); + check(player.seek(400, AVSEEK_FLAG_BACKWARD, true, frame) >= 0, "fractional second seek"); + check(player.get_videoPosition() >= 400 && player.get_videoPosition() < 600, "shared seek target"); + check(player.seek(0, AVSEEK_FLAG_BACKWARD, true, frame) >= 0, "rewind after EOF"); + count = 0; + player.get_nextFrame(frame, false); + check(count == 1 && player.get_videoPosition() == 0, "preview zero does not consume first playback frame"); + player.seek(0, AVSEEK_FLAG_BACKWARD, true, frame); + player.get_nextFrame([&](const unsigned char*, int, int) { player.set_volume(25); }, false); + check(player.get_volume() == 25, "frame callbacks may reenter control methods"); + check(player.seek(200, AVSEEK_FLAG_BACKWARD, true, + [&](const unsigned char*, int, int) { player.set_volume(50); }) >= 0, + "seek callbacks may reenter control methods"); + check(player.get_volume() == 50, "seek callback control update"); + player.set_pause(false); player.get_pausedTime(); + player.set_pause(true, false); + check(player.get_pausedTime() == 0, "state-only pause leaves timing metadata unchanged"); + FFMpeg delayed(std::string(argv[2]), options); + delayed.set_pause(false); + check(pcm(delayed) == static_cast(block.size()), "delayed audio callback"); + check(std::all_of(block.begin(), block.end(), [](uint8_t value) { return value == 0; }), + "delayed audio starts with output silence"); + check(delayed.get_playbackClock() < 100, "delayed audio does not skip the video timeline"); + bool found = false; + for (int i = 0; i < 30; ++i) { + pcm(delayed); + if (std::any_of(block.begin(), block.end(), [](uint8_t value) { return value != 0; })) { + check(delayed.get_playbackClock() >= 350, "audible samples retain relative start offset"); + found = true; break; + } + } + check(found, "delayed source eventually renders audio"); + std::ifstream input(argv[1], std::ios::binary); + std::vector memory((std::istreambuf_iterator(input)), {}); + const auto original = memory; + { + MemView view(memory.data(), static_cast(memory.size())); + uint8_t first[4] = {}; + check(view.read(first, sizeof(first)) == sizeof(first) + && std::equal(std::begin(first), std::end(first), memory.begin()), "borrowed source read"); + check(view.seek(0, AVSEEK_SIZE) == static_cast(memory.size()), "view size query"); + check(view.seek(-4, SEEK_END) == static_cast(memory.size()) - 4, "view seek from end"); + check(view.read(first, sizeof(first)) == sizeof(first), "read source tail"); + check(view.read(first, sizeof(first)) == AVERROR_EOF, "view EOF"); + check(view.seek(1, SEEK_CUR) == AVERROR(EINVAL), "view rejects out-of-bounds seek"); + check(view.seek(0, SEEK_SET | AVSEEK_FORCE) == 0, "view rewind"); + } + { + FFMpeg inMemory(memory.data(), memory.size(), options); + count = 0; inMemory.get_nextFrame(frame, false); + check(count == 1, "shared custom AVIO file source"); + check(inMemory.seek(500, AVSEEK_FLAG_BACKWARD, true, frame) >= 0, "custom AVIO seek"); + check(inMemory.get_videoFrame(600, true, frame) >= 0, "independent memory preview context"); + check(inMemory.seek(0, AVSEEK_FLAG_BACKWARD, true, frame) >= 0, "memory rewind"); + inMemory.set_pause(false); + count = 0; + size_t memoryBytes = 0; + for (int budget = 0; budget < 1000; ++budget) { + inMemory.get_nextFrame(frame, false); + memoryBytes += pcm(inMemory); + if (inMemory.get_finishState() && inMemory.audio_finished()) break; + } + check(count == 12 && memoryBytes >= 44100*4, "memory source plays and drains to EOF"); + } + check(memory == original, "caller retains source data after view/player destruction"); + for (int i = 0; i < 20; ++i) { + try { FFMpeg invalid(std::string(argv[1])+".missing", options); check(false, "invalid open must throw"); } + catch (const FFMpegException&) {} + try { FFMpeg invalid(memory.data(), 1, options); check(false, "invalid memory source must throw"); } + catch (const FFMpegException&) {} + } + std::cout << "PASS: original C++ core, video/filter/resampler drain, accurate seek, custom AVIO and failure cleanup (" << bytes << " bytes)\n"; +} diff --git a/Extensions/FFMpeg/tests/run.py b/Extensions/FFMpeg/tests/run.py new file mode 100644 index 000000000..9c090820a --- /dev/null +++ b/Extensions/FFMpeg/tests/run.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Run shared C++ FFmpeg regressions without JNI, Android or a Mixer X device.""" +import argparse,os,subprocess +from pathlib import Path +p=argparse.ArgumentParser(description=__doc__) +p.add_argument('--ffmpeg-include',required=True,type=Path);p.add_argument('--ffmpeg-lib',required=True,type=Path) +p.add_argument('--sdl-include',required=True,type=Path);p.add_argument('--sdl-lib',required=True,type=Path) +p.add_argument('--build',type=Path,default=Path('/tmp/ffmpeg-cpp-tests'));a=p.parse_args() +r=Path(__file__).resolve().parents[3];b=a.build.resolve();b.mkdir(parents=True,exist_ok=True) +def run(command,**kw):subprocess.run(list(map(str,command)),check=True,**kw) +media=b/'视频-🎬.mp4';delayed=b/'延迟-音频.mp4' +run(['ffmpeg','-v','error','-y','-f','lavfi','-i','testsrc2=size=64x48:rate=10:duration=1.2','-f','lavfi','-i','sine=frequency=1000:sample_rate=48000:duration=1.2','-c:v','mpeg4','-bf','2','-c:a','aac','-shortest',media]) +run(['ffmpeg','-v','error','-y','-f','lavfi','-i','testsrc2=size=64x48:rate=10:duration=1.2','-itsoffset','0.4','-f','lavfi','-i','sine=frequency=1000:sample_rate=48000:duration=0.8','-c:v','mpeg4','-c:a','aac',delayed]) +for name in ['PCMFragmentTest','SharedCoreTest','DecoderProtocolTest']: + binary=b/name + wrappers=['-Wl,--wrap='+symbol for symbol in ['av_read_frame','avcodec_send_packet','avcodec_receive_frame']] if name=='DecoderProtocolTest' else [] + run(['c++','-std=c++20','-g','-fsanitize=address,undefined','-fno-omit-frame-pointer','-I'+str(r/'Extensions/_3rdLib'),'-I'+str(r/'Extensions/_DeLib'),'-I'+str(a.ffmpeg_include),'-I'+str(a.sdl_include),Path(__file__).with_name(name+'.cpp'),r/'Extensions/_3rdLib/FFMpegAdapter.cpp','-L'+str(a.ffmpeg_lib),'-L'+str(a.sdl_lib),'-lavformat','-lavcodec','-lavfilter','-lavutil','-lswscale','-lswresample','-lSDL2','-Wl,-rpath,'+str(a.sdl_lib),*wrappers,'-o',binary]) + run([binary,media,*([delayed] if name=='SharedCoreTest' else [])],timeout=20,env=dict(os.environ,ASAN_OPTIONS='detect_leaks=0')) diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index ff3ef2f62..a28983331 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -150,6 +150,8 @@ constexpr auto FFMpegError_ENOMEM = AVERROR(ENOMEM); #endif // Defines +constexpr auto SeekFlag_NoGoto = 0x0001 << 16; +constexpr auto SeekFlag_NoRevert = 0x0002 << 16; constexpr auto SeekFlags = AVSEEK_FLAG_BACKWARD | AVSEEK_FLAG_FRAME; //constexpr auto SeekFlags = AVSEEK_FLAG_BYTE | AVSEEK_FLAG_FRAME; //constexpr auto SeekFlags = AVSEEK_FLAG_ANY | AVSEEK_FLAG_FRAME; @@ -430,6 +432,8 @@ class FFMpeg { double timelineOriginSeconds = 0.0; int64_t frameCount = 0; + bool stillImage = false; + bool imagePresented = false; // buffer for format conversion uint8_t* pConvertedFrameBuffer = nullptr; @@ -1017,6 +1021,10 @@ class FFMpeg { && pVideoStream->avg_frame_rate.num != 0 && pVideoStream->avg_frame_rate.den != 0) { frameCount = static_cast(totalTime * av_q2d(pVideoStream->avg_frame_rate)); } + const std::string formatName = pFormatContext->iformat->name; + stillImage = pVideoStream->nb_frames <= 1 && (formatName.find("_pipe") != std::string::npos + || formatName == "image2"); + if (stillImage) frameCount = 1; #pragma endregion #pragma region AudioInit @@ -1342,12 +1350,12 @@ class FFMpeg { av_frame_unref(pFrame); } - inline int decode_frame(const FrameDataCallBack& callBack) { + inline int decode_frame(const FrameDataCallBack& callBack, bool synchronize = true) { SyncState syncState; do { // calc delay - syncState = get_syncState(); + syncState = synchronize ? get_syncState() : SyncState::SYNC_SYNC; // decode switch (syncState) { @@ -2209,22 +2217,62 @@ class FFMpeg { return response; } - // read next frame and handle loop automatically - inline int get_nextFrame(const FrameDataCallBack& callBack) { - int response = handleLoop(); - - if (!bFinish) { response = decode_frame(callBack); } - - //do not wait audio finish to get fluent loop - //bFinish = bReadFinish && bVideoFinish && bAudioFinish; - bFinish = bReadFinish && bVideoFinish; + // Shared policy previously implemented in the Windows Fusion wrapper. + int seek(int64_t ms, int flags, bool accurate, const FrameDataCallBack& callback) { + ms = std::max(ms, 0); + const bool single = get_videoFrameCount() == 1; + const int nativeFlags = flags < 0 ? flags : flags & 0xffff; + int result = single ? 0 : set_videoPosition(ms, nativeFlags); + if (result < 0) return result; + if ((!(accurate && !(flags & AVSEEK_FLAG_BYTE)) && !single) + || (flags & SeekFlag_NoGoto)) return 0; + if (stillImage && imagePresented) return 0; + result = goto_videoPosition(ms, callback); + if (result < 0) return result; + imagePresented = true; + if (ms == 0 && !(flags & SeekFlag_NoRevert) && !single) + return set_videoPosition(ms, nativeFlags); + return 0; + } + // read next frame and handle loop automatically + inline int get_nextFrame(const FrameDataCallBack& callBack, bool synchronize = true) { + if (stillImage && imagePresented) return 0; + int response = handleLoop(); + const unsigned char* presented = nullptr; + int stride = 0, height = 0; + bool updated = false; + { + SpinLockHelper lockHelper(&audioLock); + if (!bFinish) response = decode_frame([&](const unsigned char* data, int pitch, int rows) { + imagePresented = true; + presented = data; stride = pitch; height = rows; updated = true; + }, synchronize); + // Preserve Windows' existing video-only fluent-loop policy. + bFinish = bReadFinish && bVideoFinish; #ifdef _CONSOLE - bJumped = false; + bJumped = false; #endif + } + if (updated) callBack(presented, stride, height); + return response; + } - return response; - } + bool has_audio() const { return !bNoAudio; } + bool audio_finished() { + SpinLockHelper lockHelper(&audioLock); + return bNoAudio || (bAudioFinish && audio_buf_index >= audio_buf_size); + } + bool playback_finished() { + SpinLockHelper lockHelper(&audioLock); + if (!bFinish || (!bNoAudio && (!bAudioFinish || audio_buf_index < audio_buf_size))) return false; + const double clock = bNoAudio ? get_externalClock() : get_audioClock(); + return totalTimeInMs == INT64_MAX || clock * 1000.0 >= totalTimeInMs; + } + double get_playbackClock() { + SpinLockHelper lockHelper(&audioLock); + return (bNoAudio ? get_externalClock() : get_audioClock()) * 1000.0; + } // ------------------------------------ // Volume From 8d6b088c4c62d49df1f102737fb09ecb71751b5d Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 5 Nov 2026 22:14:14 +0800 Subject: [PATCH 11/12] FFMpeg: extend the adaptor contract for decoder context ownership Add generic decoder-selection and context-lifetime hooks to the existing adaptor interface. Preserve explicit decoder overrides, accept delayed pixel-format discovery and retry software initialization after hardware setup fails. Keep the D3D implementation and guard its construction until a real device context exists. No Android adaptor is introduced here. The host core prefix compiles and its shared regressions pass; Windows MSBuild/device validation remains separate. --- Extensions/FFMpeg/tests/README.md | 54 ++++++++++++++++++++++++++++ Extensions/_3rdLib/FFMpeg.h | 33 +++++++++++++---- Extensions/_3rdLib/FFMpegAdapter.cpp | 4 +-- Extensions/_3rdLib/FFMpegAdapter.h | 6 ++++ 4 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 Extensions/FFMpeg/tests/README.md diff --git a/Extensions/FFMpeg/tests/README.md b/Extensions/FFMpeg/tests/README.md new file mode 100644 index 000000000..bfb67eb11 --- /dev/null +++ b/Extensions/FFMpeg/tests/README.md @@ -0,0 +1,54 @@ +# Shared C++ playback regressions + +These tests exercise `Extensions/_3rdLib/FFMpeg.h` directly, without JNI, +Android or a Mixer X audio device. They belong to the shared playback core. + +```sh +python3 Extensions/FFMpeg/tests/run.py \ + --ffmpeg-include /path/to/ffmpeg/include --ffmpeg-lib /path/to/ffmpeg/lib \ + --sdl-include /path/to/SDL2/include/SDL2 --sdl-lib /path/to/SDL2/lib +``` + +The runner requires a C++20 compiler with ASan/UBSan and an `ffmpeg` CLI with +MPEG-4/AAC encoding. It generates video with delayed frames, delayed audio and +Unicode filenames. LeakSanitizer is disabled for managed ptrace environments. +`PCMFragmentTest` compares a large PCM request with four-byte fragments using +SDL's real additive mixer and rejects an all-silence result. `SharedCoreTest` +covers frame count, audio tail, fractional seek, rewind/preview, callback reentry, +state-only pause, delayed audio and invalid-open cleanup. Memory-source checks +cover read/seek/size/EOF, an independent preview context, complete audio/video +playback to EOF and preservation of caller-owned data after player destruction. +`DecoderProtocolTest` uses linker wrappers around the actual FFmpeg calls to +verify Send First ordering, the queue-wait early return without touching the codec, +one-tick deferral and recovery after source EAGAIN, +and propagation of IO EIO and codec EPERM without confusing them with EOF or +queue waiting. This test requires a linker supporting GNU `--wrap`. + +## Review boundaries + +The core commits precede Android integration and are organized by the problem +they solve. Shared behavior fixes affect Windows too; they are not all required +merely to compile on Android. + +| Intent | Change and compatibility boundary | +| --- | --- | +| Dependency cleanup | Include the existing arithmetic/string/conversion helpers directly and remove a stale probe comment. Do not introduce FFmpeg-specific copies or a header for an unused audio constant. | +| Portable compilation | Standard-width types, standard exception constructor, qualified lock types and guarded Windows headers. Keep Windows call sites and error codes. | +| AVIO ownership | A non-owning MemView tracks the caller's media data and read cursor. Allocate/free the separate AVIO working buffer through FFmpeg, including partial construction. Use avformat's custom-IO probing and bounded memory seeking/size queries. Narrow paths delegate to the existing wide-path constructor. | +| Pixel conversion | Cache conversion by actual frame properties and allocate/describe the actual output stride. BGRA remains the default; callers may select RGBA. | +| Seek timeline | Preserve fractional seconds and normalize frame timestamps against the source timeline. | +| PCM copy bounds | Pass only the available decoded bytes to the mixer. The regression fails before this one-line fix and passes after it. | +| Decoder protocol | Preserve the original Send First branches, queue-wait early return, pending-packet retry and packet argument contract. Source EAGAIN defers the tick; queue waiting and native EOF are distinct from real errors. Frame-derived audio timestamps remain in the audio-pipeline commit. The separate forwardFrame seek loop retains its previous packet-release behavior; its send-EAGAIN retry policy is not changed or claimed fixed here. | +| Audio pipeline | Drain filters and resampling, size PCM storage dynamically, reset DSP state on seek and return actual bytes produced. | +| Playback clocks | Use monotonic time and submitted-audio progress; preserve delayed starts, pause and tempo continuity. Preserve the state-only pause option. | +| Shared seek/preview policy | Move the Windows wrapper policy into the core, support unclocked previews/still images and report completion. Invoke frame callbacks outside the audio lock so controls can reenter. | +| Hardware lifecycle | Extend the existing adaptor interface for decoder selection/context ownership. Preserve explicit decoder overrides, allow delayed pixel-format discovery and retry software when hardware initialization fails. | + +Only the final integration commit adds MediaCodec/GLES adaptors, JNI/Java, +Android SDK deployment, packaging, platform tests and platform-specific crypto +compatibility. The D3D implementation remains behind Windows guards. + +Each core stage from the portable-compilation commit onward was independently +compiled and exercised on a Linux host with FFmpeg 7.1.5 and SDL2. These checks do not replace Windows MSBuild or +Android device/export testing. Platform integration results are recorded in +[`Android/README.md`](../Android/README.md). diff --git a/Extensions/_3rdLib/FFMpeg.h b/Extensions/_3rdLib/FFMpeg.h index a28983331..29f7bba19 100644 --- a/Extensions/_3rdLib/FFMpeg.h +++ b/Extensions/_3rdLib/FFMpeg.h @@ -231,6 +231,7 @@ class FFMpegOptions { std::string audioCodecName; int threadCount = 0; AVPixelFormat outputFormat = PIXEL_FORMAT; + void* platformSurface = nullptr; FFMpegOptions() = default; @@ -570,6 +571,9 @@ class FFMpeg { } inline int hw_initDecoderDeviceContext(AVCodecContext* pCodecContext, const AVHWDeviceType type) { + if (pAdapter && pAdapter->OwnsDecoderContext()) { + return pAdapter->InitDecoder(pCodecContext, options.platformSurface); + } int err = 0; if ((err = hw_updateDeviceContext(type)) < 0) { return err; @@ -831,7 +835,7 @@ class FFMpeg { if (hw_initDecoderDeviceContext((*ppVCodecContext), hw_type) < 0) { //throw FFMpegException_HWInitFailed; - bHWDecode = false; + return FFMpegException_HWInitFailed; } } #endif @@ -840,9 +844,6 @@ class FFMpeg { return FFMpegException_InitFailed; } - if ((*ppVCodecContext)->pix_fmt == AV_PIX_FMT_NONE) { - return FFMpegException_InitFailed; - } return 0; } @@ -883,7 +884,13 @@ class FFMpeg { if (bHWDecode) { // update pixel format - hw_pix_fmt = hw_getPixelFormat(pVCodec, hw_type); + pAdapter = FFMpegAdapterFactory(hw_type, nullptr); + // A caller's explicit decoder override takes precedence over automatic + // platform decoder selection, as it does on Windows. + if (!options.VideoCodecValid()) pVCodec = pAdapter->SelectDecoder(pVCodec); + hw_pix_fmt = pAdapter->PixelFormat(pVCodec); + if (hw_pix_fmt == AV_PIX_FMT_NONE && !pAdapter->OwnsDecoderContext()) + hw_pix_fmt = hw_getPixelFormat(pVCodec, hw_type); if (hw_pix_fmt == AV_PIX_FMT_NONE) { // enum types to get a valid device @@ -918,7 +925,17 @@ class FFMpeg { #endif if (init_videoCodecContext(&pVCodecContext, &VCodecCtxQpaque) != 0) { - return FFMpegException_InitFailed; + if (!bHWDecode) return FFMpegException_InitFailed; + if (pAdapter) pAdapter->ReleaseDecoder(pVCodecContext); + avcodec_free_context(&pVCodecContext); + av_buffer_unref(&hw_device_ctx); + bHWDecode = bCopyToTexture = false; + hw_type = AV_HWDEVICE_TYPE_NONE; + VCodecCtxQpaque.hw_pix_fmt = AV_PIX_FMT_NONE; + pVCodec = options.VideoCodecValid() ? options.pVideoCodec + : avcodec_find_decoder(pVCodecParameters->codec_id); + if (init_videoCodecContext(&pVCodecContext, &VCodecCtxQpaque) != 0) + return FFMpegException_InitFailed; } // will create default adapter if not support or in software mode @@ -1331,7 +1348,7 @@ class FFMpeg { inline void convert_frame(AVCodecContext* pCodecContext, AVFrame* pFrame, const FrameDataCallBack& callBack) { // data invalid, do not trigger callback, in case of get a black frame - if (!pFrame->data[0]) { return; } + if (!pFrame->buf[0] && !pFrame->data[0]) { return; } #ifdef HW_DECODE if (bHWDecode) { @@ -1864,6 +1881,8 @@ class FFMpeg { if (pAdapter) { pAdapter->FreeContext(VCodecCtxQpaque.hTextureCtx); pAdapter->FreeContext(VGetCodecCtxQpaque.hTextureCtx); + pAdapter->ReleaseDecoder(pVCodecContext); + pAdapter->ReleaseDecoder(pVGetCodecContext); } avcodec_free_context(&pVCodecContext); diff --git a/Extensions/_3rdLib/FFMpegAdapter.cpp b/Extensions/_3rdLib/FFMpegAdapter.cpp index 3fb1f6321..88d1c180f 100644 --- a/Extensions/_3rdLib/FFMpegAdapter.cpp +++ b/Extensions/_3rdLib/FFMpegAdapter.cpp @@ -7,7 +7,7 @@ bool FFMpegAdapterSupport(const AVHWDeviceType type) { do { #ifdef _WIN32 - if (type == AV_HWDEVICE_TYPE_D3D11VA) { return true; } + if (type == AV_HWDEVICE_TYPE_D3D11VA && pHWDCtx) { return true; } #endif } while (false); @@ -18,7 +18,7 @@ std::unique_ptr FFMpegAdapterFactory(const AVHWDeviceType type, AVHWDeviceContext* pHWDCtx) { do { #ifdef _WIN32 - if (type == AV_HWDEVICE_TYPE_D3D11VA) { + if (type == AV_HWDEVICE_TYPE_D3D11VA && pHWDCtx) { return std::make_unique(pHWDCtx); } #endif diff --git a/Extensions/_3rdLib/FFMpegAdapter.h b/Extensions/_3rdLib/FFMpegAdapter.h index bb7fcf675..120a6f478 100644 --- a/Extensions/_3rdLib/FFMpegAdapter.h +++ b/Extensions/_3rdLib/FFMpegAdapter.h @@ -26,6 +26,12 @@ struct FFMpegAdapter { FFMpegAdapter(AVHWDeviceContext* p) :pHWDCtx(p) {}; virtual ~FFMpegAdapter() = default; + // Only platform decoder setup and output live in adaptors. Scheduling stays in FFMpeg. + virtual const AVCodec* SelectDecoder(const AVCodec* codec) { return codec; } + virtual AVPixelFormat PixelFormat(const AVCodec*) { return AV_PIX_FMT_NONE; } + virtual bool OwnsDecoderContext() const { return false; } + virtual int InitDecoder(AVCodecContext*, void*) { return 0; } + virtual void ReleaseDecoder(AVCodecContext*) {} virtual void gpu_flush() {} virtual void gpu_wait() {} virtual TextureContextHandle AllocContext() { return nullptr; } From 077893c2e7b2ec3b68999d4617578d624a924b59 Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 5 Nov 2026 22:14:20 +0800 Subject: [PATCH 12/12] Android: integrate both media plugins through the shared C++ cores Add MediaCodec/GLES adaptors through the existing factories, thin JNI and Fusion Java bindings, and one shared SDL Mixer X transport for both plugins. Preserve application/file leases through native Fusion storage. Add platform SDK deployment and publishing using the Windows FFmpeg/OpenSSL version selectors. Use the existing shared conversion CMake interface and pinned iconv source/archives. Crypto calls the original Encryption core, with compatibility guarded only for Android 32-bit API 21-23; standalone Encrypter remains deferred. Include platform fixtures, reproduction/build notes and the recorded shared-core/recovery policies. The preceding commits contain the portable playback changes, grouped by intent. Host packaged-core CMake, SDK validation and ASan/UBSan shared-core checks pass; a new full Android export and Windows MSBuild remain for integration testing. --- .../skills/android-runtime-parity/SKILL.md | 145 +++++++++- .../execution-environment-recovery/SKILL.md | 59 +++++ .gitignore | 4 + Extensions/Android_Gradle/Media-JNI.md | 172 ++++++++++++ Extensions/Android_Gradle/app/.gitignore | 4 + Extensions/Android_Gradle/app/build.gradle | 17 ++ .../app/src/main/cpp/CMakeLists.txt | 21 ++ .../src/main/java/Extensions/CExtLoad.java | 8 + .../src/main/java/Extensions/CRunFFMpeg.java | 181 +++++++++++++ .../main/java/Extensions/CRunSDL_Mixer.java | 87 ++++++ .../src/main/java/_DeLib/FFMpegPlayer.java | 58 ++++ .../src/main/java/_DeLib/FFMpegSource.java | 15 ++ .../src/main/java/_DeLib/FFMpegStorage.java | 17 ++ .../src/main/java/_DeLib/FFMpegTexture.java | 126 +++++++++ .../app/src/main/java/_DeLib/FusionFiles.java | 21 ++ .../src/main/java/_DeLib/FusionStorage.java | 102 +++++++ .../app/src/main/java/_DeLib/MediaCrypto.java | 20 ++ .../app/src/main/java/_DeLib/MediaSource.java | 122 +++++++++ .../src/main/java/_DeLib/SDLAudioRuntime.java | 45 ++++ .../app/src/main/java/_DeLib/SDLMixer.java | 100 +++++++ .../src/main/java/_DeLib/SDLMixerStorage.java | 58 ++++ .../tests/ffmpeg/AdapterTest.java | 37 +++ .../tests/ffmpeg/AudioTest.java | 44 +++ .../tests/ffmpeg/DecodeTest.java | 50 ++++ .../tests/ffmpeg/compile_android.py | 32 +++ Extensions/Android_Gradle/tests/ffmpeg/run.py | 49 ++++ .../tests/ffmpeg/sdk_version_test.py | 48 ++++ .../tests/fusion_compile_fixture.py | 44 +++ .../tests/media_crypto/NativeTest.java | 46 ++++ .../Android_Gradle/tests/media_crypto/run.py | 14 + .../tests/media_crypto/sdk_version_test.py | 22 ++ .../Android_Gradle/tests/media_packages.py | 29 ++ .../tests/sdl_mixer/AdapterTest.java | 25 ++ .../tests/sdl_mixer/MediaSourceTest.java | 34 +++ .../tests/sdl_mixer/MixerTest.cpp | 74 ++++++ .../tests/sdl_mixer/NativeTest.java | 61 +++++ .../tests/sdl_mixer/PCMTest.cpp | 48 ++++ .../tests/sdl_mixer/SessionsTest.java | 79 ++++++ .../tests/sdl_mixer/compile_android.py | 40 +++ .../tests/sdl_mixer/device/FilesProbe.java | 46 ++++ .../tests/sdl_mixer/device/ProbeActivity.java | 71 +++++ .../tests/sdl_mixer/device/ProbeProvider.java | 22 ++ .../tests/sdl_mixer/device/StorageProbe.java | 59 +++++ .../tests/sdl_mixer/device/VideoProbe.java | 79 ++++++ .../tests/sdl_mixer/device/build_probe.py | 75 ++++++ .../tests/sdl_mixer/fixtures/SDLMixer.java | 21 ++ .../Android_Gradle/tests/sdl_mixer/run.py | 33 +++ Extensions/Encrypter/ReadMe.md | 29 +- Extensions/FFMpeg/Android/.gitignore | 1 + Extensions/FFMpeg/Android/README.md | 204 ++++++++++++++ Extensions/FFMpeg/Android/build_sdk.py | 132 +++++++++ Extensions/FFMpeg/Android/cpp/CMakeLists.txt | 90 +++++++ Extensions/FFMpeg/Android/cpp/CopyAdapter.cpp | 14 + Extensions/FFMpeg/Android/cpp/EnsureSDK.cmake | 49 ++++ Extensions/FFMpeg/Android/cpp/JNI.cpp | 184 +++++++++++++ Extensions/FFMpeg/CopyAdapter/CopyAdapter.cpp | 8 + Extensions/FFMpeg/CopyAdapter/CopyAdapter.h | 21 +- .../FFMpeg/CopyAdapter/CopyAdapterAndroid.cpp | 94 +++++++ .../FFMpeg/CopyAdapter/CopyAdapterAndroid.h | 9 + Extensions/FFMpeg/FFMpeg.vcxproj | 14 + Extensions/FFMpeg/FFMpeg.vcxproj.filters | 12 + Extensions/FFMpeg/ReadMe.md | 14 + Extensions/SDL_Mixer/Android/.gitignore | 2 + Extensions/SDL_Mixer/Android/README.md | 250 ++++++++++++++++++ Extensions/SDL_Mixer/Android/build_android.py | 144 ++++++++++ .../SDL_Mixer/Android/build_crypto_sdk.py | 91 +++++++ .../SDL_Mixer/Android/cpp/AudioRuntime.cpp | 74 ++++++ .../SDL_Mixer/Android/cpp/AudioRuntime.h | 31 +++ .../SDL_Mixer/Android/cpp/CMakeLists.txt | 125 +++++++++ Extensions/SDL_Mixer/Android/cpp/JNI.cpp | 107 ++++++++ Extensions/SDL_Mixer/Android/cpp/Mixer.cpp | 208 +++++++++++++++ Extensions/SDL_Mixer/Android/cpp/Mixer.h | 66 +++++ .../SDL_Mixer/Android/cpp/MixerXFixes.cmake | 24 ++ .../SDL_Mixer/Android/cpp/PCMStream.cpp | 171 ++++++++++++ .../Android/cpp/crypto/CMakeLists.txt | 30 +++ .../Android/cpp/crypto/EnsureSDK.cmake | 58 ++++ .../SDL_Mixer/Android/cpp/crypto/JNI.cpp | 80 ++++++ .../SDL_Mixer/Android/dependencies.json | 23 ++ Extensions/SDL_Mixer/ReadMe.md | 16 ++ Extensions/SDL_Mixer/SDL_Mixer.vcxproj | 10 + Extensions/_3rdLib/FFMpegAdapter.cpp | 12 +- Extensions/_3rdLib/FFMpegAdapterAndroid.cpp | 44 +++ Extensions/_3rdLib/FFMpegAdapterAndroid.h | 17 ++ .../_3rdLib/FFmpeg-build/FFmpegVersion.cmake | 4 + Extensions/_DeLib/Encryption/Core.h | 18 ++ Extensions/_DeLib/Encryption/README.md | 6 + Extensions/_DeLib/Encryption/tests/core.cpp | 28 +- Extensions/_Script/AndroidNative.py | 66 +++++ Extensions/_Script/FFMpeg.bat | 10 + Extensions/_Script/FFMpeg.py | 147 ++++++++++ Extensions/_Script/SDL_Mixer.py | 75 ++++++ Extensions/_Script/SDL_Mixer_Android.bat | 10 + 92 files changed, 5377 insertions(+), 9 deletions(-) create mode 100644 .agents/skills/execution-environment-recovery/SKILL.md create mode 100644 Extensions/Android_Gradle/Media-JNI.md create mode 100644 Extensions/Android_Gradle/app/src/main/java/Extensions/CRunFFMpeg.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/Extensions/CRunSDL_Mixer.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegPlayer.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegSource.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegStorage.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegTexture.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionFiles.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionStorage.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaCrypto.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaSource.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLAudioRuntime.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixer.java create mode 100644 Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixerStorage.java create mode 100644 Extensions/Android_Gradle/tests/ffmpeg/AdapterTest.java create mode 100644 Extensions/Android_Gradle/tests/ffmpeg/AudioTest.java create mode 100644 Extensions/Android_Gradle/tests/ffmpeg/DecodeTest.java create mode 100644 Extensions/Android_Gradle/tests/ffmpeg/compile_android.py create mode 100644 Extensions/Android_Gradle/tests/ffmpeg/run.py create mode 100644 Extensions/Android_Gradle/tests/ffmpeg/sdk_version_test.py create mode 100644 Extensions/Android_Gradle/tests/fusion_compile_fixture.py create mode 100644 Extensions/Android_Gradle/tests/media_crypto/NativeTest.java create mode 100644 Extensions/Android_Gradle/tests/media_crypto/run.py create mode 100644 Extensions/Android_Gradle/tests/media_crypto/sdk_version_test.py create mode 100644 Extensions/Android_Gradle/tests/media_packages.py create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/AdapterTest.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/MediaSourceTest.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/MixerTest.cpp create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/NativeTest.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/PCMTest.cpp create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/SessionsTest.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/compile_android.py create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/device/FilesProbe.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeActivity.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeProvider.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/device/StorageProbe.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/device/VideoProbe.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/device/build_probe.py create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/fixtures/SDLMixer.java create mode 100644 Extensions/Android_Gradle/tests/sdl_mixer/run.py create mode 100644 Extensions/FFMpeg/Android/.gitignore create mode 100644 Extensions/FFMpeg/Android/README.md create mode 100644 Extensions/FFMpeg/Android/build_sdk.py create mode 100644 Extensions/FFMpeg/Android/cpp/CMakeLists.txt create mode 100644 Extensions/FFMpeg/Android/cpp/CopyAdapter.cpp create mode 100644 Extensions/FFMpeg/Android/cpp/EnsureSDK.cmake create mode 100644 Extensions/FFMpeg/Android/cpp/JNI.cpp create mode 100644 Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.cpp create mode 100644 Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.h create mode 100644 Extensions/SDL_Mixer/Android/.gitignore create mode 100644 Extensions/SDL_Mixer/Android/README.md create mode 100644 Extensions/SDL_Mixer/Android/build_android.py create mode 100644 Extensions/SDL_Mixer/Android/build_crypto_sdk.py create mode 100644 Extensions/SDL_Mixer/Android/cpp/AudioRuntime.cpp create mode 100644 Extensions/SDL_Mixer/Android/cpp/AudioRuntime.h create mode 100644 Extensions/SDL_Mixer/Android/cpp/CMakeLists.txt create mode 100644 Extensions/SDL_Mixer/Android/cpp/JNI.cpp create mode 100644 Extensions/SDL_Mixer/Android/cpp/Mixer.cpp create mode 100644 Extensions/SDL_Mixer/Android/cpp/Mixer.h create mode 100644 Extensions/SDL_Mixer/Android/cpp/MixerXFixes.cmake create mode 100644 Extensions/SDL_Mixer/Android/cpp/PCMStream.cpp create mode 100644 Extensions/SDL_Mixer/Android/cpp/crypto/CMakeLists.txt create mode 100644 Extensions/SDL_Mixer/Android/cpp/crypto/EnsureSDK.cmake create mode 100644 Extensions/SDL_Mixer/Android/cpp/crypto/JNI.cpp create mode 100644 Extensions/SDL_Mixer/Android/dependencies.json create mode 100644 Extensions/_3rdLib/FFMpegAdapterAndroid.cpp create mode 100644 Extensions/_3rdLib/FFMpegAdapterAndroid.h create mode 100644 Extensions/_Script/AndroidNative.py create mode 100644 Extensions/_Script/FFMpeg.bat create mode 100644 Extensions/_Script/FFMpeg.py create mode 100644 Extensions/_Script/SDL_Mixer.py create mode 100644 Extensions/_Script/SDL_Mixer_Android.bat diff --git a/.agents/skills/android-runtime-parity/SKILL.md b/.agents/skills/android-runtime-parity/SKILL.md index a964b90d3..ad5cbd892 100644 --- a/.agents/skills/android-runtime-parity/SKILL.md +++ b/.agents/skills/android-runtime-parity/SKILL.md @@ -1,20 +1,50 @@ --- name: android-runtime-parity -description: Maintain Android Fusion object selection and creation helpers, checking the current Windows implementation for changes on every Android extension or runtime update. +description: Maintain Android Fusion runtime parity by checking Windows selection/creation helpers, using native application storage and file access, and synchronizing shared media dependencies. --- # Android runtime helper parity +For transient execution-environment or transport interruptions during this work, +follow [execution environment recovery](../execution-environment-recovery/SKILL.md): +announce recovery, use bounded increasing-delay retries, and verify write results +before resuming. Do not stop after the first transient disconnection. + Use this skill whenever updating Android extensions, Android exporter/runtime integration, or the shared object selection/creation helpers. Also use it when changing their Windows counterparts so Android does not silently fall behind. ## Implementation boundary -Keep Android runtime access in Java. These helpers manipulate Java objects, -selection chains and event/action state directly; do not introduce JNI just to -reuse the Windows helper classes. Pure computational plugin algorithms can have -a separate native core when justified by that plugin's requirements. +Treat **C++ as the first-class implementation** for existing C++ extensions. +Reuse their algorithms, playback state, queues, caches, synchronization and public +abstractions across platforms. Use conditional compilation to isolate necessary +platform dependencies; avoid a separate Android business-logic implementation. +JNI is a boundary to that shared C++ implementation, not a reason to replace it. + +Keep actual Fusion Android runtime access in Java. Selection chains, event/action +state, `CRunApp` storage/files and renderer calls manipulate Java runtime objects; +do not import Windows pointers/layouts or add JNI solely to imitate those APIs. +Use Java/Android APIs where required by the platform, with thin JNI marshalling +around the reused C++ core. A platform-specific adaptor is appropriate; a duplicate +portable engine is not. + +The goal is one C++ core that compiles and is reused across platforms, including +Windows. Conditional compilation should isolate unavoidable platform APIs, not +hide an independently rewritten mobile engine. Keep portable algorithms, state, +resource ownership and fixes in that common implementation. + +When a Windows dependency blocks reuse, report the concrete header/API, affected +call chain and missing portable contract before choosing a replacement strategy. +Bring the blocker to the user and pursue a shared-core refactor or an adaptor +through its existing interface. Do not silently work around it with a new Java +or C++ implementation of the same logic. Existing authorization for a specific +adaptor/refactor remains valid; this rule does not add a confirmation step to +routine work within that agreed design. +Scope compatibility branches to the platform, ABI and API range that actually +needs them. Keep other platforms on their existing code path, and verify that +with targeted compilation or preprocessing where practical. Report unresolved +platform dependencies instead of substituting another portable-core implementation. Windows defines the shared helper capabilities and filtering behavior. Preserve Android-specific runtime representations and creation entry points. Port the @@ -82,3 +112,108 @@ pointers, layouts or call tables. comparison instead of claiming HEAD includes them. In the completion report, briefly state whether Windows helpers changed, what was synchronized, any retained platform differences and the validation performed. + +## Application-owned extension state + +For every port, inspect Windows `CreateRunObject`, `DestroyRunObject`, `StartApp` +and `EndApp`, including `GetExtUserData`/`SetExtUserData` ownership. A Fusion +**global object flag does not keep the Java extension instance or its JNI handle +alive**: Android `killRunLoop` saves global values and then destroys frame objects. +`saveGlobalObjectsData`/`loadGlobalObjectsData` restore values, not private extension +fields or native resources. + +If Windows retains shared state until EndApp, make the Android state application +owned, keyed by the actual `CRunApp`, independently of instance reference counts. +Destroying the last frame instance must only detach it. Preserve playback, channel +settings, caches, source leases and periodic maintenance through frames with no +instances. Release on application exit, reset on Restart Application, and isolate +sub-applications while allowing intentionally shared platform devices. Keep user +pause separate from application suspension; handle backgrounding and exit even +when no extension instance remains. + +Use Fusion's **native extension global storage** first: subclass +`Extensions.CExtStorage`, acquire via `CRun.getStorage(id)`, publish via +`addStorage(data, id)`, and remove via `delStorage(id)`. These are already scoped +to `rhApp.extensionStorage`; do not build a parallel static application registry +or manufacture global objects/storage markers. Reuse the extension identifier +and reject ID/type collisions. Consult the supplied runtime and SDK documentation +before inventing a global-state mechanism. + +The supplied `CExtStorage` has no cleanup/update callbacks and is not cleared by +`CRunApp.killGlobalData()`. Add only the lifecycle adaptation actually needed for +native resource release and maintenance; do not replace exporter runtime classes. +Observe existing fields read-only when callbacks are unavailable, with documented +semantics and tests for transitions consumed between observation ticks. SDL Mixer +observes `CRunApp.playerNames` identity: `initGlobal()` replaces that array on each +Restart Application, including subapps sharing values, while ordinary frame changes +and Restart Frame preserve it. Also observe `CRunApp.file == null` after +`endApplication()`: subapp destruction can call it without leaving SL_QUIT set. +Do not write `adGO`, global values or player data +to create an artificial lifecycle token. + +Add regressions for last-instance destruction and recreation, a frame without the +object, frame restart versus application restart, exit without a surviving +instance, parent/sub-application isolation and cleanup, source lifetime, and pause +restoration. Distinguish fixture coverage from a full Fusion Runtime APK test. + +## Shared dependency version selection + +Check Windows dependency deployment recipes before choosing Android versions. +Use the same authoritative version/revision selection, with platform-specific +SDK paths, toolchains, features and archive hashes. In particular Android FFmpeg +must read `Extensions/_3rdLib/FFmpeg-build/FFmpegVersion.cmake`, used by Windows; +do not repeat a release constant in the Android builder. Validate cached SDK +version, corresponding-source hash and requested ABIs before using/publishing +it. Version updates must invalidate stale SDKs rather than silently reuse them. + +For media ports, prefer `CRunApp.openHFile()` for Fusion embedded files and +OBB/platform resolution. Keep ContentResolver URI grants and Android scoped-storage +semantics intact; do not invent filesystem paths for document URIs or assume a +Windows path exists on-device. Share source materialization/AES code, retain files +until native readers close, and scope caches through native application storage. + +When two extensions share an audio backend, ship one shared native runtime and +matching Java/vendor bindings. Do not replace a process-wide music callback when +the other extension needs Mixer X music streams. FFmpeg's PCM chunk/effect channels +can coexist with SDL_Mixer's music streams, following Windows SMI. Verify device +ownership, independent pause/closure, seek queue invalidation, audio-clock progress +and DSP tail drain together; ensure the overlapping files in both export ZIPs match. + +## Counterexample: a working Android rewrite is not a shared-core port + +In the initial FFMpeg Android implementation, the existing +`Extensions/_3rdLib/FFMpeg.h`, `FFMpegAdapter` factory and Windows `CopyAdapter` +architecture were left outside the Android build. A new +`Extensions/FFMpeg/Android/cpp/Player` implemented its own demux/decode scheduling, +queues, seek, draining and clocks. Java/JNI/rendering wrappers and extensive +playback tests made that backend functional, but the portable logic existed in +parallel and could diverge when the Windows implementation changed. Matching ACE +IDs, FFmpeg versions and observable test cases did not satisfy the requested +architecture. This was an implementation mistake, not a necessary Android limit. + +Do not repeat that approach or describe it as completed architectural parity. +Windows CNG/D3D/Fusion dependencies justify isolating those dependencies; they do +not justify rewriting the surrounding portable playback core. Moving the duplicate +engine to a directory named "shared" also does not fix this if Windows continues +executing the old implementation. + +Before implementation and before declaring a port complete: + +1. Inventory the existing C++ core, adaptor interfaces/factories, policies and + dependencies. Explain which original modules Android will actually compile and + call, and which specific platform APIs require replacement. +2. Reuse/refactor the existing core. Add platform adaptors through its established + interfaces and factories; guard platform headers/implementations with macros. + Preserve Windows call compatibility. Share portable fixes and regression tests. +3. Review the real call chains and build source lists for both platforms. Check + for duplicate decode loops, state machines, seek/EOF handling, clocks and + channel/cache policies hidden behind different filenames or languages. +4. Validate shared C++ behavior as well as platform integration. Passing Android + unit tests or an independent APK does not establish reuse or Windows parity. + Report any independently reimplemented behavior explicitly, with a concrete + platform reason; do not claim a stronger result than the code demonstrates. + +For FFMpeg, the intended direction is a common C++ playback core using the existing +`FFMpegAdapter`/`CopyAdapter` design, with MediaCodec/GLES platform implementations +and native Fusion Java bindings. Encrypter remains deferred until its agreed +refactoring/resumption point; this counterexample does not authorize its port. diff --git a/.agents/skills/execution-environment-recovery/SKILL.md b/.agents/skills/execution-environment-recovery/SKILL.md new file mode 100644 index 000000000..5f645758d --- /dev/null +++ b/.agents/skills/execution-environment-recovery/SKILL.md @@ -0,0 +1,59 @@ +--- +name: execution-environment-recovery +description: Recover from transient execution-environment, network or server disconnections with bounded increasing-delay retries and verified resumption. +--- + +# Execution environment recovery + +Applies to managed execution environments on any host. This is independent of +Android and iOS exporter workflows; it does not change their platform procedures. + +## Bounded backoff + +When an execution tool disconnects, its transport fails, or an environment +unexpectedly becomes unavailable during authorized work: + +1. Briefly tell the user that execution was interrupted and recovery is being + attempted. Keep the task active; a first transient failure is not the final + failure report. +2. After the initial failure, allow up to **three retries** with increasing + delays. Approximately **10, 50 and 100 seconds** is the default example, not + a rigid timing requirement. Adapt to explicit recovery status or retry hints; + small jitter is appropriate to avoid synchronized retries. +3. Use orchestration-side timers when executor-side waiting is unavailable. + Keep each blocking wait at most 60 seconds: split a longer delay, such as + 100 seconds, into two 50-second waits. Continue concise progress updates at + least every 60 seconds. Useful independent work can occupy the delay; do + not add another full delay if that work already consumed it. +4. Probe availability with a harmless read before resuming. Use the dedicated + environment-start wait tool only when an environment is actually marked + `starting` and its tool contract permits it; it is not a general wait for an + unavailable environment. Connector availability alone does not establish + executor recovery. +5. On recovery, inspect the workspace, branch/HEAD, pending changes and relevant + outputs. Resume from the interrupted step, preserving prior authorization, + task scope and validated results. + +## Verify effects before retrying writes + +A lost response does not establish that an operation failed. Before retrying a +commit, push, upload, deployment or external write, check its actual result. +Read the remote ref or object/status as appropriate; resume only the missing +step. Use compare-and-swap or an exact force-with-lease when updating a branch. +Do not blindly replay non-idempotent operations or duplicate already completed +work. Never describe a local result as published until the remote is verified. + +## Stop conditions and reporting + +After all three retries fail, stop dependent execution and report the remaining +blocker. State what completed, what was verified, what remains unpublished or +unfinished, and the relevant local/remote revision when available. Do not claim +success from a prepared result alone. Mark remote work as pending only after +recovery attempts are exhausted, unless an immediate status correction is needed +to prevent a misleading publication claim. + +Backoff is for plausibly transient infrastructure failures. Do not retry clear +permission/policy rejections, invalid requests, deterministic build failures or +an explicitly destroyed environment as though they were network collisions. +Honor an explicit user stop or retry limit. Do not request renewed permission +for work already authorized merely because its environment disconnected. diff --git a/.gitignore b/.gitignore index 18a1fadcd..487c857cf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,12 @@ Extensions/Android_Extensions.lnk # Generated FFmpeg SDK and dependency downloads /Extensions/_3rdLib/FFmpeg-sdk/ +/Extensions/_3rdLib/FFmpeg-sdk-Android/ /Extensions/_3rdLib/FFmpeg-build/out/ # Generated OpenSSL SDK, vcpkg checkout and dependency caches /Extensions/_3rdLib/Encrypt-sdk/ /Extensions/_3rdLib/Encrypt-build/out/ + +# Platform crypto SDK uses the Windows version pin with Android binaries. +/Extensions/_3rdLib/Encrypt-sdk-Android/ diff --git a/Extensions/Android_Gradle/Media-JNI.md b/Extensions/Android_Gradle/Media-JNI.md new file mode 100644 index 000000000..645d92649 --- /dev/null +++ b/Extensions/Android_Gradle/Media-JNI.md @@ -0,0 +1,172 @@ +# Android FFMpeg and SDL Mixer + +Both extensions use JNI and **one SDL Mixer X audio device**, with the same SDL2, +Mixer X, AudioCodecs and SoundTouch revisions selected for Windows. FFmpeg's +release/source hash comes from the Windows `FFmpegVersion.cmake`. Fusion object, +event, file and rendering access stays in Java. The extensions do not replace +`RuntimeAndroid.zip`, the Activity, manifest or Gradle exporter templates. + +## Build and install + +Python 3.12+, NDK r28c and CMake 3.22+ are required. From the repository root: + +```sh +# Linux/WSL/macOS with the corresponding host NDK: build FFmpeg's Android SDK. +python3 Extensions/FFMpeg/Android/build_sdk.py \ + --ndk /path/to/android-ndk-r28c \ + --output /path/to/ffmpeg-android-sdk + +# These publishers also work on Windows with its Windows NDK and prepared SDK. +python3 Extensions/_Script/SDL_Mixer.py +python3 Extensions/_Script/FFMpeg.py --ffmpeg-sdk /path/to/ffmpeg-android-sdk +``` + +`ANDROID_HOME`/`ANDROID_SDK_ROOT` use FTW's SDK discovery. Supply `--ndk` and +`--cmake` together to use explicit tools. On Windows use `python` or `py -3`; +`SDL_Mixer_Android.bat` and `FFMpeg.bat` in `Extensions/_Script` are the entry points. +FFmpeg source compilation needs bash/make and the Linux/macOS NDK; ordinary Windows +Python cannot run that source build. Prepare the SDK under WSL using a **Linux** +NDK, with the output on a Windows-accessible path, then set `OFE_FFMPEG_ROOT` to +its Windows path before building the Visual Studio projects. The SDL/AudioCodecs +and JNI builds themselves use the Windows NDK normally. + +Both publishers default to the same audio source/build cache under +`Extensions/SDL_Mixer/Android/out`. Custom caches must agree: SDL uses `--sources` +and `--build-root`; FFmpeg uses `--audio-sources` and `--audio-build-root`. +The FFmpeg SDK defaults to `Extensions/_3rdLib/FFmpeg-sdk-Android`, separate from +Windows' `FFmpeg-sdk`. Missing/stale SDKs are rebuilt on supported hosts; version, +source SHA and ABI validation prevents silently using another Android version. + +The outputs are: + +- `Extensions/SDL_Mixer/ToInstall/Files/Data/Runtime/Android/SDL_Mixer.zip` +- `Extensions/FFMpeg/ToInstall/Files/Data/Runtime/Android/FFMpeg.zip` + +Copy the ZIPs **unextracted** into Fusion's `Data/Runtime/Android` directory, then +export the application normally. Each ZIP is standalone. When both are installed, +use ZIPs from the **same revision/build caches**: the shared Java sources, SDL +bindings, SDL2/Mixer X/audio libraries and libc++ overlap intentionally and must +match. Rebuild/reinstall both when updating shared dependencies. Runtime ABI 2 +adds PCM streaming; an older `libofe_audio.so` cannot serve the new FFmpeg binary. + +`Run_Only Unicode` publishes Android ZIPs after the Windows builds. To build the +Windows runtimes alone, set `/p:SDLMixerPackageAndroid=false` and +`/p:FFMpegPackageAndroid=false`. Existing SDL_Mixer Windows archive packaging is +unchanged. First-time source/codec builds can take several minutes. + +For the repository's development Gradle app, pass +`-POFE_SDL_MIXER_SOURCES=/path/to/prepared/audio/sources` and +`-POFE_FFMPEG_ROOT=/path/to/ffmpeg-android-sdk`, after preparing the audio SDK +with its publisher/builder. For a custom audio build root, also pass +`-POFE_SDL_MIXER_BUILD_ROOT=/path/to/audio-builds`. Export ZIPs already contain their +native libraries, so normal Fusion exports do not rebuild dependencies. + +## Reading files + +Use the same path string in either extension's existing play/open/cache action. +No new Fusion object is needed for file access. The adapters use +`CRunApp.openHFile()` for Fusion embedded files and OBB/platform resolution, plus +Android's native file, assets, URL and ContentResolver APIs. Sources needing a +seekable native filename are materialized in private cache storage and retained +until the decoder finishes using them. AES keys use Windows' UTF-16/Win32 derivation. + +| Input | Example | Requirement | +| --- | --- | --- | +| Fusion embedded binary | The original embedded file name/path, including a Windows path exported by Fusion | Include the file in the Fusion application; native `openHFile` resolves it | +| APK asset | `asset://music/tone.ogg`, `file:///android_asset/music/tone.ogg` | Include the file as an Android asset | +| Asset/`mmf` fallback | `tone.wav` | Matching asset, with an `mmf/` fallback | +| Ordinary local file | `/storage/emulated/0/Android/data//files/movie.mp4` | A real path the application can read | +| File URI | `file:///.../movie%20name.mp4` | Same permissions as the corresponding local path | +| Android document/media URI | `content://...` from a file picker or MediaStore | A valid read grant, or applicable media permission | +| HTTP/HTTPS | `https://example.org/movie.mp4` | Internet permission; Android's cleartext policy applies to HTTP | + +On modern Android, `/sdcard/...` is not unrestricted desktop file access. Prefer a +Storage Access Framework document URI and retain its read grant when playback must +survive application restarts. URI permission acquisition/persistence belongs to the +file picker/exported application. The media extensions consume the supplied grant; +they do not request broad storage permissions or display an unsolicited picker. +If reading media through MediaStore, configure the appropriate Android media +permissions in the application. App-private and app-specific external files need +no broad storage permission. An arbitrary Windows filesystem path cannot refer to +a device file unless Fusion resolves it as an embedded binary. + +Both extensions report open failures through their existing behavior/logs. Inspect +`OFE.FFMpeg`/`OFE.SDL_Mixer` in logcat for missing files, denied URI grants, bad AES +keys or unavailable decoders. File materialization, open and seek remain synchronous; +large files/downloads need enough private cache space and can block the run thread. +MIDI soundfonts/instrument configuration remain external data, as with Mixer X on +Windows; compiling the MIDI decoders does not bundle instrument banks. + +## Playback and lifecycle + +SDL Mixer keeps streams, channel settings, binary cache and source leases in +Fusion's native `CExtStorage` (SMIX). Destroying the last frame instance only +unbinds it. Playback and maintenance continue in frames without an instance; +Restart Frame preserves state, Restart Application clears it, and application exit +releases it. Subapps have independent state and share only the output device. + +FFmpeg decoders/textures are per object, as on Windows. Its materialized-file cache +is application-owned native storage (FFMG). Closing an instance releases that +reader; cached files survive until erase, application restart or exit. Active +readers remain valid across cache erase. Shared lifecycle adaptation observes +existing runtime state (including the file cleared by `endApplication()`) and Activity callbacks because CExtStorage has no lifecycle +hooks; no fake global object or second global-state registry is introduced. + +FFmpeg PCM uses independent Mixer X chunk/effect channels, following Windows SMI; +SDL Mixer uses Mixer X music streams. FFmpeg's JNI calls the original C++ +`FFMpeg` class and its `FFMpegAdapter`/`CopyAdapter` factories, adding Android +MediaCodec/GLES adaptors beside the Windows implementations. Seek flushes shared +decoder/filter/resampler state. The original libavfilter atempo chain preserves +FFmpeg pitch; SoundTouch remains available to SDL Mixer. Native audio callbacks +drive the shared C++ media clock. Muted/no-audio video uses a monotonic clock. Focus loss freezes +shared audio; application suspension and user pause remain separate. + +Hardware rendering uses MediaCodec -> SurfaceTexture -> an OES-to-2D copy on the +Fusion GL thread. Software rendering uses FFmpeg RGBA -> JNI -> GLES texture +upload. Fusion draws both through its native ITexture/renderer, ink effects and +shaders. Hardware initialization failure falls back to software. Android has no +Windows cSurface/native-address ABI: FFmpeg surface-pointer expressions return 0; +SDL raw-address expressions return an empty string/0. Handled-memory SDL input +uses bounded opaque names and remains supported. + +See each extension's `Android/README.md` for dependency/build recipes and validation. +Full licensed Fusion exports and physical-device/route behavior still need user +validation; independent APK/fixture checks are not a full Fusion export test. + +## Shared encryption and deferred Encrypter runtime + +The media ports now use master's shared C++ encryption core through +`MediaCrypto` / `libofe_crypto.so`. Password expansion and AES-128-CBC/PKCS#7 +buffering run in the original `Encryption/KeyGen.h` and C API, including on +Windows; there is no production Java Cipher/key-derivation implementation. +Java retains Fusion's file resolver, application storage and lease ownership. +The existing plaintext-cache materialization behavior is retained. JNI passes +UTF-16 units and explicitly selects legacy Win32 seed width even on arm64/x86_64. + +Android OpenSSL follows `Extensions/_3rdLib/Encrypt-build/EncryptVersion.cmake`, +currently 3.6.5 with its pinned vcpkg revision. `SDL_Mixer/Android/build_crypto_sdk.py` +verifies the source SHA512 from that pinned port and prepares per-ABI static SDKs. +CMake auto-prepares missing/stale SDKs on supported Linux/WSL hosts. Supply +`OFE_ENCRYPT_ROOT` for an existing SDK; `OFE_ENCRYPT_AUTO_DEPLOY=OFF` rejects stale +version/revision/API/ABI inputs. Windows publishing can reuse an Android SDK built +under Linux/WSL with its matching NDK. The SDK's default Android path is +`Extensions/_3rdLib/Encrypt-sdk-Android`, separate from the Windows SDK. +Both exports ship identical `libofe_crypto.so`, original core sources, OpenSSL +source archive/hash/license and build recipe. OpenSSL is statically linked into +the owned JNI library; no process-wide OpenSSL shared library is introduced. + +Shared core/JNI regressions pass the supplied old Windows ciphertext and JCE +reference vectors, including Unicode/NUL/surrogate units, short-key Win32 seeds, +empty/padding boundaries and errors. A 32-bit Android API 21–23 file-I/O issue is +handled inside FileSource with narrowly scoped macros; other platforms retain +their original path. Four-ABI crypto JNI cross-builds target API 21. These are +native builds and host/fixture tests, not a new device or full Fusion APK run. + +```sh +python3 Extensions/Android_Gradle/tests/media_crypto/run.py --cmake /path/to/cmake +python3 Extensions/Android_Gradle/tests/sdl_mixer/compile_android.py \ + --android-jar /path/to/android.jar --crypto-library-dir /path/to/crypto-build/lib +``` + +The standalone Encrypter extension's Android ACE/runtime/export work remains +separate and deferred. See [its status](../Encrypter/ReadMe.md#android-status--shared-core-reviewed-extension-runtime-deferred). diff --git a/Extensions/Android_Gradle/app/.gitignore b/Extensions/Android_Gradle/app/.gitignore index 5cd56ed6c..f03474956 100644 --- a/Extensions/Android_Gradle/app/.gitignore +++ b/Extensions/Android_Gradle/app/.gitignore @@ -30,3 +30,7 @@ !/src/main/java/Extensions/CExtLoad.java !/src/main/java/Extensions/CRunTemplate.java + +!/src/main/java/Extensions/CRunFFMpeg.java + +!/src/main/java/Extensions/CRunSDL_Mixer.java diff --git a/Extensions/Android_Gradle/app/build.gradle b/Extensions/Android_Gradle/app/build.gradle index f42905f1c..d4ebf8fb8 100644 --- a/Extensions/Android_Gradle/app/build.gradle +++ b/Extensions/Android_Gradle/app/build.gradle @@ -12,6 +12,18 @@ android { // Shared C++ core. CMake builds one JNI library for every selected export ABI. externalNativeBuild { cmake { arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + if (project.hasProperty("OFE_SDL_MIXER_SOURCES")) { + arguments "-DOFE_SDL_MIXER_SOURCES=" + file(project.property("OFE_SDL_MIXER_SOURCES")).absolutePath + } + if (project.hasProperty("OFE_SDL_MIXER_BUILD_ROOT")) { + arguments "-DOFE_SDL_MIXER_BUILD_ROOT=" + file(project.property("OFE_SDL_MIXER_BUILD_ROOT")).absolutePath + } + if (project.hasProperty("OFE_ENCRYPT_ROOT")) { + arguments "-DOFE_ENCRYPT_ROOT=" + file(project.property("OFE_ENCRYPT_ROOT")).absolutePath + } + if (project.hasProperty("OFE_FFMPEG_ROOT")) { + arguments "-DOFE_FFMPEG_ROOT=" + file(project.property("OFE_FFMPEG_ROOT")).absolutePath + } if (project.hasProperty("FTW_ICONV_ROOT")) { arguments "-DFTW_ICONV_ROOT=" + file(project.property("FTW_ICONV_ROOT")).absolutePath } } @@ -68,6 +80,11 @@ android { version "3.22.1" } } + if (project.hasProperty("OFE_SDL_MIXER_SOURCES")) { + def sourceRoot = file(project.property("OFE_SDL_MIXER_SOURCES")) + sourceSets.main.java.srcDir(new File(sourceRoot, + "SDL2-5d249570393f7a37e037abf22cd6012a4cc56a71/android-project/app/src/main/java")) + } lintOptions { checkReleaseBuilds false // Or, if you prefer, you can continue to check for errors in release builds, diff --git a/Extensions/Android_Gradle/app/src/main/cpp/CMakeLists.txt b/Extensions/Android_Gradle/app/src/main/cpp/CMakeLists.txt index 41c701203..9a14611e9 100644 --- a/Extensions/Android_Gradle/app/src/main/cpp/CMakeLists.txt +++ b/Extensions/Android_Gradle/app/src/main/cpp/CMakeLists.txt @@ -23,3 +23,24 @@ if(NOT ANDROID) "$ENV{JAVA_HOME}/include/win32" REQUIRED) target_include_directories(findtheway PRIVATE "${FTW_JNI_INCLUDE}" "${FTW_JNI_PLATFORM_INCLUDE}") endif() + +if(ANDROID AND OFE_SDL_MIXER_SOURCES) + set(OFE_SDL_SOURCE "${OFE_SDL_MIXER_SOURCES}/SDL2-5d249570393f7a37e037abf22cd6012a4cc56a71") + set(OFE_MIXERX_SOURCE "${OFE_SDL_MIXER_SOURCES}/MixerX-72139aa7332062de541de50708742fe4ee2ae05c") + set(OFE_SOUNDTOUCH_SOURCE "${OFE_SDL_MIXER_SOURCES}/SoundTouch-e83424d5928ab8513d2d082779c275765dee31b9") + set(OFE_AUDIOCODECS_SOURCE "${OFE_SDL_MIXER_SOURCES}/AudioCodecs-bb11ca3cc1d7597b23a530d519b3e636dceb7b23") + get_filename_component(ofe_audio_cache "${OFE_SDL_MIXER_SOURCES}/../build" ABSOLUTE) + set(OFE_SDL_MIXER_BUILD_ROOT "${ofe_audio_cache}" CACHE PATH "Prepared shared audio builds") + set(OFE_AUDIOCODECS_ROOT "${OFE_SDL_MIXER_BUILD_ROOT}/${ANDROID_ABI}/codecs-sdk") + if(NOT EXISTS "${OFE_AUDIOCODECS_ROOT}/lib/libopusfile.a") + message(FATAL_ERROR "Prepare the audio SDK using SDL_Mixer/Android/build_android.py and set OFE_SDL_MIXER_BUILD_ROOT to its build root") + endif() + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../../../../SDL_Mixer/Android/cpp" + "${CMAKE_CURRENT_BINARY_DIR}/sdl-mixer") +endif() + +# Optional development build. Export packages carry prebuilt FFmpeg JNI libraries. +if(ANDROID AND OFE_FFMPEG_ROOT) + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../../../../FFMpeg/Android/cpp" + "${CMAKE_CURRENT_BINARY_DIR}/ffmpeg") +endif() diff --git a/Extensions/Android_Gradle/app/src/main/java/Extensions/CExtLoad.java b/Extensions/Android_Gradle/app/src/main/java/Extensions/CExtLoad.java index 665871aa7..a77c0091d 100644 --- a/Extensions/Android_Gradle/app/src/main/java/Extensions/CExtLoad.java +++ b/Extensions/Android_Gradle/app/src/main/java/Extensions/CExtLoad.java @@ -84,6 +84,14 @@ else if (name.compareToIgnoreCase("OUYA")==0) // STARTCUT // STARTCUT // STARTCUT + if (name.compareToIgnoreCase("SDL_Mixer")==0) + { + object=new CRunSDL_Mixer(); + } + if (name.compareToIgnoreCase("FFMpeg")==0) + { + object=new CRunFFMpeg(); + } if (name.compareToIgnoreCase("FindTheWay")==0) { object=new CRunFindTheWay(); diff --git a/Extensions/Android_Gradle/app/src/main/java/Extensions/CRunFFMpeg.java b/Extensions/Android_Gradle/app/src/main/java/Extensions/CRunFFMpeg.java new file mode 100644 index 000000000..98be3f92e --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/Extensions/CRunFFMpeg.java @@ -0,0 +1,181 @@ +package Extensions; + +import Actions.CActExtension; +import Conditions.CCndExtension; +import Expressions.CValue; +import OpenGL.GLRenderer; +import RunLoop.CCreateObjectInfo; +import Runtime.MMFRuntime; +import Services.CBinaryFile; +import _DeLib.FFMpegPlayer; +import _DeLib.MediaSource; +import _DeLib.FFMpegStorage; +import _DeLib.FFMpegTexture; + +/** Android Fusion runtime adapter. ACE numbers match FFMpeg/Main.cpp. */ +public final class CRunFFMpeg extends CRunExtension { + private FFMpegPlayer player; + private MediaSource source; + private FFMpegTexture texture = new FFMpegTexture(); + private String name = "", key = "", videoCodec = "", audioCodec = ""; + private int width, height, threads, volume = 100; + private boolean stretch, autoPlay, loop, accurate, cache, wantedHardware, copyToTexture, mute; + private boolean playing, finished, hidden, suspended; + private float tempo = 1; + private final CValue result = new CValue(0); + @Override public int getNumberOfConditions() { return 8; } + @Override public boolean createRunObject(CBinaryFile file, CCreateObjectInfo cob, int version) { + width = file.readUnsignedShort(); height = file.readUnsignedShort(); + file.readByte(); // Windows HWA surface preference; Android always uses the runtime renderer. + stretch = file.readByte() != 0; autoPlay = file.readByte() != 0; loop = file.readByte() != 0; + threads = Math.max(0, Math.min(16, file.readInt())); file.readInt(); + accurate = file.readByte() != 0; cache = file.readByte() != 0; file.skipBytes(2); + wantedHardware = file.readInt() != 0; mute = file.readByte() != 0; + copyToTexture = file.readByte() != 0; file.readByte(); + wantedHardware |= copyToTexture; + ho.hoImgWidth = width; ho.hoImgHeight = height; return true; + } + private void release() { + if (player != null) { player.close(); player = null; } + texture.close(); + if (source != null) { source.close(); source = null; } + } + private void failure(Exception ex) { android.util.Log.e("OFE.FFMpeg", ex.getMessage(), ex); } + private void open(String path, String password, long position) { + release(); name = path; key = password; finished = hidden = false; + try { + source = FFMpegStorage.get(rh, MMFRuntime.inst).files.open(path, password, cache); + player = new FFMpegPlayer(MMFRuntime.inst, source.path, wantedHardware ? texture.decoderSurface() : null, + threads, videoCodec, audioCodec, mute); + player.volume(volume); player.tempo(tempo); + if (position > 0) player.seek(position, 1, accurate); + else player.tick(true); + updateTexture(); + playing = autoPlay; player.play(playing); player.suspend(suspended); + } catch (Exception ex) { + release(); playing = false; failure(ex); ho.generateEvent(5, 0); + } + } + private void updateTexture() { + if (player == null) return; + long[] info = player.info(); + int w = (int)info[0], h = (int)info[1]; + if (info[6] != 0) { if (info[4] != 0) texture.copyHardware(w, h); else texture.copySoftware(player, w, h); } + ho.hoImgWidth = stretch ? width : w; ho.hoImgHeight = stretch ? height : h; + ho.hoImgXSpot = ho.hoImgYSpot = 0; + } + @Override public int handleRunObject() { + if (player == null) return 0; + try { + if (!texture.contextValid()) { reinitDisplay(); return REFLAG_DISPLAY; } + int state = player.tick(false); + if (state == 1) { hidden = false; updateTexture(); } + else if (player.info()[4] != 0) updateTexture(); // SurfaceTexture callbacks can arrive after the decode tick. + if (state == 2 && !finished) { + finished = true; + if (loop) { player.seek(0, 1, accurate); finished = false; updateTexture(); } + else { playing = false; player.play(false); } + ho.generateEvent(4, 0); // All state committed before the reentrant Fusion event. + } + return REFLAG_DISPLAY; + } catch (Exception ex) { failure(ex); release(); playing = false; return REFLAG_DISPLAY; } + } + @Override public void displayRunObject() { + if (player == null || hidden || texture.texture() < 0) return; + float sx = stretch ? width / (float)texture.getWidth() : ho.roc.rcScaleX; + float sy = stretch ? height / (float)texture.getHeight() : ho.roc.rcScaleY; + GLRenderer.inst.renderScaledRotatedImage(texture, true, ho.roc.rcAngle, sx, sy, 0, 0, + ho.hoX - rh.rhWindowX, ho.hoY - rh.rhWindowY, texture.getWidth(), texture.getHeight(), + ho.ros.rsEffectShader, ho.ros.rsEffect, ho.ros.rsEffectParam); + } + @Override public void reinitDisplay() { + if (player == null) { texture.close(); return; } + long position = player.info()[3]; boolean resume = playing; + open(name, key, position); + if (player != null) { playing = resume; player.play(resume); } + } + @Override public void pauseRunObject() { suspended = true; if (player != null) player.suspend(true); } + @Override public void continueRunObject() { suspended = false; if (player != null) player.suspend(false); } + @Override public void destroyRunObject(boolean fast) { release(); } + @Override public boolean condition(int id, CCndExtension cnd) { + switch (id) { + case 0: return player != null; + case 1: return player != null && playing && !finished; + case 2: return loop; + case 3: return finished; + case 4: case 5: return name.equals(cnd.getParamExpString(rh, 0)); + case 6: return player != null && player.info()[4] != 0; + case 7: return wantedHardware; + default: return false; + } + } + private void seek(long ms, int flags) { + if (player != null) { player.seek(Math.max(0, ms), flags, accurate); finished = hidden = false; + if ((flags & (1 << 16)) == 0) updateTexture(); } + } + @Override public void action(int id, CActExtension act) { + try { + switch (id) { + case 0: open(act.getParamExpString(rh, 0), act.getParamExpString(rh, 1), 0); break; + case 1: release(); playing = finished = false; break; + case 2: playing = true; hidden = false; if (player != null) player.play(true); break; + case 3: playing = false; if (player != null) player.play(false); break; + case 4: volume = Math.max(0, Math.min(100, act.getParamExpression(rh, 0))); if (player != null) player.volume(volume); break; + case 5: loop = act.getParamExpression(rh, 0) != 0; break; + case 6: seek(act.getParamExpression(rh, 0), 1); break; + case 7: seek(act.getParamExpression(rh, 0), act.getParamExpression(rh, 1)); break; + case 8: threads = Math.max(0, Math.min(16, act.getParamExpression(rh, 0))); break; + case 9: accurate = act.getParamExpression(rh, 0) != 0; break; + case 10: { + String path = act.getParamExpString(rh, 0), password = act.getParamExpString(rh, 1); + if (cache) FFMpegStorage.get(rh, MMFRuntime.inst).files.cache(path, password); break; + } + case 11: { String path = act.getParamExpString(rh, 0); if (cache) FFMpegStorage.get(rh, MMFRuntime.inst).files.erase(path); break; } + case 12: { + String path = act.getParamExpString(rh, 0), password = act.getParamExpString(rh, 1); + open(path, password, Math.max(0, act.getParamExpression(rh, 2))); break; + } + case 13: { + String device = act.getParamExpString(rh, 0); + wantedHardware = "mediacodec".equalsIgnoreCase(device); copyToTexture &= wantedHardware; break; + } + case 14: width = Math.max(0, act.getParamExpression(rh, 0)); height = Math.max(0, act.getParamExpression(rh, 1)); stretch = true; + ho.hoImgWidth = width; ho.hoImgHeight = height; break; + case 15: { + float value = (float)act.getParamExpDouble(rh, 0); + if ((Float.isNaN(value) || Float.isInfinite(value)) || value <= 0 || value > 32) throw new IllegalArgumentException("Invalid playback tempo"); + if (player != null) player.tempo(value); tempo = value; break; + } + case 16: mute = act.getParamExpression(rh, 0) != 0; break; + case 17: videoCodec = act.getParamExpString(rh, 0); audioCodec = act.getParamExpString(rh, 1); break; + case 18: if (!playing) hidden = true; break; + case 19: copyToTexture = act.getParamExpression(rh, 0) != 0; if (copyToTexture) wantedHardware = true; break; + default: break; + } + } catch (Exception ex) { failure(ex); } + } + @Override public CValue expression(int id) { + switch (id) { + case 0: result.forceString(name); break; + case 1: result.forceInt(player == null ? -1 : (int)player.info()[3]); break; + case 2: result.forceInt(player == null ? -1 : (int)player.info()[2]); break; + case 3: result.forceInt(volume); break; + // Desktop cSurface* values have no Android Runtime counterpart. Consume parameters. + case 4: ho.getExpParam().getInt(); result.forceInt(0); break; + case 5: ho.getExpParam().getInt(); ho.getExpParam().getInt(); result.forceInt(0); break; + case 6: result.forceInt(player != null ? 1 : 0); break; + case 7: result.forceInt(player != null && playing && !finished ? 1 : 0); break; + case 8: result.forceInt(loop ? 1 : 0); break; + case 9: result.forceInt(finished ? 1 : 0); break; + case 10: result.forceInt(player != null && player.info()[4] != 0 ? 1 : 0); break; + case 11: result.forceString("mediacodec"); break; + case 12: result.forceString(wantedHardware ? "mediacodec" : "none"); break; + case 13: result.forceDouble(tempo); break; + case 14: result.forceString(videoCodec); break; + case 15: result.forceString(audioCodec); break; + case 16: result.forceInt(threads); break; + default: result.forceInt(0); break; + } + return result; + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/Extensions/CRunSDL_Mixer.java b/Extensions/Android_Gradle/app/src/main/java/Extensions/CRunSDL_Mixer.java new file mode 100644 index 000000000..da42dd4c2 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/Extensions/CRunSDL_Mixer.java @@ -0,0 +1,87 @@ +package Extensions; + +import Actions.CActExtension; +import Conditions.CCndExtension; +import Expressions.CValue; +import RunLoop.CCreateObjectInfo; +import Runtime.MMFRuntime; +import Services.CBinaryFile; +import _DeLib.SDLMixer; +import _DeLib.SDLMixerStorage; + +/** Fusion Android binding for the existing 7/17/14 Windows ACE table. */ +public final class CRunSDL_Mixer extends CRunExtension { + private SDLMixer mixer; + private SDLMixerStorage.Binding binding; + private final CValue result = new CValue(0); + @Override public int getNumberOfConditions() { return 7; } + private void failure(Throwable error) { android.util.Log.e("OFE.SDL_Mixer", error.getMessage(), error); } + @Override public boolean createRunObject(CBinaryFile file, CCreateObjectInfo cob, int version) { + try { binding = SDLMixerStorage.attach(rh, MMFRuntime.inst); mixer = binding.mixer(); return true; } + catch (RuntimeException | LinkageError error) { failure(error); return false; } + } + @Override public void destroyRunObject(boolean fast) { if (binding != null) { binding.close(); binding = null; mixer = null; } } + @Override public int handleRunObject() { if (mixer != null) mixer.update(); return 0; } + @Override public void pauseRunObject() { if (binding != null) binding.suspend(true); } + @Override public void continueRunObject() { if (binding != null) binding.suspend(false); } + @Override public boolean condition(int id, CCndExtension cnd) { + if (mixer == null) return false; + try { + int channel = 0; boolean exclusive = true; String name = ""; + switch (id) { + case 0: case 3: case 5: channel = cnd.getParamExpression(rh, 0); exclusive = cnd.getParamExpression(rh, 1) != 0; break; + case 2: channel = cnd.getParamExpression(rh, 0); break; + case 6: name = cnd.getParamExpString(rh, 0); break; + default: break; + } + return mixer.condition(id, channel, exclusive, name); + } catch (RuntimeException error) { failure(error); return false; } + } + @Override public void action(int id, CActExtension act) { + if (mixer == null) return; + try { + if (id == 0 || id == 1) { + String name = act.getParamExpString(rh, 0), key = act.getParamExpString(rh, 1); + mixer.play(name, key, act.getParamExpression(rh, 2), id == 0, act.getParamExpression(rh, 3), act.getParamExpression(rh, 4)); return; + } + if (id == 10 || id == 12) { mixer.cache(act.getParamExpString(rh, 0), act.getParamExpString(rh, 1), id == 12); return; } + if (id == 11) { mixer.erase(act.getParamExpString(rh, 0)); return; } + int channel = 0; boolean exclusive = true; double a = 0, b = 0; String text = ""; + switch (id) { + case 2: case 3: channel = act.getParamExpression(rh, 0); a = act.getParamExpression(rh, 1); exclusive = act.getParamExpression(rh, 2) != 0; break; + case 4: case 5: channel = act.getParamExpression(rh, 0); exclusive = act.getParamExpression(rh, 1) != 0; break; + case 6: channel = act.getParamExpression(rh, 0); a = act.getParamExpDouble(rh, 1); break; + case 7: case 13: a = act.getParamExpression(rh, 0); break; + case 8: channel = act.getParamExpression(rh, 0); a = act.getParamExpDouble(rh, 1); b = act.getParamExpDouble(rh, 2); break; + case 9: channel = act.getParamExpression(rh, 0); a = act.getParamExpression(rh, 1); text = act.getParamExpString(rh, 2); b = act.getParamExpression(rh, 3); exclusive = false; break; + case 14: case 15: channel = act.getParamExpression(rh, 0); a = act.getParamExpression(rh, 1); b = act.getParamExpression(rh, 2); exclusive = act.getParamExpression(rh, 3) != 0; break; + case 16: channel = act.getParamExpression(rh, 0); a = act.getParamExpression(rh, 1); exclusive = act.getParamExpression(rh, 2) != 0; break; + default: return; + } + mixer.action(id, channel, exclusive, a, b, text); + } catch (Exception error) { failure(error); } + } + @Override public CValue expression(int id) { + try { + int channel = 0; boolean exclusive = true; String name = ""; + switch (id) { + case 0: case 1: case 10: case 11: case 12: case 13: channel = ho.getExpParam().getInt(); exclusive = ho.getExpParam().getInt() != 0; break; + case 2: case 3: case 5: channel = ho.getExpParam().getInt(); break; + case 4: name = ho.getExpParam().getString(); exclusive = ho.getExpParam().getInt() != 0; break; + // Native desktop address expressions have no safe Android int-pointer ABI. + case 6: ho.getExpParam().getString(); ho.getExpParam().getInt(); ho.getExpParam().getInt(); result.forceString(""); return result; + case 7: { + String access = ho.getExpParam().getString(), binary = ho.getExpParam().getString(); + int offset = ho.getExpParam().getInt(), size = ho.getExpParam().getInt(); + result.forceString(mixer == null ? "" : mixer.memoryName(access, binary, offset, size)); return result; + } + case 8: ho.getExpParam().getString(); ho.getExpParam().getInt(); result.forceInt(0); return result; + default: break; + } + if (id == 5) { result.forceString(mixer == null ? "" : mixer.channelName(channel)); return result; } + double value = mixer == null ? 0 : mixer.number(id, channel, exclusive, name); + if (id == 2 || id == 3) result.forceDouble(value); else result.forceInt((int)value); + } catch (RuntimeException error) { failure(error); result.forceInt(0); } + return result; + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegPlayer.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegPlayer.java new file mode 100644 index 000000000..51d800fd3 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegPlayer.java @@ -0,0 +1,58 @@ +package _DeLib; + +import android.content.Context; +import android.view.Surface; +import java.nio.ByteBuffer; + +/** Serialized JNI ownership; playback, clocks and decoding belong to the shared C++ core. */ +public final class FFMpegPlayer implements AutoCloseable { + static { System.loadLibrary("ofe_ffmpeg"); } + private long handle; + private boolean retained; + private boolean playing, suspended; + private float speed = 1, volume = 1; + private static native long nOpen(String path, Surface surface, int threads, String video, String audio, boolean mute); + private static native void nClose(long handle); + private static native long[] nInfo(long handle); + private static native int nNext(long handle, long targetMs); + private static native void nSeek(long handle, long ms, int flags, boolean accurate); + private static native void nCopy(long handle, ByteBuffer pixels, boolean premultiply); + private static native void nState(long handle, boolean playing, boolean suspended, float volume, double tempo); + private static native double nClock(long handle); + private static native boolean nPending(long handle); + public FFMpegPlayer(String path, Surface surface, int threads, String video, String audio, boolean mute) { + this(null, path, surface, threads, video, audio, mute); + } + public FFMpegPlayer(Context context, String path, Surface surface, int threads, String video, String audio, boolean mute) { + if (!mute && context == null) throw new IllegalArgumentException("Android audio requires an application Context"); + try { + if (!mute) { SDLAudioRuntime.retain(context); retained = true; } + handle = nOpen(path, surface, threads, video, audio, mute); + } catch (RuntimeException | LinkageError error) { if (retained) SDLAudioRuntime.release(); retained = false; throw error; } + } + private long live() { if (handle == 0) throw new IllegalStateException("FFmpeg player closed"); return handle; } + public synchronized long[] info() { return nInfo(live()); } + public synchronized void copyPixels(ByteBuffer buffer) { nCopy(live(), buffer, false); } + public synchronized void copyPixels(ByteBuffer buffer, boolean premultiply) { nCopy(live(), buffer, premultiply); } + public synchronized long clock() { return Math.max(0, (long)nClock(live())); } + private void state() { nState(live(), playing, suspended, volume, speed); } + public synchronized void play(boolean value) { playing = value; if (value && retained) SDLAudioRuntime.requestFocus(); state(); } + public synchronized void suspend(boolean value) { suspended = value; state(); } + public synchronized void volume(int value) { volume = Math.max(0, Math.min(100, value)) / 100f; state(); } + public synchronized void tempo(float value) { + if ((Float.isNaN(value) || Float.isInfinite(value)) || value <= 0 || value > 32) throw new IllegalArgumentException("Invalid playback tempo"); + speed = value; state(); + } + public synchronized void seek(long ms, int flags, boolean accurate) { + nSeek(live(), ms, flags, accurate); + } + public synchronized int tick(boolean preview) { + if ((!playing || suspended) && !preview) return 0; + // The native core owns frame scheduling and decoder/filter/audio completion. + return nNext(live(), preview ? Long.MAX_VALUE : 0); + } + @Override public synchronized void close() { + try { if (handle != 0) { nClose(handle); handle = 0; } } + finally { if (retained) { retained = false; SDLAudioRuntime.release(); } } + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegSource.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegSource.java new file mode 100644 index 000000000..6dd9c51b3 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegSource.java @@ -0,0 +1,15 @@ +package _DeLib; +import android.content.Context; +/** Compatibility facade; source ownership and AES support are shared with SDL Mixer. */ +public final class FFMpegSource implements AutoCloseable { + private final MediaSource source; + public final String path; + private FFMpegSource(MediaSource source) { this.source = source; this.path = source.path; } + public static FFMpegSource open(Context context, String name, String key, boolean cache) throws Exception { + return new FFMpegSource(MediaSource.open(context, name, key, cache)); + } + public static void cache(Context context, String name, String key) throws Exception { MediaSource.cache(context, name, key); } + public static void erase(Context context, String name) throws Exception { MediaSource.erase(context, name); } + static byte[] keyMaterial(String key) { return MediaSource.keyMaterial(key); } + @Override public void close() { source.close(); } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegStorage.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegStorage.java new file mode 100644 index 000000000..61f0dec0c --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegStorage.java @@ -0,0 +1,17 @@ +package _DeLib; + +import Application.CRunApp; +import RunLoop.CRun; +import android.app.Activity; + +/** Windows' application-owned FFmpeg file cache, held by native Fusion storage. */ +public final class FFMpegStorage extends FusionStorage { + private static final int STORAGE_ID = 'F' | ('F' << 8) | ('M' << 16) | ('G' << 24); + public final MediaSource.Resolver files; + private FFMpegStorage(CRunApp app, Activity activity) { super(app, activity, STORAGE_ID); files=FusionFiles.resolver(app,activity); } + public static FFMpegStorage get(CRun run,Activity activity) { + return acquire(run,activity,STORAGE_ID,FFMpegStorage.class,()->new FFMpegStorage(run.rhApp,activity)); + } + @Override protected void update() {} + @Override protected void release() { files.close(); } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegTexture.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegTexture.java new file mode 100644 index 000000000..a2aeec5d4 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FFMpegTexture.java @@ -0,0 +1,126 @@ +package _DeLib; + +import android.graphics.SurfaceTexture; +import android.opengl.EGL14; +import android.opengl.EGLContext; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.view.Surface; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.util.concurrent.atomic.AtomicBoolean; +import OpenGL.ITexture; +import OpenGL.GLRenderer; +import Runtime.MMFRuntime; + +/** Fusion renders a regular ITexture; only the hardware adaptor sees external OES. */ +public final class FFMpegTexture extends ITexture implements AutoCloseable { + private int texture, external, framebuffer, program, vbo, width, height; + private SurfaceTexture producer; + private Surface surface; + private EGLContext context; + private final AtomicBoolean available = new AtomicBoolean(); + private final float[] transform = new float[16]; + private ByteBuffer pixels; + private static native void nBlit(int program, int external, int framebuffer, int vbo, + int width, int height, float[] transform); + private static int integer(int name) { int[] value = new int[1]; GLES20.glGetIntegerv(name, value, 0); return value[0]; } + public boolean contextValid() { return context == null || context.equals(EGL14.eglGetCurrentContext()); } + public Surface decoderSurface() { + if (surface != null) return surface; + context = EGL14.eglGetCurrentContext(); + if (EGL14.EGL_NO_CONTEXT.equals(context)) return null; + int old = integer(GLES11Ext.GL_TEXTURE_BINDING_EXTERNAL_OES); + int[] ids = new int[1]; GLES20.glGenTextures(1, ids, 0); external = ids[0]; + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, external); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, old); + producer = new SurfaceTexture(external); + producer.setOnFrameAvailableListener(st -> available.set(true)); + surface = new Surface(producer); return surface; + } + private void resize(int w, int h) { + if (w == width && h == height && texture != 0) return; + context = EGL14.eglGetCurrentContext(); + width = w; height = h; + if (w <= 0 || h <= 0 || (long)w * h > 67108864L) throw new IllegalStateException("Invalid video size"); + int old = integer(GLES20.GL_TEXTURE_BINDING_2D); + if (texture == 0) { int[] ids = new int[1]; GLES20.glGenTextures(1, ids, 0); texture = ids[0]; } + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, w, h, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, old); + pixels = null; + } + public void copySoftware(FFMpegPlayer player, int w, int h) { + GLRenderer.inst.renderFlush(); resize(w, h); + if (pixels == null) pixels = ByteBuffer.allocateDirect(w * h * 4).order(ByteOrder.nativeOrder()); + pixels.clear(); player.copyPixels(pixels, MMFRuntime.inst.texPremultiply); + int old = integer(GLES20.GL_TEXTURE_BINDING_2D), alignment = integer(GLES20.GL_UNPACK_ALIGNMENT); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture); GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, 1); + GLES20.glTexSubImage2D(GLES20.GL_TEXTURE_2D, 0, 0, 0, w, h, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, pixels); + GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, alignment); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, old); + } + private static int shader(int type, String source) { + int s = GLES20.glCreateShader(type); GLES20.glShaderSource(s, source); GLES20.glCompileShader(s); + int[] ok = new int[1]; GLES20.glGetShaderiv(s, GLES20.GL_COMPILE_STATUS, ok, 0); + if (ok[0] == 0) { String error = GLES20.glGetShaderInfoLog(s); GLES20.glDeleteShader(s); throw new IllegalStateException(error); } + return s; + } + private void initHardwareCopy() { + // Fusion bitmap uploads expose their first (top) row at v=0; invert the FBO output to match. + int vs = shader(GLES20.GL_VERTEX_SHADER, "attribute vec2 position; varying vec2 uv; uniform mat4 transform; void main(){ gl_Position=vec4(position,0.,1.); vec2 tc=(position+1.)*.5; tc.y=1.-tc.y; uv=(transform*vec4(tc,0.,1.)).xy; }"); + int fs = shader(GLES20.GL_FRAGMENT_SHADER, "#extension GL_OES_EGL_image_external : require\nprecision mediump float; varying vec2 uv; uniform samplerExternalOES image; void main(){gl_FragColor=texture2D(image,uv);}"); + program = GLES20.glCreateProgram(); GLES20.glAttachShader(program, vs); GLES20.glAttachShader(program, fs); + GLES20.glBindAttribLocation(program, 0, "position"); GLES20.glLinkProgram(program); + GLES20.glDeleteShader(vs); GLES20.glDeleteShader(fs); + int[] ok = new int[1]; GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, ok, 0); + if (ok[0] == 0) throw new IllegalStateException(GLES20.glGetProgramInfoLog(program)); + int[] ids = new int[1]; GLES20.glGenFramebuffers(1, ids, 0); framebuffer = ids[0]; + GLES20.glGenBuffers(1, ids, 0); vbo = ids[0]; + int old = integer(GLES20.GL_ARRAY_BUFFER_BINDING); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbo); + FloatBuffer vertices = ByteBuffer.allocateDirect(32).order(ByteOrder.nativeOrder()).asFloatBuffer(); + vertices.put(new float[]{-1,-1, 1,-1, -1,1, 1,1}).position(0); + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, 32, vertices, GLES20.GL_STATIC_DRAW); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, old); + } + public boolean copyHardware(int w, int h) { + if (producer == null || !available.getAndSet(false)) return false; + GLRenderer.inst.renderFlush(); resize(w, h); + int oldExternal = integer(GLES11Ext.GL_TEXTURE_BINDING_EXTERNAL_OES); + producer.updateTexImage(); producer.getTransformMatrix(transform); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oldExternal); + if (program == 0) initHardwareCopy(); + int old = integer(GLES20.GL_FRAMEBUFFER_BINDING); + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, framebuffer); + GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, texture, 0); + boolean complete = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER) == GLES20.GL_FRAMEBUFFER_COMPLETE; + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, old); + if (!complete) throw new IllegalStateException("FFmpeg framebuffer incomplete"); + nBlit(program, external, framebuffer, vbo, w, h, transform); return true; + } + @Override public int texture() { return texture == 0 ? -1 : texture; } + @Override public int getWidth() { return width; } + @Override public int getHeight() { return height; } + @Override public void close() { + if (surface != null) surface.release(); + if (producer != null) { producer.setOnFrameAvailableListener(null); producer.release(); } + surface = null; producer = null; + if (contextValid()) { + if (texture != 0) GLES20.glDeleteTextures(1, new int[]{texture}, 0); + if (external != 0) GLES20.glDeleteTextures(1, new int[]{external}, 0); + if (framebuffer != 0) GLES20.glDeleteFramebuffers(1, new int[]{framebuffer}, 0); + if (vbo != 0) GLES20.glDeleteBuffers(1, new int[]{vbo}, 0); + if (program != 0) GLES20.glDeleteProgram(program); + } + texture = external = framebuffer = program = vbo = width = height = 0; + pixels = null; context = null; available.set(false); + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionFiles.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionFiles.java new file mode 100644 index 000000000..ca44207c9 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionFiles.java @@ -0,0 +1,21 @@ +package _DeLib; + +import Application.CRunApp; +import android.content.Context; +import java.io.FilterInputStream; +import java.io.IOException; + +/** Uses Fusion's own embedded-file/OBB/path resolution without an exporter patch. */ +public final class FusionFiles { + private FusionFiles() {} + public static MediaSource.Resolver resolver(CRunApp app, Context context) { + return new MediaSource.Resolver(context, name -> { + CRunApp.HFile file = app.openHFile(name); + if (file == null) return null; + if (file.stream == null) { file.close(); return null; } + return new FilterInputStream(file.stream) { + @Override public void close() throws IOException { file.close(); } + }; + }); + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionStorage.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionStorage.java new file mode 100644 index 000000000..2323cf930 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/FusionStorage.java @@ -0,0 +1,102 @@ +package _DeLib; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import Application.CRunApp; +import Extensions.CExtStorage; +import RunLoop.CRun; +import java.util.IdentityHashMap; +import java.util.Map; + +/** Lifecycle adaptation for Fusion's existing application extension storage. */ +public abstract class FusionStorage extends CExtStorage { + protected final CRunApp app; + protected final Activity activity; + private final Map generations = new IdentityHashMap<>(); + private Handler handler; + private boolean closed, background; + protected FusionStorage(CRunApp app, Activity activity, int id) { + this.app = app; this.activity = activity; this.id = id; + // initGlobal replaces playerNames at Restart Application, even when the + // subapp shares all values. Frame changes/Restart Frame preserve it. + for (CRunApp owner = app; owner != null; owner = owner.parentApp) generations.put(owner, owner.playerNames); + } + protected interface Factory { T create(); } + protected static T acquire(CRun run, Activity activity, int id, Class type, Factory factory) { + synchronized (FusionStorage.class) { + if (run == null || run.rhApp == null || activity == null) throw new IllegalArgumentException("Fusion run and Activity are required"); + CExtStorage saved = run.getStorage(id); + if (saved != null && !type.isInstance(saved)) throw new IllegalStateException("Extension storage ID collision"); + T data = type.cast(saved); + if (data != null && !data.valid()) { data.close(); data = null; } + if (data == null) { + data = factory.create(); + try { run.addStorage(data, id); data.watch(); } + catch (RuntimeException | LinkageError error) { data.close(); throw error; } + } + return data; + } + } + protected final boolean valid() { + if (closed || activity.isFinishing() || activity.isDestroyed()) return false; + for (Map.Entry entry : generations.entrySet()) { + CRunApp owner = entry.getKey(); + if (owner.file == null || owner.playerNames != entry.getValue() || owner.appRunningState == CRunApp.SL_QUIT + || owner.appRunningState == CRunApp.SL_RESTART) return false; + } + return true; + } + protected final boolean ended() { return closed; } + protected final boolean suspended() { + boolean paused = background; + for (CRunApp owner : generations.keySet()) paused |= owner.run != null && owner.run.rh2PauseCompteur != 0; + return paused; + } + protected abstract void update(); + protected abstract void release(); + protected final void watch() { + handler = new Handler(Looper.getMainLooper()); + activity.getApplication().registerActivityLifecycleCallbacks(callbacks); + handler.postDelayed(tick, 50); + } + final void maintain() { + synchronized (FusionStorage.class) { if (!valid()) close(); else update(); } + } + protected final void close() { + if (closed) return; + closed = true; + if (handler != null) { + handler.removeCallbacks(tick); + activity.getApplication().unregisterActivityLifecycleCallbacks(callbacks); + handler = null; + } + if (app.run != null) { if (app.run.getStorage(id) == this) app.run.delStorage(id); } + else if (app.extensionStorage != null) app.extensionStorage.remove(this); + release(); + } + private final Runnable tick = () -> { + synchronized (FusionStorage.class) { + try { maintain(); } + catch (RuntimeException error) { android.util.Log.e("OFE.Media", "Application storage update failed", error); } + finally { if (handler != null) handler.postDelayed(this.tick, 50); } + } + }; + private final Application.ActivityLifecycleCallbacks callbacks = new Application.ActivityLifecycleCallbacks() { + public void onActivityPaused(Activity owner) { change(owner, true, false); } + public void onActivityResumed(Activity owner) { change(owner, false, false); } + public void onActivityDestroyed(Activity owner) { change(owner, true, true); } + public void onActivityCreated(Activity owner, Bundle state) {} + public void onActivityStarted(Activity owner) {} + public void onActivityStopped(Activity owner) {} + public void onActivitySaveInstanceState(Activity owner, Bundle state) {} + }; + private void change(Activity owner, boolean paused, boolean destroyed) { + synchronized (FusionStorage.class) { + if (owner != activity || closed) return; + if (destroyed) close(); else { background = paused; update(); } + } + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaCrypto.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaCrypto.java new file mode 100644 index 000000000..a4ce1ec68 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaCrypto.java @@ -0,0 +1,20 @@ +package _DeLib; + +import java.security.GeneralSecurityException; + +/** JNI lifetime only. Password mapping, cipher buffering and padding live in the shared C++ core. */ +public final class MediaCrypto implements AutoCloseable { + static { System.loadLibrary("ofe_crypto"); } + private long handle; + private static native byte[] nMaterial(String key); + private static native long nOpen(String key) throws GeneralSecurityException; + private static native byte[] nUpdate(long handle, byte[] input, int count) throws GeneralSecurityException; + private static native byte[] nFinish(long handle) throws GeneralSecurityException; + private static native void nClose(long handle); + public static byte[] material(String key) { return nMaterial(key); } + public MediaCrypto(String key) throws GeneralSecurityException { handle = nOpen(key); } + private long live() { if (handle == 0) throw new IllegalStateException("Media cipher closed"); return handle; } + public synchronized byte[] update(byte[] input, int count) throws GeneralSecurityException { return nUpdate(live(), input, count); } + public synchronized byte[] finish() throws GeneralSecurityException { return nFinish(live()); } + @Override public synchronized void close() { if (handle != 0) { nClose(handle); handle = 0; } } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaSource.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaSource.java new file mode 100644 index 000000000..e8fc2e6b9 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaSource.java @@ -0,0 +1,122 @@ +package _DeLib; + +import android.content.Context; +import android.net.Uri; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** Shared source materialization. Fusion supplies its native application file opener. */ +public final class MediaSource implements AutoCloseable { + public interface Opener { InputStream open(String name) throws IOException; } + private static final class Entry { File file; String name; int references; boolean cached; } + public final String path; + private Resolver owner; + private String identity; + private MediaSource(String path, Resolver owner, String identity) { this.path=path; this.owner=owner; this.identity=identity; } + private static String identity(String name, String key) throws Exception { + byte[] digest=MessageDigest.getInstance("SHA-256").digest((name+"\0"+key).getBytes(StandardCharsets.UTF_8)); + StringBuilder out=new StringBuilder(); for(byte b:digest)out.append(String.format("%02x",b&255)); return out.toString(); + } + /** Each Fusion application owns its resolver through CExtStorage. */ + public static final class Resolver implements AutoCloseable { + private final Context context; + private final Opener opener; + private final Map entries=new HashMap<>(); + private boolean closed; + public Resolver(Context context, Opener opener) { this.context=context.getApplicationContext(); this.opener=opener; } + private static String local(String name) { + if(name.startsWith("file://")&&!name.startsWith("file:///android_asset/")) { + Uri uri=Uri.parse(name); + if(uri.getHost()!=null&&!uri.getHost().isEmpty()&&!"localhost".equalsIgnoreCase(uri.getHost())) return name; + return uri.getPath(); + } + return name; + } + private InputStream input(String name) throws IOException { + String local=local(name); + if(new File(local).isFile()) return new FileInputStream(local); + if(name.startsWith("content://")) { + InputStream input=context.getContentResolver().openInputStream(Uri.parse(name)); + if(input==null)throw new FileNotFoundException(name);return input; + } + String asset=null; + if(name.startsWith("asset://"))asset=name.substring(8); + else if(name.startsWith("file:///android_asset/"))asset=Uri.parse(name).getPath().substring("/android_asset/".length()); + if(asset==null&&opener!=null) { + InputStream input=opener.open(name);if(input!=null)return input; + } + if(name.startsWith("https://")||name.startsWith("http://")) { + URLConnection connection=new java.net.URL(name).openConnection(); + connection.setConnectTimeout(15000);connection.setReadTimeout(15000); + try { + InputStream stream=connection.getInputStream(); + return new FilterInputStream(stream) { @Override public void close() throws IOException { + try {super.close();}finally{if(connection instanceof HttpURLConnection)((HttpURLConnection)connection).disconnect();} + }}; + }catch(IOException error){if(connection instanceof HttpURLConnection)((HttpURLConnection)connection).disconnect();throw error;} + } + if(asset==null)asset=name.replace('\\','/'); + try {return context.getAssets().open(asset);} + catch(IOException first) {try{return context.getAssets().open("mmf/"+asset);}catch(IOException missing){missing.addSuppressed(first);throw missing;}} + } + public synchronized MediaSource open(String name,String key,boolean cache) throws Exception {return open(name,key,cache,false);} + public synchronized MediaSource openFile(String name,String key) throws Exception {return open(name,key,false,true);} + private MediaSource open(String name,String key,boolean cache,boolean localOnly) throws Exception { + if(closed)throw new IllegalStateException("Media source application ended"); + if(name==null||name.isEmpty()||key==null||name.indexOf('\0')>=0)throw new IllegalArgumentException("Invalid media path or key"); + String local=local(name); + boolean protocol=name.contains("://")&&!name.startsWith("asset://")&&!name.startsWith("content://") + &&!name.startsWith("http://")&&!name.startsWith("https://")&&!name.startsWith("file://"); + if(key.isEmpty()&&!cache&&(new File(local).isFile()||(!localOnly&&protocol)))return new MediaSource(local,null,null); + String id=identity(name,key);Entry entry=entries.get(id); + if(entry==null) { + File target=File.createTempFile("ofe-media-",".media",context.getCacheDir()); + try(InputStream input=input(name);OutputStream output=new FileOutputStream(target)) { + byte[] buffer=new byte[65536];int count; + if(key.isEmpty()){while((count=input.read(buffer))!=-1)output.write(buffer,0,count);} + else { + try(MediaCrypto cipher=new MediaCrypto(key)) { + while((count=input.read(buffer))!=-1)output.write(cipher.update(buffer,count)); + output.write(cipher.finish()); + } + } + }catch(Exception error){target.delete();throw error;} + entry=new Entry();entry.file=target;entry.name=name;entries.put(id,entry); + } + ++entry.references;entry.cached|=cache; + return new MediaSource(entry.file.getAbsolutePath(),this,id); + } + public synchronized void cache(String name,String key) throws Exception {try(MediaSource source=open(name,key,true)) {}} + public synchronized void erase(String name) { + for(Iterator> it=entries.entrySet().iterator();it.hasNext();) { + Entry entry=it.next().getValue(); + if(name.isEmpty()||name.equals(entry.name)){entry.cached=false;if(entry.references==0){entry.file.delete();it.remove();}} + } + } + private synchronized void release(String identity) { + Entry entry=entries.get(identity); + if(entry!=null&&--entry.references==0&&!entry.cached){entry.file.delete();entries.remove(identity);} + } + @Override public synchronized void close(){if(!closed){closed=true;erase("");}} + } + // Compatibility API for standalone consumers. Fusion adapters use their own + // native application storage, not this process-wide convenience cache. + private static final Map defaults=new HashMap<>(); + private static synchronized Resolver defaults(Context context) { + Context application=context.getApplicationContext();Resolver resolver=defaults.get(application); + if(resolver==null){resolver=new Resolver(application,null);defaults.put(application,resolver);}return resolver; + } + public static MediaSource open(Context context,String name,String key,boolean cache)throws Exception{return defaults(context).open(name,key,cache);} + public static MediaSource openFile(Context context,String name,String key)throws Exception{return defaults(context).openFile(name,key);} + public static void cache(Context context,String name,String key)throws Exception{defaults(context).cache(name,key);} + public static void erase(Context context,String name)throws Exception{defaults(context).erase(name);} + // Kept for existing callers/tests; the original shared C++ KeyGen is authoritative. + static byte[] keyMaterial(String key) { return MediaCrypto.material(key); } + @Override public synchronized void close(){if(owner!=null){owner.release(identity);owner=null;identity=null;}} +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLAudioRuntime.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLAudioRuntime.java new file mode 100644 index 000000000..b894afef9 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLAudioRuntime.java @@ -0,0 +1,45 @@ +package _DeLib; + +import android.content.Context; +import android.media.AudioManager; + +/** Audio-only SDL initialization within Fusion's existing Activity. */ +public final class SDLAudioRuntime { + private static int references; + private static boolean initialized; + private static Context context; + private static AudioManager audio; + private static native void nFocus(boolean focused); + private static final AudioManager.OnAudioFocusChangeListener listener = change -> { + nFocus(change == AudioManager.AUDIOFOCUS_GAIN); + }; + private SDLAudioRuntime() {} + private static void binding(String type, String method, Class[] signature, Object... args) { + try { Class.forName("org.libsdl.app." + type).getMethod(method, signature).invoke(null, args); } + catch (ReflectiveOperationException error) { throw new IllegalStateException("SDL2 Java bindings are missing or incompatible", error); } + } + public static synchronized void retain(Context owner) { + if (references++ != 0) return; + try { + context = owner.getApplicationContext(); + if (!initialized) { + System.loadLibrary("SDL2"); System.loadLibrary("SDL2_mixer_ext"); System.loadLibrary("ofe_audio"); + binding("SDL", "setupJNI", new Class[0]); binding("SDL", "initialize", new Class[0]); initialized = true; + } + binding("SDL", "setContext", new Class[]{Context.class}, context); + audio = (AudioManager)context.getSystemService(Context.AUDIO_SERVICE); + requestFocus(); + } catch (RuntimeException | LinkageError error) { references = 0; throw error; } + } + public static synchronized void requestFocus() { + if (audio != null) nFocus(audio.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED); + } + public static synchronized void release() { + if (references == 0 || --references != 0) return; + if (audio != null) audio.abandonAudioFocus(listener); + audio = null; + if (context != null) binding("SDLAudioManager", "release", new Class[]{Context.class}, context); + binding("SDL", "setContext", new Class[]{Context.class}, new Object[]{null}); context = null; + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixer.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixer.java new file mode 100644 index 000000000..f0e26c105 --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixer.java @@ -0,0 +1,100 @@ +package _DeLib; + +import android.content.Context; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +/** JNI marshalling only. Channel policy and playback use native SDL Mixer X. */ +public final class SDLMixer implements AutoCloseable { + private static final Map sources = new HashMap<>(); + private static int references; + private final Context context; + private final boolean isolated; + private final Map media; + private long handle; + private MediaSource.Resolver resolver; + private static native long nCreate(); + private static native long nCreateIsolated(); + private static native void nDestroy(long handle); + private static native long nPlay(long handle, String name, String path, int channel, boolean exclusive, int loops, int fade); + private static native void nAction(long handle, int op, int channel, boolean exclusive, double a, double b, String text); + private static native boolean nCondition(long handle, int op, int channel, boolean exclusive, String name); + private static native double nNumber(long handle, int op, int channel, boolean exclusive, String name); + private static native String nName(long handle, int channel); + private static native void nCache(long handle, String name, String path, boolean replace); + private static native void nErase(long handle, String name); + private static native String nMemoryName(long handle, String access, String binary, long offset, long size); + private static native long[] nUpdate(long handle); + private static native void nSuspend(long handle, boolean suspend); + public SDLMixer(Context context) { this(context, false); } + static SDLMixer isolated(Context context) { return new SDLMixer(context, true); } + static SDLMixer isolated(Context context, MediaSource.Resolver resolver) { + SDLMixer mixer = new SDLMixer(context, true); mixer.resolver = resolver; return mixer; + } + private MediaSource openFile(String name, String key) throws Exception { + return resolver == null ? MediaSource.openFile(context, name, key) : resolver.openFile(name, key); + } + private SDLMixer(Context context, boolean isolated) { + this.context = context.getApplicationContext(); + this.isolated = isolated; + this.media = isolated ? new HashMap<>() : sources; + synchronized (SDLMixer.class) { + SDLAudioRuntime.retain(context); + try { System.loadLibrary("ofe_sdl_mixer"); handle = isolated ? nCreateIsolated() : nCreate(); if (!isolated) ++references; } + catch (RuntimeException | LinkageError error) { SDLAudioRuntime.release(); throw error; } + } + } + private long live() { if (handle == 0) throw new IllegalStateException("SDL Mixer is closed"); return handle; } + public void play(String name, String key, int channel, boolean exclusive, int loops, int fade) throws Exception { + synchronized (SDLMixer.class) { + live(); SDLAudioRuntime.requestFocus(); + MediaSource source = null; + try { + source = name.startsWith("ofe-mixer-memory://") ? null : openFile(name, key); + long voice = nPlay(handle, name, source == null ? "" : source.path, channel, exclusive, loops, fade); + if (source != null) media.put(voice, source); + } catch (Exception error) { + if (source != null) source.close(); + if (exclusive && channel >= 0 && channel <= 4095) nAction(handle, 3, channel, true, fade, 0, ""); + throw error; + } + } + } + public void update() { + synchronized (SDLMixer.class) { + long[] voices = nUpdate(live()); Set active = new HashSet<>(); for (long id : voices) active.add(id); + for (Iterator> it = media.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); if (!active.contains(entry.getKey())) { entry.getValue().close(); it.remove(); } + } + } + } + public void action(int op, int channel, boolean exclusive, double a, double b, String text) { + synchronized (SDLMixer.class) { nAction(live(), op, channel, exclusive, a, b, text); } + } + public boolean condition(int op, int channel, boolean exclusive, String name) { + synchronized (SDLMixer.class) { return nCondition(live(), op, channel, exclusive, name); } + } + public double number(int op, int channel, boolean exclusive, String name) { + synchronized (SDLMixer.class) { return nNumber(live(), op, channel, exclusive, name); } + } + public String channelName(int channel) { synchronized (SDLMixer.class) { return nName(live(), channel); } } + public void cache(String name, String key, boolean replace) throws Exception { + synchronized (SDLMixer.class) { live(); try (MediaSource source = openFile(name, key)) { nCache(handle, name, source.path, replace); } } + } + public void erase(String name) { synchronized (SDLMixer.class) { nErase(live(), name); } } + public String memoryName(String access, String binary, long offset, long size) { + synchronized (SDLMixer.class) { return nMemoryName(live(), access, binary, offset, size); } + } + public void suspend(boolean value) { synchronized (SDLMixer.class) { nSuspend(live(), value); } } + @Override public void close() { + synchronized (SDLMixer.class) { + if (handle == 0) return; + nDestroy(handle); handle = 0; + if (isolated || --references == 0) { for (MediaSource source : media.values()) source.close(); media.clear(); } + SDLAudioRuntime.release(); + } + } +} diff --git a/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixerStorage.java b/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixerStorage.java new file mode 100644 index 000000000..980c09c2a --- /dev/null +++ b/Extensions/Android_Gradle/app/src/main/java/_DeLib/SDLMixerStorage.java @@ -0,0 +1,58 @@ +package _DeLib; + +import android.app.Activity; +import Application.CRunApp; +import RunLoop.CRun; +import java.util.IdentityHashMap; +import java.util.Map; + +/** Playback lives in Fusion's native CExtStorage, independently of frame instances. */ +public final class SDLMixerStorage extends FusionStorage { + private static final int STORAGE_ID = 'S' | ('M' << 8) | ('I' << 16) | ('X' << 24); + private final SDLMixer mixer; + private final MediaSource.Resolver files; + private final Map bindings = new IdentityHashMap<>(); + public static final class Binding implements AutoCloseable { + private SDLMixerStorage storage; + private boolean paused; + private Binding(SDLMixerStorage storage) { this.storage = storage; storage.bindings.put(this, Boolean.TRUE); } + public SDLMixer mixer() { synchronized (FusionStorage.class) { return live().mixer; } } + private SDLMixerStorage live() { + if (storage == null || !storage.valid()) throw new IllegalStateException("SDL Mixer application storage ended"); + return storage; + } + public void suspend(boolean value) { + synchronized (FusionStorage.class) { SDLMixerStorage owner = live(); paused = value; owner.updatePause(); } + } + @Override public void close() { + synchronized (FusionStorage.class) { + if (storage == null) return; + SDLMixerStorage owner = storage; storage = null; owner.bindings.remove(this); + if (!owner.ended()) owner.updatePause(); + // DestroyRunObject only detaches; Fusion application storage owns playback. + } + } + } + + + public static Binding attach(CRun run, Activity activity) { + synchronized (FusionStorage.class) { + SDLMixerStorage storage = acquire(run, activity, STORAGE_ID, SDLMixerStorage.class, + () -> new SDLMixerStorage(run.rhApp, activity)); + return new Binding(storage); + } + } + private SDLMixerStorage(CRunApp app, Activity activity) { + super(app, activity, STORAGE_ID); + files = FusionFiles.resolver(app, activity); + try { mixer = SDLMixer.isolated(activity, files); } + catch (RuntimeException | LinkageError error) { files.close(); throw error; } + } + private void updatePause() { + boolean paused = suspended(); + for (Binding binding : bindings.keySet()) paused |= binding.paused; + mixer.suspend(paused); + } + @Override protected void update() { updatePause(); mixer.update(); } + @Override protected void release() { try { mixer.close(); } finally { files.close(); bindings.clear(); } } +} diff --git a/Extensions/Android_Gradle/tests/ffmpeg/AdapterTest.java b/Extensions/Android_Gradle/tests/ffmpeg/AdapterTest.java new file mode 100644 index 000000000..5f82463ab --- /dev/null +++ b/Extensions/Android_Gradle/tests/ffmpeg/AdapterTest.java @@ -0,0 +1,37 @@ +package Extensions; +import java.nio.*; +import Actions.CActExtension; +import Conditions.CCndExtension; +import Services.CBinaryFile; +public final class AdapterTest { + private static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); } + public static void main(String[] args) { + CRunFFMpeg extension = new CRunFFMpeg(); + extension.ho = new Objects.CExtension(); extension.rh = new RunLoop.CRun(); + ByteBuffer properties = ByteBuffer.allocate(28).order(ByteOrder.LITTLE_ENDIAN); + properties.putShort((short)320).putShort((short)240).put((byte)1).put((byte)1).put((byte)1).put((byte)1); + properties.putInt(8).putInt(64).put((byte)1).put((byte)0).putShort((short)0); + properties.putInt(7).put((byte)1).put((byte)1).put((byte)1).put((byte)0); + CBinaryFile file = new CBinaryFile(properties.array()); + check(extension.createRunObject(file, null, 1), "create"); + check(file.position() == 27, "Win32 EDATA alignment"); + check(extension.getNumberOfConditions() == 8, "condition count"); + check(extension.ho.hoImgWidth == 320 && extension.ho.hoImgHeight == 240, "property dimensions"); + check(extension.condition(2, null) && extension.condition(7, null), "loop/hardware properties"); + check(extension.expression(16).getInt() == 8, "thread property"); + extension.action(4, new CActExtension(150)); check(extension.expression(3).getInt() == 100, "volume clamp"); + extension.action(4, new CActExtension(-50)); check(extension.expression(3).getInt() == 0, "negative volume clamp"); + extension.action(5, new CActExtension(0)); check(!extension.condition(2, null), "loop action"); + extension.action(8, new CActExtension(99)); check(extension.expression(16).getInt() == 16, "thread clamp"); + extension.action(13, new CActExtension("none")); check(!extension.condition(7, null), "device selection"); + extension.action(19, new CActExtension(1)); check(extension.condition(7, null), "copy requests hardware"); + extension.action(14, new CActExtension(1920, 1080)); check(extension.ho.hoImgWidth == 1920, "stretch"); + extension.action(15, new CActExtension(1.5)); check(extension.expression(13).getDouble() == 1.5, "tempo double"); + extension.action(17, new CActExtension("h264", "aac")); + check(extension.expression(14).getString().equals("h264") && extension.expression(15).getString().equals("aac"), "codec overrides"); + extension.expression(4); extension.expression(5); check(extension.ho.parameters == 3, "pointer parameters consumed"); + extension.pauseRunObject(); extension.continueRunObject(); extension.destroyRunObject(false); extension.destroyRunObject(false); + check(!extension.condition(0, null) && !extension.condition(6, null), "closed conditions"); + System.out.println("PASS: Fusion ACE IDs, Win32 property layout, configuration and pointer-parameter consumption"); + } +} diff --git a/Extensions/Android_Gradle/tests/ffmpeg/AudioTest.java b/Extensions/Android_Gradle/tests/ffmpeg/AudioTest.java new file mode 100644 index 000000000..be1a58aff --- /dev/null +++ b/Extensions/Android_Gradle/tests/ffmpeg/AudioTest.java @@ -0,0 +1,44 @@ +package _DeLib; +import java.lang.reflect.*; +import java.util.*; + +/** Real FFmpeg decode -> shared Mixer X callbacks, with another music client alive. */ +public final class AudioTest { + private static final Map video=new HashMap<>(), music=new HashMap<>(); + private static Object call(Map methods,String name,Object... args)throws Exception { + try{return methods.get(name).invoke(null,args);}catch(InvocationTargetException error){throw new RuntimeException(name,error.getCause());} + } + private static void check(boolean ok,String name){if(!ok)throw new AssertionError(name);} + private static void methods(Class type,Map out){for(Method m:type.getDeclaredMethods())if(Modifier.isNative(m.getModifiers())){m.setAccessible(true);out.put(m.getName(),m);}} + private static double clock(long id)throws Exception{return (Double)call(video,"nClock",id);} + private static void drain(long id,long millis)throws Exception { + long deadline=System.nanoTime()+millis*1000000L;int state=0; + while(System.nanoTime()30,"PCM device clock advances"); + call(video,"nState",id,true,true,1f,1.);double frozen=clock(id); + double musicBefore=(Double)call(music,"nNumber",musicId,2,0,true,"");Thread.sleep(100); + check(Math.abs(clock(id)-frozen)<.01,"application suspend freezes PCM with another client active"); + check((Double)call(music,"nNumber",musicId,2,0,true,"")>musicBefore,"SDL music keeps rendering"); + call(video,"nSeek",id,400L,1,true);check(Math.abs(clock(id)-400)<.1,"seek discards queued audio and resets clock"); + call(video,"nState",id,true,false,.7f,1.);drain(id,4000); + check(clock(id)>=1000,"normal-speed drain advances audio"); + call(video,"nState",id,false,false,1f,1.); + call(video,"nSeek",id,0L,1,true);call(video,"nState",id,true,false,1f,2.); + drain(id,4000);check(clock(id)>=1000,"pitch-preserving tempo and DSP tail drain"); + }finally{call(video,"nClose",id);} + check((Boolean)call(music,"nCondition",musicId,0,0,true,""),"closing FFmpeg leaves SDL music alive"); + call(music,"nDestroy",musicId); + System.out.println("PASS: FFmpeg PCM through real Mixer X, shared music, device clock, app pause, seek, tempo, DSP drain and independent closure"); + } +} diff --git a/Extensions/Android_Gradle/tests/ffmpeg/DecodeTest.java b/Extensions/Android_Gradle/tests/ffmpeg/DecodeTest.java new file mode 100644 index 000000000..92fdd4b35 --- /dev/null +++ b/Extensions/Android_Gradle/tests/ffmpeg/DecodeTest.java @@ -0,0 +1,50 @@ +package _DeLib; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.util.*; +public final class DecodeTest { + private static void check(boolean valid, String message) { if (!valid) throw new AssertionError(message); } + public static void main(String[] args) throws Exception { + byte[] expected = Files.readAllBytes(Paths.get(args[1])); + try (FFMpegPlayer p = new FFMpegPlayer(args[0], null, 2, "", "", true)) { + check(p.info()[0] == 64 && p.info()[1] == 48, "dimensions"); + ByteBuffer pixels = ByteBuffer.allocateDirect(64*48*4); + int count = 0, retries = 0; + for (;;) { + int state = p.tick(true); + if (state == 2) break; + if (state == 0) { check(++retries < 100, "decode progress"); continue; } + p.copyPixels(pixels); byte[] actual = new byte[pixels.capacity()]; pixels.position(0); pixels.get(actual); + check(Arrays.equals(actual, Arrays.copyOfRange(expected, count*actual.length, (count+1)*actual.length)), "RGBA frame " + count); + ++count; + } + check(count == 12, "all frames including delayed frames: " + count); + p.seek(500, 1, true); check(p.info()[3] >= 500, "accurate seek"); + p.seek(0, 1, true); check(p.tick(true) == 1 && p.info()[3] == 0, "rewind after preview"); + p.play(true); p.suspend(true); long frozen = p.clock(); Thread.sleep(10); check(p.clock() == frozen, "pause clock"); + p.tempo(2); p.suspend(false); Thread.sleep(15); check(p.clock() > frozen + 15, "tempo clock"); + p.close(); p.close(); + try { p.info(); throw new AssertionError("closed handle"); } catch (IllegalStateException correct) {} + } + try { new FFMpegPlayer(args[0]+".missing", null, 0, "", "", true); throw new AssertionError("missing file"); } + catch (IllegalStateException correct) {} + try { new FFMpegPlayer(args[0]+"\0x", null, 0, "", "", true); throw new AssertionError("NUL path"); } + catch (IllegalStateException correct) {} + check(Arrays.equals(FFMpegSource.keyMaterial("1234567890abcdef"), "1234567890abcdef".getBytes("UTF-16LE")), "Windows AES material"); + check(Arrays.equals(FFMpegSource.keyMaterial("1234567890abcdefZ"), "khinolmbcj;89>?<".getBytes("UTF-16LE")), "Windows long-key derivation"); + try (FFMpegPlayer image = new FFMpegPlayer(args[2], null, 0, "", "", true)) { + check(image.tick(true) == 1, "still image frame"); + ByteBuffer straight = ByteBuffer.allocateDirect(16*16*4), premultiplied = ByteBuffer.allocateDirect(16*16*4); + image.copyPixels(straight); image.copyPixels(premultiplied, true); + int alpha = straight.get(3) & 255; + check(alpha > 0 && alpha < 255, "source transparency retained"); + check((premultiplied.get(0) & 255) == ((straight.get(0) & 255) * alpha + 127) / 255, "premultiplied Fusion output"); + check(premultiplied.get(3) == straight.get(3), "alpha unchanged"); + try { image.copyPixels(ByteBuffer.allocateDirect(1)); throw new AssertionError("buffer bounds"); } + catch (IllegalStateException correct) {} + check(image.tick(true) == 0 && image.info()[5] == 0, "still image held without finish"); + image.seek(0, 1, true); check(image.info()[6] != 0, "still image seek retains texture"); + } + System.out.println("PASS: JNI RGBA/reference parity, delayed EOF, seek, clock, lifetime and errors"); + } +} diff --git a/Extensions/Android_Gradle/tests/ffmpeg/compile_android.py b/Extensions/Android_Gradle/tests/ffmpeg/compile_android.py new file mode 100644 index 000000000..0db8fb278 --- /dev/null +++ b/Extensions/Android_Gradle/tests/ffmpeg/compile_android.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Compile owned Java against Android APIs and narrow Fusion API fixtures. +The fixtures match the supplied RuntimeAndroid.zip API; this is not an APK test. +""" +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--android-jar', required=True, type=Path) +args=parser.parse_args() +repo=Path(__file__).resolve().parents[4] +java=repo/'Extensions/Android_Gradle/app/src/main/java' +# Only Fusion is substituted. SurfaceTexture, EGL, GLES and AudioTrack compile against real Android APIs. +import sys +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from fusion_compile_fixture import FIXTURES as fixtures +with tempfile.TemporaryDirectory(prefix='ffmpeg-java-') as temp: + root=Path(temp); sources=[] + for name,body in fixtures.items(): + package,cls=name.split('/'); path=root/(name+'.java'); path.parent.mkdir(parents=True,exist_ok=True) + path.write_text('package '+package+';\n'+body); sources.append(path) + sources += [java/'Extensions/CRunFFMpeg.java']+list((java/'_DeLib').glob('FFMpeg*.java')) + sources += [java/'_DeLib/MediaSource.java', java/'_DeLib/MediaCrypto.java'] + sources += [java/'_DeLib'/name for name in ['SDLAudioRuntime.java','FusionStorage.java','FusionFiles.java','FFMpegStorage.java']] + sources.append(Path(__file__).with_name('AdapterTest.java')) + subprocess.run([str(Path(os.environ['JAVA_HOME'])/'bin/javac'),'--release','8','-Xlint:-options', + '-cp',str(args.android_jar),'-d',str(root/'classes'),*map(str,sources)],check=True) + subprocess.run([str(Path(os.environ['JAVA_HOME'])/'bin/java'), '-cp', str(root/'classes')+os.pathsep+str(args.android_jar), 'Extensions.AdapterTest'],check=True) +print('PASS: Android API Java compilation (Fusion boundary fixtures; no APK/device coverage)') diff --git a/Extensions/Android_Gradle/tests/ffmpeg/run.py b/Extensions/Android_Gradle/tests/ffmpeg/run.py new file mode 100644 index 000000000..3785e7c90 --- /dev/null +++ b/Extensions/Android_Gradle/tests/ffmpeg/run.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Exercise the actual player/JNI against FFmpeg output; Android/device tests are separate.""" +import argparse +import os +from pathlib import Path +import subprocess + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--ffmpeg-sdk', required=True, type=Path) +parser.add_argument('--audio-root', required=True, type=Path) +parser.add_argument('--android-jar', required=True, type=Path) +parser.add_argument('--cmake', default='cmake') +parser.add_argument('--build', type=Path, default=Path('/tmp/ofe-ffmpeg-tests')) +args = parser.parse_args() +repo = Path(__file__).resolve().parents[4] +cpp = repo/'Extensions/FFMpeg/Android/cpp' +java = repo/'Extensions/Android_Gradle/app/src/main/java' +tests = Path(__file__).resolve().parent +build = args.build.resolve(); build.mkdir(parents=True, exist_ok=True) +jdk = Path(os.environ['JAVA_HOME'])/'bin' +def run(command): + print('+', ' '.join(map(str, command)), flush=True) + subprocess.run(list(map(str, command)), check=True) +run([args.cmake, '-S', cpp, '-B', build/'native', '-DOFE_FFMPEG_ROOT='+str(args.ffmpeg_sdk.resolve()), '-DOFE_AUDIO_ROOT='+str(args.audio_root.resolve()), '-DCMAKE_BUILD_TYPE=Debug']) +run([args.cmake, '--build', build/'native', '-j2']) +media = build/'sample-视频-🎬.mp4'; raw = build/'reference.rgba' +run(['ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc2=size=64x48:rate=10:duration=1.2', + '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000:duration=1.2', '-c:v', 'mpeg4', '-bf', '2', '-c:a', 'aac', '-shortest', media]) +run(['ffmpeg', '-v', 'error', '-y', '-i', media, '-f', 'rawvideo', '-pix_fmt', 'rgba', raw]) +image=build/'still.png' +run(['ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', 'color=c=red@0.5:s=16x16,format=rgba', '-frames:v', '1', image]) +classes=build/'classes'; classes.mkdir(exist_ok=True) +run([jdk/'javac', '--release', '8', '-cp', args.android_jar, '-d', classes, + java/'_DeLib/FFMpegPlayer.java', java/'_DeLib/SDLAudioRuntime.java', java/'_DeLib/SDLMixer.java', java/'_DeLib/FFMpegSource.java', java/'_DeLib/MediaSource.java', java/'_DeLib/MediaCrypto.java', tests/'DecodeTest.java', tests/'AudioTest.java']) +run([jdk/'java', '-Xcheck:jni', '-Djava.library.path='+str(build/'native')+os.pathsep+str(args.audio_root/'lib'), '-cp',str(classes)+os.pathsep+str(args.android_jar), '_DeLib.DecodeTest', media, raw, image]) +wave=build/'music.wav' +run(['ffmpeg','-v','error','-y','-f','lavfi','-i','sine=frequency=440:sample_rate=44100:duration=3','-ac','2',wave]) +subprocess.run([str(jdk/'java'),'-Xcheck:jni','-Djava.library.path='+str(build/'native')+os.pathsep+str(args.audio_root/'lib'),'-cp',str(classes)+os.pathsep+str(args.android_jar),'_DeLib.AudioTest',str(media),str(wave)],check=True,env=dict(os.environ,SDL_AUDIODRIVER='dummy')) +shared=repo/'Extensions/_3rdLib' +utility=repo/'Extensions/_DeLib' +run(['c++', '-std=c++20', '-g', '-fsanitize=address,undefined', '-fno-omit-frame-pointer', + '-I'+str(shared), '-I'+str(utility), '-I'+str(args.ffmpeg_sdk/'include'), + '-I'+str(args.audio_root/'codecs-sdk/include/SDL2'), repo/'Extensions/FFMpeg/tests/SharedCoreTest.cpp', shared/'FFMpegAdapter.cpp', + '-L'+str(args.ffmpeg_sdk/'lib'), '-L'+str(args.audio_root/'lib'), + '-lavformat','-lavcodec','-lavfilter','-lavutil','-lswscale','-lswresample','-lSDL2-2.0', + '-Wl,-rpath,'+str(args.audio_root/'lib'), '-o',build/'shared-core-test']) +delayed=build/'delayed-audio.mp4' +run(['ffmpeg','-v','error','-y','-f','lavfi','-i','testsrc2=size=64x48:rate=10:duration=1.2','-itsoffset','0.4','-f','lavfi','-i','sine=frequency=1000:sample_rate=48000:duration=0.8','-c:v','mpeg4','-c:a','aac',delayed]) +subprocess.run([str(build/'shared-core-test'), str(media), str(delayed)], check=True, env=dict(os.environ, ASAN_OPTIONS='detect_leaks=0')) diff --git a/Extensions/Android_Gradle/tests/ffmpeg/sdk_version_test.py b/Extensions/Android_Gradle/tests/ffmpeg/sdk_version_test.py new file mode 100644 index 000000000..4807cb715 --- /dev/null +++ b/Extensions/Android_Gradle/tests/ffmpeg/sdk_version_test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Verify shared version selection and reject mismatched/missing Android SDK ABIs.""" +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import argparse + +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('--cmake', default='cmake') +args=parser.parse_args() +repo=Path(__file__).resolve().parents[4] +port=repo/'Extensions/FFMpeg/Android' +spec=importlib.util.spec_from_file_location('sdk_builder',port/'build_sdk.py') +builder=importlib.util.module_from_spec(spec);spec.loader.exec_module(builder) +with tempfile.TemporaryDirectory(prefix='ffmpeg-version-') as temporary: + root=Path(temporary) + canonical=builder.VERSION_FILE.read_text() + version_file=root/'FFmpegVersion.cmake' + version_file.write_text(canonical.replace('set(OFE_FFMPEG_VERSION "'+builder.VERSION+'")','set(OFE_FFMPEG_VERSION "10.1")')) + assert builder.version_settings(version_file)==('10.1',builder.SHA256) + manifest={'version':builder.VERSION,'source_sha256':builder.SHA256,'abis':['arm64-v8a']} + for component in builder.COMPONENTS: + lib=root/'arm64-v8a/lib'/('lib'+component+'.so');lib.parent.mkdir(parents=True,exist_ok=True);lib.touch() + header=root/'arm64-v8a/include'/('lib'+component)/'version.h';header.parent.mkdir(parents=True,exist_ok=True);header.touch() + (root/'arm64-v8a/include/libavcodec/avcodec.h').touch() + def write(): (root/'manifest.json').write_text(json.dumps(manifest)) + write() + assert builder.sdk_matches(root,['arm64-v8a']) + assert not builder.sdk_matches(root,['x86_64']) + script=root/'check.cmake' + script.write_text('cmake_minimum_required(VERSION 3.22)\nset(ANDROID_ABI arm64-v8a)\nset(OFE_FFMPEG_AUTO_DEPLOY OFF)\nset(OFE_FFMPEG_ROOT "'+root.as_posix()+'")\ninclude("'+(port/'cpp/EnsureSDK.cmake').as_posix()+'")\n') + def check_cmake(success): + result=subprocess.run([args.cmake,'-P',script],capture_output=True,text=True) + assert (result.returncode==0)==success,result.stdout+result.stderr + if not success: assert 'must match' in result.stderr,result.stderr + check_cmake(True) + manifest['version']='0.0';write() + assert not builder.sdk_matches(root,['arm64-v8a']);check_cmake(False) + manifest['version']=builder.VERSION;manifest['source_sha256']='0'*64;write() + assert not builder.sdk_matches(root,['arm64-v8a']);check_cmake(False) + manifest['source_sha256']=builder.SHA256;manifest['abis']=[];write() + assert not builder.sdk_matches(root,['arm64-v8a']);check_cmake(False) + manifest['abis']=['arm64-v8a'];write() + (root/'arm64-v8a/lib/libavcodec.so').unlink() + assert not builder.sdk_matches(root,['arm64-v8a']);check_cmake(False) +print('PASS: Windows/Android shared FFmpeg version, SDK version/source/ABI validation and missing-library rejection') diff --git a/Extensions/Android_Gradle/tests/fusion_compile_fixture.py b/Extensions/Android_Gradle/tests/fusion_compile_fixture.py new file mode 100644 index 000000000..fc65c6543 --- /dev/null +++ b/Extensions/Android_Gradle/tests/fusion_compile_fixture.py @@ -0,0 +1,44 @@ +"""Narrow Fusion API fixtures shared by Android adapter compile/dispatch tests. +These are not a substitute for a Fusion Runtime APK test. +""" +FIXTURES={ +'Extensions/CRunExtension': '''public class CRunExtension { + public static final int REFLAG_DISPLAY=1; public Objects.CExtension ho; public RunLoop.CRun rh; + public int getNumberOfConditions(){return 0;} public boolean createRunObject(Services.CBinaryFile f,RunLoop.CCreateObjectInfo c,int v){return false;} + public int handleRunObject(){return 0;} public void displayRunObject(){} public void reinitDisplay(){} + public void pauseRunObject(){} public void continueRunObject(){} public void destroyRunObject(boolean f){} + public boolean condition(int n,Conditions.CCndExtension c){return false;} public void action(int n,Actions.CActExtension a){} + public Expressions.CValue expression(int n){return null;}}''', +'Objects/CExtension': '''public class CExtension { public int hoX,hoY,hoImgWidth,hoImgHeight,hoImgXSpot,hoImgYSpot; + public int parameters; public Expressions.CValue[] values=new Expressions.CValue[0]; public void setParams(Expressions.CValue... args){values=args;parameters=0;} public Objects.CRCom roc; public Sprites.CRSpr ros; public Expressions.CValue getExpParam(){int n=parameters++;return n();e.id=id;rhApp.extensionStorage.add(e);}} + public void delStorage(int id){if(rhApp.extensionStorage!=null)for(int n=0;n extensionStorage; + public java.util.Map embedded=new java.util.HashMap<>(); + public HFile openHFile(String name){byte[] bytes=embedded.get(name);if(bytes==null)return null;HFile f=new HFile();f.stream=new java.io.ByteArrayInputStream(bytes);return f;} + public class HFile {public java.io.InputStream stream;public void close(){try{if(stream!=null)stream.close();}catch(java.io.IOException e){}stream=null;}} + }''', +'RunLoop/CCreateObjectInfo': 'public class CCreateObjectInfo {}', +'Services/CBinaryFile': '''public class CBinaryFile {private java.nio.ByteBuffer data; + public CBinaryFile(byte[] bytes){data=java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN);} + public int position(){return data.position();} public int readUnsignedShort(){return data.getShort() & 65535;} public byte readByte(){return data.get();} + public int readInt(){return data.getInt();} public void skipBytes(int n){data.position(data.position()+n);} }''', +'Conditions/CCndExtension': 'public class CCndExtension {private Object[] args; public CCndExtension(Object... args){this.args=args;} public String getParamExpString(RunLoop.CRun r,int n){return (String)args[n];} public int getParamExpression(RunLoop.CRun r,int n){return ((Number)args[n]).intValue();}}', +'Actions/CActExtension': '''public class CActExtension {private Object[] args; public CActExtension(Object... args){this.args=args;} + public String getParamExpString(RunLoop.CRun r,int n){return (String)args[n];} + public int getParamExpression(RunLoop.CRun r,int n){return ((Number)args[n]).intValue();} public double getParamExpDouble(RunLoop.CRun r,int n){return ((Number)args[n]).doubleValue();} }''', +'Expressions/CValue': '''public class CValue {private Object value; public CValue(int n){value=n;} + public int getInt(){return ((Number)value).intValue();} public double getDouble(){return ((Number)value).doubleValue();} public String getString(){return (String)value;} + public void forceInt(int n){value=n;} public void forceDouble(double n){value=n;} public void forceString(String n){value=n;} }''', +'Runtime/MMFRuntime': 'public class MMFRuntime extends android.app.Activity {public static MMFRuntime inst; public boolean texPremultiply;}', +'OpenGL/ITexture': '''public abstract class ITexture {public boolean antialiased; + public abstract int texture(); public abstract int getWidth(); public abstract int getHeight();}''', +'OpenGL/GLRenderer': '''public abstract class GLRenderer {public static GLRenderer inst; + public abstract void renderFlush(); public abstract void renderImage(ITexture t,boolean a,int x,int y,int w,int h,int shader,int effect,int param); + public abstract void renderScaledRotatedImage(ITexture t,boolean a,float angle,float sx,float sy,int hx,int hy,int x,int y,int w,int h,int shader,int effect,int param);}''', +} diff --git a/Extensions/Android_Gradle/tests/media_crypto/NativeTest.java b/Extensions/Android_Gradle/tests/media_crypto/NativeTest.java new file mode 100644 index 000000000..81696df99 --- /dev/null +++ b/Extensions/Android_Gradle/tests/media_crypto/NativeTest.java @@ -0,0 +1,46 @@ +package _DeLib; +import java.nio.file.*; +import java.util.*; +import java.io.ByteArrayOutputStream; +import java.security.GeneralSecurityException; +import javax.crypto.*; +import javax.crypto.spec.*; + +/** JCE is only an independent test oracle; production uses the shared C++ core. */ +public final class NativeTest { + private static void check(boolean value,String message){if(!value)throw new AssertionError(message);} + private static byte[] encrypt(byte[] source,String key)throws Exception { + byte[] material=MediaCrypto.material(key); + Cipher cipher=Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(Arrays.copyOfRange(material,0,16),"AES"),new IvParameterSpec(Arrays.copyOfRange(material,16,32))); + return cipher.doFinal(source); + } + private static byte[] decrypt(byte[] source,String key,int chunk)throws Exception { + try(MediaCrypto cipher=new MediaCrypto(key)) { + ByteArrayOutputStream output=new ByteArrayOutputStream(); + for(int i=0;i?<".getBytes("UTF-16LE")),"long-key folding"); + check(hex(MediaCrypto.material("abc")).equals("6100620063002d0026009b00f900c100e7005c0096001d003900e0005000ad00"),"frozen Win32 short-key seed on a 64-bit JVM"); + Random random=new Random(20261104);int cases=0; + for(String key:new String[]{"","a","abc","1234567890abcdef","1234567890abcdefZ","密码🔑","a\0b","\ud800","\udfff"}) { + for(int size:new int[]{0,1,15,16,17,31,32,4097,65537}){ + byte[] source=new byte[size];random.nextBytes(source);byte[] encrypted=encrypt(source,key); + check(Arrays.equals(source,decrypt(encrypted,key,17)),"streaming roundtrip");++cases; + } + } + if(args.length==2)check(Arrays.equals(Files.readAllBytes(Paths.get(args[1])),decrypt(Files.readAllBytes(Paths.get(args[0])),"12345678ABCDEFGH",4093)),"supplied old Windows ciphertext"); + try{decrypt(new byte[15],"abc",1);throw new AssertionError("truncated ciphertext accepted");}catch(BadPaddingException expected){} + MediaCrypto closed=new MediaCrypto("abc");closed.close();closed.close(); + try{closed.update(new byte[1],1);throw new AssertionError("closed context");}catch(IllegalStateException expected){} + System.out.println("PASS: shared-core JNI, "+cases+" JCE vectors, UTF-16/NUL/surrogates, Win32 seed, old asset, padding, finalization and lifetime"); + } + private static String hex(byte[] bytes){StringBuilder out=new StringBuilder();for(byte b:bytes)out.append(String.format("%02x",b&255));return out.toString();} +} diff --git a/Extensions/Android_Gradle/tests/media_crypto/run.py b/Extensions/Android_Gradle/tests/media_crypto/run.py new file mode 100644 index 000000000..3f8446246 --- /dev/null +++ b/Extensions/Android_Gradle/tests/media_crypto/run.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Test the actual JNI wrapper around the shared encryption core without Android/SDL fixtures.""" +import argparse,os,subprocess +from pathlib import Path +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('--cmake',default='cmake');parser.add_argument('--build',type=Path,default=Path('/tmp/ofe-media-crypto-tests')) +args=parser.parse_args();repo=Path(__file__).resolve().parents[4];build=args.build.resolve();build.mkdir(parents=True,exist_ok=True) +def run(command):subprocess.run(list(map(str,command)),check=True) +run([args.cmake,'-S',repo/'Extensions/SDL_Mixer/Android/cpp/crypto','-B',build,'-DCMAKE_BUILD_TYPE=Debug']) +run([args.cmake,'--build',build,'--parallel','2']) +classes=build/'classes';classes.mkdir(exist_ok=True);jdk=Path(os.environ['JAVA_HOME'])/'bin' +run([jdk/'javac','--release','8','-Xlint:-options','-d',classes,repo/'Extensions/Android_Gradle/app/src/main/java/_DeLib/MediaCrypto.java',Path(__file__).with_name('NativeTest.java')]) +fixtures=repo/'Extensions/NeoPic/ToInstall/Files/Examples/NeoPic' +run([jdk/'java','-Xcheck:jni','-Djava.library.path='+str(build/'lib'),'-cp',classes,'_DeLib.NativeTest',fixtures/'Test_Encrypt.dat',fixtures/'Test_Origin.jpg']) diff --git a/Extensions/Android_Gradle/tests/media_crypto/sdk_version_test.py b/Extensions/Android_Gradle/tests/media_crypto/sdk_version_test.py new file mode 100644 index 000000000..3fe95ff89 --- /dev/null +++ b/Extensions/Android_Gradle/tests/media_crypto/sdk_version_test.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Reject stale Android crypto SDKs using the same OpenSSL selection as Windows.""" +import argparse,importlib.util,json,subprocess,tempfile +from pathlib import Path +parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--cmake',default='cmake');args=parser.parse_args() +repo=Path(__file__).resolve().parents[4];port=repo/'Extensions/SDL_Mixer/Android' +spec=importlib.util.spec_from_file_location('crypto_sdk',port/'build_crypto_sdk.py');sdk=importlib.util.module_from_spec(spec);spec.loader.exec_module(sdk) +with tempfile.TemporaryDirectory(prefix='crypto-sdk-test-') as temporary: + root=Path(temporary);abi='arm64-v8a' + for name in ['include/openssl/evp.h','lib/libcrypto.a']: + p=root/abi/name;p.parent.mkdir(parents=True,exist_ok=True);p.touch() + metadata=dict(version=sdk.VERSION,vcpkg_commit=sdk.COMMIT,api=21,abis=[abi]) + script=root/'check.cmake';script.write_text('cmake_minimum_required(VERSION 3.22)\nset(ofe_extensions "'+(repo/'Extensions').as_posix()+'")\nset(ANDROID_ABI arm64-v8a)\nset(ANDROID_PLATFORM android-21)\nset(OFE_ENCRYPT_AUTO_DEPLOY OFF)\nset(OFE_ENCRYPT_ROOT "'+root.as_posix()+'")\ninclude("'+(port/'cpp/crypto/EnsureSDK.cmake').as_posix()+'")\n') + def check(ok): + (root/'manifest.json').write_text(json.dumps(metadata));assert sdk.matches(root,[abi])==ok + result=subprocess.run([args.cmake,'-P',script],capture_output=True,text=True) + assert (result.returncode==0)==ok,result.stdout+result.stderr + check(True) + for field,value in [('version','0.0'),('vcpkg_commit','0'*40),('api',24),('abis',[])]: + original=metadata[field];metadata[field]=value;check(False);metadata[field]=original + (root/abi/'lib/libcrypto.a').unlink();check(False) +print('PASS: shared Windows OpenSSL version/revision, Android API/ABI and missing-library checks') diff --git a/Extensions/Android_Gradle/tests/media_packages.py b/Extensions/Android_Gradle/tests/media_packages.py new file mode 100644 index 000000000..cd847668c --- /dev/null +++ b/Extensions/Android_Gradle/tests/media_packages.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Verify each extension is standalone and their shared export payloads agree.""" +import argparse +import hashlib +import json +from pathlib import Path +import zipfile +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('--ffmpeg',type=Path,required=True) +parser.add_argument('--sdl',type=Path,required=True) +args=parser.parse_args() +abis=['arm64-v8a','armeabi-v7a','x86','x86_64'] +with zipfile.ZipFile(args.ffmpeg) as video,zipfile.ZipFile(args.sdl) as music: + for zip,own,libs in [(video,'CRunFFMpeg.java',['ofe_ffmpeg','avcodec','avformat','avutil','swscale','swresample','avfilter']), + (music,'CRunSDL_Mixer.java',['ofe_sdl_mixer'])]: + assert zip.testzip() is None + manifest=next(n for n in zip.namelist() if n.endswith('package-manifest.json')) + for name,sha in json.loads(zip.read(manifest)).items():assert hashlib.sha256(zip.read(name)).hexdigest()==sha,name + assert 'app/src/main/java/Extensions/'+own in zip.namelist() + for abi in abis: + for lib in libs+['SDL2','SDL2_mixer_ext','ofe_audio','ofe_crypto','c++_shared']: + assert 'app/src/main/jniLibs/'+abi+'/lib'+lib+'.so' in zip.namelist(),(abi,lib) + for name in ['FusionStorage','FusionFiles','MediaSource','SDLAudioRuntime']: + assert 'app/src/main/java/_DeLib/'+name+'.java' in zip.namelist() + for forbidden in ['AndroidManifest.xml','app/build.gradle','app/src/main/java/Application/CRunApp.java','app/src/main/java/Extensions/CExtStorage.java','app/src/main/java/Runtime/MMFRuntime.java']: + assert forbidden not in zip.namelist(),forbidden + shared=set(video.namelist())&set(music.namelist()) + for name in shared:assert video.read(name)==music.read(name),'Different overlapping payload: '+name + print('PASS: two standalone four-ABI packages, source manifests/CRC,',len(shared),'identical shared files, no Fusion runtime/template replacement') diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/AdapterTest.java b/Extensions/Android_Gradle/tests/sdl_mixer/AdapterTest.java new file mode 100644 index 000000000..c57820dd9 --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/AdapterTest.java @@ -0,0 +1,25 @@ +package Extensions; +import _DeLib.SDLMixer; +import Runtime.MMFRuntime; +import Expressions.CValue; +import Actions.CActExtension; +import Conditions.CCndExtension; +public final class AdapterTest { + private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);} + private static CValue value(Object input){CValue v=new CValue(0);if(input instanceof String)v.forceString((String)input);else if(input instanceof Double)v.forceDouble((Double)input);else v.forceInt((Integer)input);return v;} + private static void params(CRunSDL_Mixer ext,Object... args){CValue[] values=new CValue[args.length];for(int i=0;i +#include +#include +#include +#include +using ofe::audio::Mixer; +void check(bool value, const char* name) { if (!value) throw std::runtime_error(name); } +std::atomic left{0}, right{0}, crossings{0}; +void SDLCALL capture(void*, Uint8* bytes, int length) { + auto pcm = reinterpret_cast(bytes); int previous = 0; + uint64_t l = 0, r = 0, c = 0; + for (int i = 0; i + 1 < length / 2; i += 2) { l += std::abs(static_cast(pcm[i])); r += std::abs(static_cast(pcm[i+1])); if (previous <= 0 && pcm[i] > 0) ++c; previous = pcm[i]; } + left.fetch_add(l); right.fetch_add(r); crossings.fetch_add(c); +} +int main(int argc, char** argv) { + check(argc >= 5, "audio codec fixtures required"); + auto first = OFEAudio_Acquire(), second = OFEAudio_Acquire(); + check(first && second && first != second, SDL_GetError()); + check(OFEAudio_ABIVersion() == 2, "audio ABI"); + OFEAudio_Release(first); check(OFEAudio_GetSpec(nullptr,nullptr,nullptr), "second client retains device"); + Mix_SetPostMix(capture, nullptr); + { + Mixer mixer; + for (int i = 1; i < argc; ++i) { + mixer.play("fixture", argv[i], 0, true, 1, -1); SDL_Delay(50); + check(mixer.condition(0,0,true), "native codec playback"); + check(mixer.number(3,0,true) > 1, "duration"); + } + mixer.action(7,0,true,-1); mixer.update(); check(mixer.condition(1,0,true), "stop all"); + mixer.action(2,0,true,80); mixer.action(13,0,true,50); + check(mixer.number(0,0,true) == 80 && mixer.number(9,0,true) == 50, "channel/master settings"); + mixer.play("old",argv[1],0,true,0,-1); mixer.play("new",argv[1],0,true,0,100); + check(mixer.liveVoices().size() == 2, "crossfade retains outgoing stream"); + SDL_Delay(180); mixer.update(); check(mixer.liveVoices().size() == 1, "crossfade cleanup"); + check(mixer.channelName(0) == "new", "exclusive replacement"); + mixer.action(4,0,true); mixer.update(); check(mixer.condition(4,0,true), "all paused"); + auto position = mixer.number(2,0,true); SDL_Delay(40); + check(mixer.number(2,0,true) == position, "user pause preserves playhead"); + OFEAudio_SetSuspended(second,1); OFEAudio_SetSuspended(second,0); + check(mixer.condition(3,0,true), "app resume preserves user pause"); + mixer.play("resume replacement",argv[1],0,true,0,100);mixer.update(); + check(mixer.liveVoices().size()==1, "paused outgoing stream reclaimed on replacement"); + mixer.action(5,0,true); mixer.action(6,0,true,.4); check(mixer.number(2,0,true) >= .35, "stream seek"); + mixer.action(8,0,true,.05,.1); SDL_Delay(140); mixer.update(); check(mixer.number(2,0,true) < .15, "AB loop"); + try { mixer.play("bad",argv[1],-1,true,1,-1); throw std::runtime_error("negative channel accepted"); } catch (const std::invalid_argument&) {} + mixer.action(7,0,true,-1); mixer.update(); + mixer.cache("bank",argv[1],false); + std::ifstream file(argv[1],std::ios::binary|std::ios::ate); auto size=static_cast(file.tellg()); + auto view=mixer.memoryName("memory", "bank",0,size); mixer.play(view,"",3,true,1,-1); + check(!mixer.unreferenced("bank") && mixer.channelName(3)=="memory", "handled binary reference"); + mixer.erase("bank"); check(!mixer.memoryName("still present","bank",0,size).empty(), "active binary retained"); + mixer.cache("bank",argv[1],true); check(!mixer.unreferenced("bank"), "update retains old readers"); + mixer.action(7,0,true,-1); mixer.update(); mixer.erase("bank"); + check(mixer.memoryName("gone","bank",0,size).empty(), "unreferenced binary release"); + mixer.play("partial",argv[1],4,false,0,-1); mixer.action(4,4,false); + mixer.play("partial",argv[1],4,false,0,-1); + check(!mixer.condition(5,4,false), "mixed paused and audible voices still have output"); + mixer.action(3,4,false,-1); mixer.update(); + mixer.action(13,0,true,100); + mixer.action(2,1,false,0); mixer.play("tone",argv[1],1,false,0,-1); + mixer.action(9,2,false,1,0,"{0,12}"); mixer.action(14,2,false,255,0); + check(mixer.number(10,2,false)==255 && mixer.number(11,2,false)==0, "panning ACE order"); + mixer.play("tone",argv[1],2,false,0,-1); + SDL_Delay(200); left=right=crossings=0; SDL_Delay(300); + check(left.load()>0 && right.load()190 && crossings.load()<400, "SoundTouch octave pitch through real mixer"); + check(!mixer.condition(4,0,true) && !mixer.condition(5,2,false), "aggregate pause and mixing output conditions"); + mixer.action(7,0,true,-1); mixer.update(); check(mixer.liveVoices().empty(), "all voices released"); + } + Mix_SetPostMix(nullptr,nullptr); OFEAudio_Release(second); + check(!OFEAudio_GetSpec(nullptr,nullptr,nullptr), "last client closes device"); + std::cout << "PASS: real Mixer X WAV/MP3/Ogg/FLAC/Opus/WavPack codecs, crossfade, seek/AB, pause, shared lifetime, cache, panning and SoundTouch\n"; +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/NativeTest.java b/Extensions/Android_Gradle/tests/sdl_mixer/NativeTest.java new file mode 100644 index 000000000..779e078bf --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/NativeTest.java @@ -0,0 +1,61 @@ +package _DeLib; +import java.lang.reflect.*; +import java.nio.file.*; +import java.util.*; +/** Uses the production native declarations; Android device setup is covered separately. */ +public final class NativeTest { + private static final Map methods=new HashMap<>(); + private static Object call(String name,Object... args) throws Exception { + try {return methods.get(name).invoke(null,args);} + catch(InvocationTargetException error){Throwable cause=error.getCause();if(cause instanceof Exception)throw (Exception)cause;throw error;} + } + private static void check(boolean valid,String message){if(!valid)throw new AssertionError(message);} + public static void main(String[] args)throws Exception{ + System.loadLibrary("ofe_sdl_mixer"); + for(Method method:SDLMixer.class.getDeclaredMethods())if(Modifier.isNative(method.getModifiers())){method.setAccessible(true);methods.put(method.getName(),method);} + long a=(Long)call("nCreate"),b=(Long)call("nCreate"); + call("nPlay",a,"声音🎵",args[0],0,true,1,-1); + check((Boolean)call("nCondition",b,0,0,true,""),"shared channel registry"); + check(call("nName",b,0).equals("声音🎵"),"UTF-16/UTF-8 roundtrip"); + call("nDestroy",a); + check((Boolean)call("nCondition",b,0,0,true,""),"one client close retains playback"); + call("nAction",b,2,0,true,73.,0.,"");check((Double)call("nNumber",b,0,0,true,"")==73.,"gain command"); + call("nAction",b,4,0,true,0.,0.,"");check((Boolean)call("nCondition",b,3,0,true,""),"pause command"); + call("nSuspend",b,true);call("nSuspend",b,false);check((Boolean)call("nCondition",b,3,0,true,""),"suspend preserves user pause"); + call("nCache",b,"bank",args[0],false); + String view=(String)call("nMemoryName",b,"memory 🎵","bank",0L,Files.size(Paths.get(args[0]))); + check(view.startsWith("ofe-mixer-memory://"),"opaque memory view"); + call("nPlay",b,view,"",1,false,1,-1);check(!(Boolean)call("nCondition",b,6,0,true,"bank"),"cache reader lifetime"); + try{call("nMemoryName",b,"bad","bank",-1L,1L);throw new AssertionError("binary bounds");}catch(IllegalStateException expected){} + try{call("nPlay",b,"bad",args[0],-1,true,1,-1);throw new AssertionError("negative channel");}catch(IllegalStateException expected){} + call("nAction",b,7,0,true,-1.,0.,"");call("nUpdate",b); + call("nErase",b,"");check(call("nMemoryName",b,"gone","bank",0L,1L).equals(""),"cache release"); + call("nDestroy",b);call("nDestroy",b); + try{call("nNumber",b,9,0,true,"");throw new AssertionError("closed handle");}catch(IllegalStateException expected){} + long c=(Long)call("nCreate");call("nCache",c,"bank",args[0],false); + String next=(String)call("nMemoryName",c,"new","bank",0L,Files.size(Paths.get(args[0]))); + check(!next.equals(view),"memory names cannot alias a previous mixer lifetime"); + try{call("nPlay",c,view,"",0,true,1,-1);throw new AssertionError("expired memory name");}catch(IllegalStateException expected){} + call("nDestroy",c); + long parent=(Long)call("nCreateIsolated"),child=(Long)call("nCreateIsolated"); + call("nPlay",parent,"parent",args[0],0,true,0,-1); + check(!(Boolean)call("nCondition",child,0,0,true,""),"subapp channel isolation"); + call("nCache",parent,"bank",args[0],false); + check(call("nMemoryName",child,"other","bank",0L,1L).equals(""),"subapp cache isolation"); + call("nAction",parent,13,0,true,37.,0.,""); + check((Double)call("nNumber",child,9,0,true,"")==100.,"subapp master gain isolation"); + call("nPlay",child,"child",args[0],0,true,0,-1); + call("nSuspend",parent,true); + double frozen=(Double)call("nNumber",parent,2,0,true,""); + Thread.sleep(120); + check(Math.abs((Double)call("nNumber",parent,2,0,true,"")-frozen)<.02,"one app pauses while another renders"); + check((Double)call("nNumber",child,2,0,true,"")>0.,"other app continues callbacks"); + call("nAction",parent,4,0,true,0.,0.,""); + call("nSuspend",parent,false); + check((Boolean)call("nCondition",parent,3,0,true,""),"isolated resume preserves user pause"); + call("nDestroy",child); + check((Boolean)call("nCondition",parent,0,0,true,""),"subapp cleanup preserves parent streams"); + call("nDestroy",parent); + System.out.println("PASS: actual JNI signatures, Unicode, shared clients, pause, cache bounds and stale handles"); + } +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/PCMTest.cpp b/Extensions/Android_Gradle/tests/sdl_mixer/PCMTest.cpp new file mode 100644 index 000000000..5635450d2 --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/PCMTest.cpp @@ -0,0 +1,48 @@ +#include "AudioRuntime.h" +#include +#include +#include +#include +#include +#include +#include +#include +struct Capture { + std::atomic written{0}; + std::vector samples = std::vector(44100*3); + static void SDLCALL callback(void* user, Uint8* data, int bytes) { + auto& self=*static_cast(user);auto source=reinterpret_cast(data); + size_t start=self.written.load(std::memory_order_relaxed); + size_t count=std::min(bytes/4,self.samples.size()-std::min(start,self.samples.size())); + for(size_t i=0;i wave(44100*2); + for(size_t i=0;i(8000*std::sin(i*440*6.283185307/44100)); + OFEAudio_PCMWrite(pcm,wave.data(),wave.size()*2,0);OFEAudio_PCMState(pcm,1,1,1); + SDL_Delay(160);check(OFEAudio_PCMClock(pcm)>80,"device callback clock"); + OFEAudio_PCMState(pcm,0,1,1);double stopped=OFEAudio_PCMClock(pcm);SDL_Delay(50); + check(OFEAudio_PCMClock(pcm)==stopped,"per-stream pause"); + OFEAudio_PCMReset(pcm,0);OFEAudio_PCMState(pcm,0,1,2); + OFEAudio_SetSuspended(client,1);capture.written=0;OFEAudio_SetSuspended(client,0); + OFEAudio_PCMWrite(pcm,wave.data(),wave.size()*2,0);OFEAudio_PCMFlush(pcm);OFEAudio_PCMState(pcm,1,1,2); + for(int i=0;i<200&&OFEAudio_PCMPending(pcm);++i){OFEAudio_PCMFlush(pcm);SDL_Delay(10);} + check(OFEAudio_PCMPending(pcm)==0,"tempo tail drain"); + OFEAudio_PCMState(pcm,0,1,2); + OFEAudio_SetSuspended(client,1);size_t count=capture.written.load(); + int crossings=0;size_t first=count,last=0; + for(size_t i=1;i200){first=std::min(first,i);last=i;}if(capture.samples[i-1]<=0&&capture.samples[i]>0)++crossings;} + check(last>first,"PCM produces non-silent output through Mixer X"); + double seconds=(last-first)/44100.;double frequency=crossings/seconds; + check(seconds>.35&&seconds<.7,"tempo changes duration"); + check(frequency>410&&frequency<470,"tempo preserves pitch"); + OFEAudio_PCMReset(pcm,1000);check(OFEAudio_PCMPending(pcm)==0&&OFEAudio_PCMClock(pcm)==1000,"seek discards old samples"); + Mix_SetPostMix(nullptr,nullptr);OFEAudio_DestroyPCM(pcm);OFEAudio_Release(client); + std::cout<<"PASS: actual mixed PCM, pause, reset, bounded queue, tempo duration/pitch and DSP tail ("<(app.extensionStorage)) ((SDLMixerStorage)e).maintain(); + } + public static void main(String[] args) { + Activity activity = new Activity(); + CRunApp app = new CRunApp(); + SDLMixerStorage.Binding first = SDLMixerStorage.attach(run(app), activity); + SDLMixer original = first.mixer(); + SDLMixerStorage.Binding second = SDLMixerStorage.attach(run(app), activity); + check(second.mixer() == original, "same application shares playback"); + check(app.extensionStorage.size()==1 && run(app).getStorage(app.extensionStorage.get(0).id)==app.extensionStorage.get(0), "Fusion owns the only global storage entry"); + original.play("tone", "", 0, true, 0, -1); + first.close(); second.close(); first.close(); + check(!original.wasClosed && original.playing, "last instance destruction retains playback"); + int updates = original.updates; + // A frame with no instance still maintains AB loops, voices and leases. + maintain(app); + check(original.updates > updates, "maintenance without instances"); + app.run = null; + run(app); + SDLMixerStorage.Binding next = SDLMixerStorage.attach(run(app), activity); + check(next.mixer() == original, "next frame and Restart Frame retain session"); + next.suspend(true); check(original.isSuspended, "binding pause"); + next.close(); check(!original.isSuspended, "destroy paused binding clears its vote"); + Application.ActivityLifecycleCallbacks callback = activity.getApplication().callbacks.get(0); + callback.onActivityPaused(activity); check(original.isSuspended, "background without object"); + callback.onActivityResumed(activity); check(!original.isSuspended, "foreground without object"); + app.run.rh2PauseCompteur = 1; maintain(app); + check(original.isSuspended, "Fusion pause without object"); + app.run.rh2PauseCompteur = 0; maintain(app); + check(!original.isSuspended, "Fusion resume"); + CRunApp child = new CRunApp(); child.parentApp = app; + SDLMixerStorage.Binding sub = SDLMixerStorage.attach(run(child), activity); + SDLMixer subMixer = sub.mixer(); check(subMixer != original, "subapp state isolated"); + sub.close(); child.appRunningState = CRunApp.SL_QUIT; maintain(child); + check(subMixer.wasClosed && !original.wasClosed, "subapp exit releases only subapp"); + // Restart can be fully consumed before the observer runs. initGlobal + // replaces playerNames even in subapps sharing global values. + app.playerNames = new String[4]; + SDLMixerStorage.Binding restarted = SDLMixerStorage.attach(run(app), activity); + SDLMixer fresh = restarted.mixer(); + check(original.wasClosed && fresh != original && !fresh.playing, "restart generation reset before reacquire"); + try { next.mixer(); throw new AssertionError("stale binding"); } catch (IllegalStateException expected) {} + CRunApp child2 = new CRunApp(); child2.parentApp = app; + SDLMixerStorage.Binding sub2 = SDLMixerStorage.attach(run(child2), activity); + SDLMixer childMixer = sub2.mixer(); sub2.close(); restarted.close(); + app.playerNames = new String[4]; maintain(app); maintain(child2); + check(fresh.wasClosed && childMixer.wasClosed, "parent restart releases descendant sessions"); + SDLMixerStorage.Binding last = SDLMixerStorage.attach(run(app), activity); + SDLMixer ending = last.mixer(); last.close(); + app.run = null; app.appRunningState = CRunApp.SL_QUIT; maintain(app); + check(ending.wasClosed && activity.getApplication().callbacks.isEmpty(), "EndApp without object cleans session and observer"); + app.appRunningState = 1; + SDLMixerStorage.Binding destroyed = SDLMixerStorage.attach(run(app), activity); + SDLMixer ending2 = destroyed.mixer(); + activity.getApplication().callbacks.get(0).onActivityDestroyed(activity); + check(ending2.wasClosed && activity.getApplication().callbacks.isEmpty(), "Activity destruction cleanup"); + destroyed.close(); + check(app.extensionStorage.isEmpty(), "native storage entry removed on cleanup"); + CRunApp direct=new CRunApp(); + SDLMixerStorage.Binding directBinding=SDLMixerStorage.attach(run(direct),activity); + SDLMixer directMixer=directBinding.mixer();directBinding.close();direct.file=null;maintain(direct); + check(directMixer.wasClosed && direct.extensionStorage.isEmpty(),"direct EndApp/subapp destruction without SL_QUIT"); + System.out.println("PASS: application sessions, empty frames, frame/app restart, subapps, background, pause, stale bindings and EndApp cleanup"); + } +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/compile_android.py b/Extensions/Android_Gradle/tests/sdl_mixer/compile_android.py new file mode 100644 index 000000000..2dac9d17d --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/compile_android.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Compile real Android bridge APIs, then test every Fusion ACE against runtime fixtures.""" +import argparse +import os +from pathlib import Path +import subprocess +import sys +import tempfile +sys.path.insert(0,str(Path(__file__).resolve().parents[1])) +from fusion_compile_fixture import FIXTURES +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('--android-jar',required=True,type=Path) +parser.add_argument('--crypto-library-dir',required=True,type=Path) +args=parser.parse_args() +repo=Path(__file__).resolve().parents[4];java=repo/'Extensions/Android_Gradle/app/src/main/java';tests=Path(__file__).resolve().parent +jdk=Path(os.environ['JAVA_HOME'])/'bin' +with tempfile.TemporaryDirectory(prefix='sdl-mixer-java-') as temp: + root=Path(temp);fixtures=[] + for name,body in FIXTURES.items(): + package,cls=name.split('/');path=root/(name+'.java');path.parent.mkdir(parents=True,exist_ok=True);path.write_text('package '+package+';\n'+body);fixtures.append(path) + actual=[java/'Extensions/CRunSDL_Mixer.java']+[java/'_DeLib'/name for name in ['SDLMixer.java','SDLMixerStorage.java','FusionStorage.java','FusionFiles.java','SDLAudioRuntime.java','MediaSource.java','MediaCrypto.java']] + subprocess.run([str(jdk/'javac'),'--release','8','-Xlint:-options','-cp',str(args.android_jar),'-d',str(root/'actual'),*map(str,fixtures+actual)],check=True) + android_fixtures = { + 'android/content/Context': 'public class Context {public Context getApplicationContext(){return this;} public android.content.ContentResolver getContentResolver(){return null;} public android.content.res.AssetManager getAssets(){return null;} public java.io.File getCacheDir(){return new java.io.File(System.getProperty("java.io.tmpdir"));}}', + 'android/app/Activity': 'public class Activity extends android.content.Context { private final Application app=new Application(); public Application getApplication(){return app;} public boolean isFinishing(){return false;} public boolean isDestroyed(){return false;} }', + 'android/app/Application': 'public class Application {public java.util.ArrayList callbacks=new java.util.ArrayList<>(); public void registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks c){callbacks.add(c);} public void unregisterActivityLifecycleCallbacks(ActivityLifecycleCallbacks c){callbacks.remove(c);} public interface ActivityLifecycleCallbacks {void onActivityCreated(Activity a,android.os.Bundle b);void onActivityStarted(Activity a);void onActivityResumed(Activity a);void onActivityPaused(Activity a);void onActivityStopped(Activity a);void onActivitySaveInstanceState(Activity a,android.os.Bundle b);void onActivityDestroyed(Activity a);}}', + 'android/os/Bundle': 'public class Bundle {}', + 'android/os/Looper': 'public class Looper {public static Looper getMainLooper(){return new Looper();}}', + 'android/os/Handler': 'public class Handler {public Handler(Looper l){} public boolean postDelayed(Runnable r,long t){return true;} public void removeCallbacks(Runnable r){}}', + 'android/util/Log': 'public class Log {public static int e(String tag,String message,Throwable error){throw new AssertionError(message,error);}}', + '_DeLib/SessionsTestSweep': 'public class SessionsTestSweep {public static void run(Application.CRunApp app){for(Extensions.CExtStorage e:new java.util.ArrayList<>(app.extensionStorage))((SDLMixerStorage)e).maintain();}}', + } + platform=[] + for name,body in android_fixtures.items(): + package,cls=name.rsplit('/',1);path=root/(name+'.java');path.parent.mkdir(parents=True,exist_ok=True);path.write_text('package '+package.replace('/','.')+';\n'+body);platform.append(path) + subprocess.run([str(jdk/'javac'),'--release','8','-Xlint:-options','-cp',str(args.android_jar),'-d',str(root/'test'),*map(str,fixtures+platform),str(java/'Extensions/CRunSDL_Mixer.java'),str(java/'_DeLib/SDLMixerStorage.java'),str(java/'_DeLib/FusionStorage.java'),str(java/'_DeLib/FusionFiles.java'),str(java/'_DeLib/MediaSource.java'),str(java/'_DeLib/MediaCrypto.java'),str(tests/'fixtures/SDLMixer.java'),str(tests/'SessionsTest.java'),str(tests/'MediaSourceTest.java'),str(tests/'AdapterTest.java')],check=True) + subprocess.run([str(jdk/'java'),'-Djava.library.path='+str(args.crypto_library_dir),'-cp',str(root/'test')+os.pathsep+str(args.android_jar),'Extensions.AdapterTest'],check=True) + subprocess.run([str(jdk/'java'),'-Djava.library.path='+str(args.crypto_library_dir),'-cp',str(root/'test')+os.pathsep+str(args.android_jar),'_DeLib.SessionsTest'],check=True) + subprocess.run([str(jdk/'java'),'-Djava.library.path='+str(args.crypto_library_dir),'-cp',str(root/'test')+os.pathsep+str(args.android_jar),'_DeLib.MediaSourceTest'],check=True) +print('PASS: Java 8 Android compilation; Fusion fixtures do not establish full Runtime APK behavior') diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/device/FilesProbe.java b/Extensions/Android_Gradle/tests/sdl_mixer/device/FilesProbe.java new file mode 100644 index 000000000..39094a1cf --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/device/FilesProbe.java @@ -0,0 +1,46 @@ +package _DeLib; +import android.app.Activity; +import Application.CRunApp; +import java.io.*; +import java.util.Arrays; +import java.net.ServerSocket; +import java.net.Socket; + +/** Actual Android assets, ContentResolver, local files and TLS-capable Java URL IO. */ +public final class FilesProbe { + private static void check(boolean ok,String name){if(!ok)throw new AssertionError(name);} + static byte[] asset(Activity activity,String name)throws Exception { + try(InputStream input=activity.getAssets().open(name);ByteArrayOutputStream output=new ByteArrayOutputStream()) { + byte[] buffer=new byte[8192];int count;while((count=input.read(buffer))!=-1)output.write(buffer,0,count);return output.toByteArray(); + } + } + private static void verify(MediaSource.Resolver resolver,String name,byte[] expected)throws Exception { + try(MediaSource source=resolver.openFile(name,"")) { + try(InputStream input=new FileInputStream(source.path);ByteArrayOutputStream output=new ByteArrayOutputStream()) { + byte[] buffer=new byte[8192];int count;while((count=input.read(buffer))!=-1)output.write(buffer,0,count); + check(Arrays.equals(output.toByteArray(),expected),"file read: "+name); + } + } + } + public static void run(Activity activity)throws Exception { + CRunApp app=new CRunApp();byte[] bytes=asset(activity,"tone.wav"); + String embedded="C:\\project\\内置声音🎵.wav";app.embedded.put(embedded,bytes); + File local=new File(activity.getFilesDir(),"声音 🎵.wav");try(OutputStream output=new FileOutputStream(local)){output.write(bytes);} + try(MediaSource.Resolver resolver=FusionFiles.resolver(app,activity)) { + for(String name:new String[]{local.getAbsolutePath(),android.net.Uri.fromFile(local).toString(),"asset://tone.wav","file:///android_asset/tone.wav","tone.wav","extra.wav",embedded, + "content://ofe.sdlprobe.files/"+android.net.Uri.encode(local.getName())})verify(resolver,name,bytes); + try(MediaSource decrypted=resolver.openFile("asset://tone.enc","1234567890abcdef")) { + check(new File(decrypted.path).length()==bytes.length,"AES file read"); + } + try(ServerSocket server=new ServerSocket(0)) { + Thread response=new Thread(()->{try(Socket socket=server.accept()) { + socket.setSoTimeout(5000);BufferedReader input=new BufferedReader(new InputStreamReader(socket.getInputStream())); + while(true){String line=input.readLine();if(line==null||line.isEmpty())break;} + OutputStream output=socket.getOutputStream();output.write(("HTTP/1.1 200 OK\r\nContent-Length: "+bytes.length+"\r\nConnection: close\r\n\r\n").getBytes("US-ASCII"));output.write(bytes);output.flush(); + }catch(Exception error){throw new RuntimeException(error);}},"ofe-file-http"); + response.start();verify(resolver,"http://127.0.0.1:"+server.getLocalPort()+"/tone.wav",bytes);response.join(5000); + check(!response.isAlive(),"HTTP file read completed"); + } + } + } +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeActivity.java b/Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeActivity.java new file mode 100644 index 000000000..67134397c --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeActivity.java @@ -0,0 +1,71 @@ +package ofe.sdlprobe; + +import android.app.Activity; +import android.os.Bundle; +import android.widget.TextView; +import _DeLib.SDLMixer; +import Runtime.MMFRuntime; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.io.FileOutputStream; + +/** Runs the production Android bridge in an ordinary Activity, without SDLActivity. */ +public final class ProbeActivity extends Activity { + private TextView status; + @Override public void onCreate(Bundle saved) { + super.onCreate(saved); new File(getFilesDir(),"result.txt").delete(); MMFRuntime.inst=new MMFRuntime(); status=new TextView(this);status.setText("Testing SDL Mixer JNI...");setContentView(status); + // Fusion initializes extensions on its run/GL thread, so test without a prepared Looper. + new Thread(this::probe,"ofe-sdl-probe").start(); + } + private static void check(boolean valid,String name){if(!valid)throw new AssertionError(name);} + private void probe(){ + String outcome; + try { + if(getIntent().hasExtra("driver")){ + System.loadLibrary("SDL2"); + org.libsdl.app.SDLActivity.nativeSetenv("SDL_AUDIODRIVER",getIntent().getStringExtra("driver")); + } + try(SDLMixer first=new SDLMixer(this);SDLMixer second=new SDLMixer(this)){ + for(String name:new String[]{"tone.wav","tone.mp3","tone.ogg","tone.flac","tone.opus","tone.wv"}){ + first.play("asset://"+name,"",0,true,1,-1); + for(int retry=0;retry<50&&second.number(2,0,true,"")<=0;++retry)Thread.sleep(100); + check(second.condition(0,0,true,""),"codec playback: "+name); + check(second.number(2,0,true,"")>0,"audio callback advances: "+name); + } + first.play("asset://tone.wav","",0,true,0,80);Thread.sleep(150);first.update(); + first.close();check(second.condition(0,0,true,""),"shared device after one object closes"); + second.suspend(true);double stopped=second.number(2,0,true,"");Thread.sleep(100); + check(Math.abs(second.number(2,0,true,"")-stopped)<.02,"application pause freezes media position");second.suspend(false); + second.action(6,0,true,.4,0,"");check(second.number(2,0,true,"")>=.35,"fractional WAV seek"); + second.action(4,0,true,0,0,"");second.update();check(second.condition(3,0,true,""),"paused stream survives update"); + second.suspend(true);Thread.sleep(80);second.suspend(false); + check(second.condition(3,0,true,""),"app resume preserves user pause"); + second.action(5,0,true,0,0,"");second.action(8,0,true,.05,.1,"");Thread.sleep(200);second.update(); + check(second.number(2,0,true,"")<.15,"AB loop"); + second.play("asset://tone.enc","1234567890abcdef",2,true,1,-1);Thread.sleep(100); + check(second.condition(0,2,true,""),"AES materialization"); + second.cache("asset://tone.wav","",false); + long size; + try(android.content.res.AssetFileDescriptor file=getAssets().openFd("tone.wav")){size=file.getLength();} + String memory=second.memoryName("内存🎵","asset://tone.wav",0,size); + second.play(memory,"",3,false,1,-1);check(second.number(4,0,false,"内存🎵")==3,"Unicode memory name");check(!second.condition(6,0,true,"asset://tone.wav"),"memory reader"); + second.erase("asset://tone.wav");check(second.condition(0,3,false,""),"erase retains active reader"); + second.action(7,0,true,-1,0,"");second.update();second.erase(""); + check(second.condition(1,0,true,""),"all stopped"); + outcome="PASS: Android non-SDL Activity, worker-thread JNI bootstrap, WAV/MP3/OGG/FLAC/Opus/WavPack audio callbacks, shared clients, crossfade, seek/AB, pause, AES and handled memory"; + } + try(SDLMixer reopened=new SDLMixer(this)){ + reopened.play("asset://tone.wav","",0,true,1,-1); + for(int retry=0;retry<50&&reopened.number(2,0,true,"")<=0;++retry)Thread.sleep(100); + check(reopened.number(2,0,true,"")>0,"device reopens after last client closes"); + } + _DeLib.FilesProbe.run(this); + String rendering=_DeLib.VideoProbe.run(this); + _DeLib.StorageProbe.run(this); + outcome += "; native application storage, empty frames, restart, subapps and EndApp; files/content/HTTP/embedded/AES; shared FFmpeg audio and GLES "+rendering; + }catch(Throwable error){outcome="FAIL: "+android.util.Log.getStackTraceString(error);} + try(FileOutputStream output=new FileOutputStream(new File(getFilesDir(),"result.txt"))){output.write(outcome.getBytes(StandardCharsets.UTF_8));} + catch(Exception error){android.util.Log.e("OFESDLProbe","Cannot write result",error);} + final String text=outcome;android.util.Log.i("OFESDLProbe",text);runOnUiThread(()->status.setText(text)); + } +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeProvider.java b/Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeProvider.java new file mode 100644 index 000000000..a73feeb64 --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/device/ProbeProvider.java @@ -0,0 +1,22 @@ +package ofe.sdlprobe; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import java.io.File; +import java.io.FileNotFoundException; +/** Private test provider, exercising real ContentResolver reads without path conversion. */ +public final class ProbeProvider extends ContentProvider { + public boolean onCreate(){return true;} + @Override public ParcelFileDescriptor openFile(Uri uri,String mode)throws FileNotFoundException { + String name=uri.getLastPathSegment(); + if(!"声音 🎵.wav".equals(name)&&!"video.mp4".equals(name))throw new FileNotFoundException(name); + return ParcelFileDescriptor.open(new File(getContext().getFilesDir(),name),ParcelFileDescriptor.MODE_READ_ONLY); + } + public String getType(Uri uri){return "application/octet-stream";} + public Cursor query(Uri uri,String[] projection,String selection,String[] args,String sort){return null;} + public Uri insert(Uri uri,ContentValues values){throw new UnsupportedOperationException();} + public int delete(Uri uri,String selection,String[] args){throw new UnsupportedOperationException();} + public int update(Uri uri,ContentValues values,String selection,String[] args){throw new UnsupportedOperationException();} +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/device/StorageProbe.java b/Extensions/Android_Gradle/tests/sdl_mixer/device/StorageProbe.java new file mode 100644 index 000000000..6820da084 --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/device/StorageProbe.java @@ -0,0 +1,59 @@ +package _DeLib; + +import Application.CRunApp; +import RunLoop.CRun; +import android.app.Activity; + +/** Real JNI/device output with narrow Fusion storage/lifecycle fixtures. */ +public final class StorageProbe { + private static void check(boolean ok, String name) { if (!ok) throw new AssertionError(name); } + private static CRun frame(CRunApp app) { CRun run = new CRun(); run.rhApp = app; app.run = run; return run; } + public static void run(Activity activity) throws Exception { + CRunApp app = new CRunApp(); + SDLMixerStorage.Binding first = SDLMixerStorage.attach(frame(app), activity); + SDLMixer audio = first.mixer(); + audio.play("asset://tone.enc", "1234567890abcdef", 0, true, 0, -1); + audio.action(8, 0, true, .05, .15, ""); + audio.cache("asset://tone.wav", "", false); + String view; + try (android.content.res.AssetFileDescriptor file = activity.getAssets().openFd("tone.wav")) { + view = audio.memoryName("retained", "asset://tone.wav", 0, file.getLength()); + } + audio.action(13, 0, true, 63, 0, ""); + first.close(); app.run = null; + // No extension exists. The production Handler still maintains AB loops. + Thread.sleep(450); + SDLMixerStorage.Binding next = SDLMixerStorage.attach(frame(app), activity); + check(next.mixer() == audio, "frame recreation reuses native storage"); + check(audio.condition(0, 0, true, ""), "encrypted source lease survives last frame instance"); + check(audio.number(2, 0, true, "") < .25, "AB maintenance without objects"); + check(audio.number(9, 0, true, "") == 63, "channel settings survive frame change"); + audio.play(view, "", 1, false, 0, -1); + check(audio.number(4, 0, false, "retained") == 1, "memory cache survives frame change"); + next.close(); + // Complete a restart between observer ticks: no transient SL_RESTART needed. + app.playerNames = new String[4]; + SDLMixerStorage.Binding restarted = SDLMixerStorage.attach(frame(app), activity); + SDLMixer fresh = restarted.mixer(); + check(fresh != audio && fresh.condition(1, 0, true, ""), "Restart Application resets playback"); + check(fresh.number(9, 0, true, "") == 100, "Restart Application resets settings"); + check(fresh.memoryName("gone", "asset://tone.wav", 0, 1).isEmpty(), "restart clears cache"); + CRunApp child = new CRunApp(); child.parentApp = app; + SDLMixerStorage.Binding sub = SDLMixerStorage.attach(frame(child), activity); + SDLMixer childAudio = sub.mixer(); + fresh.play("asset://tone.wav", "", 0, true, 0, -1); + check(!childAudio.condition(0, 0, true, ""), "subapp channel isolation"); + childAudio.play("asset://tone.wav", "", 0, true, 0, -1); + restarted.suspend(true); + double stopped = fresh.number(2, 0, true, ""); Thread.sleep(100); + check(Math.abs(fresh.number(2, 0, true, "") - stopped) < .02, "app suspension with another client running"); + check(childAudio.number(2, 0, true, "") > 0, "subapp callbacks continue"); + restarted.suspend(false); sub.close(); child.appRunningState = CRunApp.SL_QUIT; + ((SDLMixerStorage)child.extensionStorage.get(0)).maintain(); + check(fresh.condition(0, 0, true, ""), "subapp exit retains parent playback"); + restarted.close(); app.appRunningState = CRunApp.SL_QUIT; + Thread.sleep(200); + check(app.extensionStorage.isEmpty(), "EndApp cleanup without frame objects"); + check(child.extensionStorage.isEmpty(), "subapp native storage entry removed"); + } +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/device/VideoProbe.java b/Extensions/Android_Gradle/tests/sdl_mixer/device/VideoProbe.java new file mode 100644 index 000000000..1aac4ae55 --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/device/VideoProbe.java @@ -0,0 +1,79 @@ +package _DeLib; +import android.app.Activity; +import android.opengl.*; +import java.io.*; +import java.nio.ByteBuffer; +import OpenGL.GLRenderer; +import OpenGL.ITexture; +import Runtime.MMFRuntime; + +/** Production decoder/copy adaptors on a real GLES pbuffer; Fusion draw API is a fixture. */ +public final class VideoProbe { + private static void check(boolean ok,String name){if(!ok)throw new AssertionError(name);} + private static int integer(int name){int[] out=new int[1];GLES20.glGetIntegerv(name,out,0);return out[0];} + private static byte[] pixels(FFMpegTexture texture){ + int old=integer(GLES20.GL_FRAMEBUFFER_BINDING);int[] frame=new int[1];GLES20.glGenFramebuffers(1,frame,0); + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER,frame[0]); + GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER,GLES20.GL_COLOR_ATTACHMENT0,GLES20.GL_TEXTURE_2D,texture.texture(),0); + ByteBuffer bytes=ByteBuffer.allocateDirect(texture.getWidth()*texture.getHeight()*4); + GLES20.glReadPixels(0,0,texture.getWidth(),texture.getHeight(),GLES20.GL_RGBA,GLES20.GL_UNSIGNED_BYTE,bytes); + byte[] out=new byte[bytes.capacity()];bytes.get(out);GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER,old);GLES20.glDeleteFramebuffers(1,frame,0);return out; + } + public static String run(Activity activity)throws Exception { + GLRenderer.inst=new GLRenderer(){public void renderFlush(){} public void renderImage(ITexture t,boolean a,int x,int y,int w,int h,int s,int e,int p){} public void renderScaledRotatedImage(ITexture t,boolean a,float r,float sx,float sy,int hx,int hy,int x,int y,int w,int h,int s,int e,int p){}}; + MMFRuntime.inst.texPremultiply=false; + EGLDisplay display=EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);int[] version=new int[2];check(EGL14.eglInitialize(display,version,0,version,1),"EGL initialize"); + EGLConfig[] configs=new EGLConfig[1];int[] count=new int[1]; + EGL14.eglChooseConfig(display,new int[]{EGL14.EGL_RENDERABLE_TYPE,4,EGL14.EGL_SURFACE_TYPE,EGL14.EGL_PBUFFER_BIT,EGL14.EGL_RED_SIZE,8,EGL14.EGL_GREEN_SIZE,8,EGL14.EGL_BLUE_SIZE,8,EGL14.EGL_ALPHA_SIZE,8,EGL14.EGL_NONE},0,configs,0,1,count,0);check(count[0]>0,"GLES config"); + EGLContext context=EGL14.eglCreateContext(display,configs[0],EGL14.EGL_NO_CONTEXT,new int[]{EGL14.EGL_CONTEXT_CLIENT_VERSION,2,EGL14.EGL_NONE},0); + EGLSurface surface=EGL14.eglCreatePbufferSurface(display,configs[0],new int[]{EGL14.EGL_WIDTH,64,EGL14.EGL_HEIGHT,48,EGL14.EGL_NONE},0); + check(EGL14.eglMakeCurrent(display,surface,surface,context),"GLES context"); + byte[] video=FilesProbe.asset(activity,"video.mp4"),reference=FilesProbe.asset(activity,"video.rgba"); + File file=new File(activity.getFilesDir(),"video.mp4");try(OutputStream output=new FileOutputStream(file)){output.write(video);} + String hardware="fallback"; + try { + try(FFMpegTexture texture=new FFMpegTexture();FFMpegPlayer player=new FFMpegPlayer(activity,file.getAbsolutePath(),null,2,"","",false);SDLMixer music=new SDLMixer(activity)) { + music.play("asset://tone.wav","",0,true,0,-1);check(player.tick(true)==1,"FFmpeg software frame"); + texture.copySoftware(player,64,48); + byte[] actual=pixels(texture);ByteBuffer decoded=ByteBuffer.allocateDirect(actual.length);player.copyPixels(decoded);byte[] nativePixels=new byte[actual.length];decoded.get(nativePixels); + check(java.util.Arrays.equals(actual,nativePixels),"software texture matches native RGBA upload"); + long difference=0;for(int i=0;i + + + + + +''') +owned=[java/'_DeLib'/name for name in ['SDLMixer.java','SDLMixerStorage.java','FusionStorage.java','FusionFiles.java','FFMpegStorage.java','FFMpegPlayer.java','FFMpegTexture.java','SDLAudioRuntime.java','MediaSource.java','MediaCrypto.java']] +vendor=list((args.sdl_source/'android-project/app/src/main/java/org/libsdl/app').glob('*.java')) +sys.path.insert(0,str(Path(__file__).resolve().parents[2])) +from fusion_compile_fixture import FIXTURES +fusion=[] +for name in ['Application/CRunApp','RunLoop/CRun','Extensions/CExtStorage','OpenGL/ITexture','OpenGL/GLRenderer','Runtime/MMFRuntime']: + path=root/'fusion-fixtures'/(name+'.java');path.parent.mkdir(parents=True,exist_ok=True) + package,cls=name.split('/');path.write_text('package '+package+';\n'+FIXTURES[name]);fusion.append(path) +run([jdk/'javac','--release','8','-Xlint:-options','-cp',args.android_jar,'-d',classes,*owned,*vendor,*fusion,*[Path(__file__).with_name(name) for name in ['StorageProbe.java','FilesProbe.java','VideoProbe.java','ProbeProvider.java','ProbeActivity.java']]]) +jar=root/'classes.jar' +run([jdk/'jar','--create','--file',jar,'-C',classes,'.']) +dex=root/'dex';dex.mkdir(exist_ok=True) +run([args.build_tools/'d8','--min-api','21','--lib',args.android_jar,'--output',dex,jar]) +base=root/'probe-base.apk' +run([args.build_tools/'aapt','package','-f','-M',manifest,'-I',args.android_jar,'-F',base]) +fixtures=root/'assets';fixtures.mkdir(exist_ok=True) +wave=fixtures/'tone.wav' +run(['ffmpeg','-v','error','-y','-f','lavfi','-i','sine=frequency=440:sample_rate=44100:duration=2','-ac','2',wave]) +for extension in ['mp3','ogg','flac','opus','wv']:run(['ffmpeg','-v','error','-y','-i',wave,fixtures/('tone.'+extension)]) +(fixtures/'mmf').mkdir(exist_ok=True);shutil.copy2(wave,fixtures/'mmf/extra.wav') +video=fixtures/'video.mp4' +run(['ffmpeg','-v','error','-y','-f','lavfi','-i','testsrc2=size=64x48:rate=10:duration=1.2','-f','lavfi','-i','sine=frequency=1000:sample_rate=48000:duration=1.2','-c:v','libx264','-pix_fmt','yuv420p','-profile:v','baseline','-c:a','aac','-shortest',video]) +run(['ffmpeg','-v','error','-y','-i',video,'-frames:v','1','-f','rawvideo','-pix_fmt','rgba',fixtures/'video.rgba']) +material='1234567890abcdef'.encode('utf-16le') +run(['openssl','enc','-aes-128-cbc','-K',material[:16].hex(),'-iv',material[16:].hex(),'-in',wave,'-out',fixtures/'tone.enc']) +host=next((args.ndk/'toolchains/llvm/prebuilt').iterdir()) +with zipfile.ZipFile(base,'a',zipfile.ZIP_DEFLATED) as apk: + apk.write(dex/'classes.dex','classes.dex') + for name in ['SDL2','SDL2_mixer_ext','ofe_audio','ofe_sdl_mixer','ofe_crypto']:apk.write(args.native_build/'lib'/('lib'+name+'.so'),'lib/x86_64/lib'+name+'.so') + apk.write(host/'sysroot/usr/lib/x86_64-linux-android/libc++_shared.so','lib/x86_64/libc++_shared.so') + apk.write(args.ffmpeg_build/'libofe_ffmpeg.so','lib/x86_64/libofe_ffmpeg.so') + for component in ['avcodec','avformat','avutil','swscale','swresample','avfilter']:apk.write(args.ffmpeg_sdk/'x86_64/lib'/('lib'+component+'.so'),'lib/x86_64/lib'+component+'.so') + for source in fixtures.rglob('*'): + if source.is_file():apk.write(source,'assets/'+source.relative_to(fixtures).as_posix(),compress_type=zipfile.ZIP_STORED) +aligned=root/'probe-aligned.apk';run([args.build_tools/'zipalign','-f','4',base,aligned]) +key=root/'probe.keystore' +if not key.exists():run([jdk/'keytool','-genkeypair','-keystore',key,'-storepass','android','-keypass','android','-alias','probe','-keyalg','RSA','-validity','30','-dname','CN=OFE Android Test']) +output=root/'SDL-Mixer-Probe.apk' +run([args.build_tools/'apksigner','sign','--ks',key,'--ks-pass','pass:android','--key-pass','pass:android','--out',output,aligned]) +print('Probe APK:',output) diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/fixtures/SDLMixer.java b/Extensions/Android_Gradle/tests/sdl_mixer/fixtures/SDLMixer.java new file mode 100644 index 000000000..22fa7f68a --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/fixtures/SDLMixer.java @@ -0,0 +1,21 @@ +package _DeLib; +import android.content.Context; +public final class SDLMixer implements AutoCloseable { + public static int op,channel,loops,fade; public static double a,b; public static boolean exclusive,suspended,closed; + public static String name,key,text; + public boolean wasClosed,isSuspended,playing; public int updates; + static SDLMixer isolated(Context owner,MediaSource.Resolver resolver){return new SDLMixer(owner);} + static SDLMixer isolated(Context owner){return new SDLMixer(owner);} + public SDLMixer(Context owner){closed=false;} + public void play(String n,String k,int c,boolean x,int l,int f){playing=true;op=x?0:1;name=n;key=k;channel=c;exclusive=x;loops=l;fade=f;} + public void action(int o,int c,boolean x,double v,double w,String t){op=o;channel=c;exclusive=x;a=v;b=w;text=t;} + public boolean condition(int o,int c,boolean x,String n){op=o;channel=c;exclusive=x;name=n;return true;} + public double number(int o,int c,boolean x,String n){op=o;channel=c;exclusive=x;name=n;return o+.5;} + public String channelName(int c){op=5;channel=c;return "channel";} + public void cache(String n,String k,boolean replace){op=replace?12:10;name=n;key=k;} + public void erase(String n){op=11;name=n;} + public String memoryName(String access,String binary,long offset,long size){op=7;name=access;key=binary;a=offset;b=size;return "memory";} + public void update(){++updates;} + public void suspend(boolean s){suspended=s;isSuspended=s;} + public void close(){closed=true;wasClosed=true;playing=false;} +} diff --git a/Extensions/Android_Gradle/tests/sdl_mixer/run.py b/Extensions/Android_Gradle/tests/sdl_mixer/run.py new file mode 100644 index 000000000..2cdc140cf --- /dev/null +++ b/Extensions/Android_Gradle/tests/sdl_mixer/run.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Run real Mixer X playback with SDL's dummy output and exercise production JNI.""" +import argparse +import os +from pathlib import Path +import subprocess + +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('--build',required=True,type=Path) +parser.add_argument('--sdl-source',required=True,type=Path) +parser.add_argument('--android-jar',required=True,type=Path) +args=parser.parse_args() +repo=Path(__file__).resolve().parents[4] +java=repo/'Extensions/Android_Gradle/app/src/main/java' +tests=Path(__file__).resolve().parent +build=args.build.resolve();fixtures=build/'fixtures';fixtures.mkdir(exist_ok=True) +jdk=Path(os.environ['JAVA_HOME'])/'bin' +def run(command,env=None): + print('+',' '.join(map(str,command)),flush=True) + subprocess.run(list(map(str,command)),check=True,env=env) +wave=fixtures/'音频🎵.wav' +run(['ffmpeg','-v','error','-y','-f','lavfi','-i','sine=frequency=440:sample_rate=44100:duration=2','-ac','2',wave]) +files=[wave] +for extension in ['mp3','ogg','flac','opus','wv']: + target=fixtures/('tone.'+extension);run(['ffmpeg','-v','error','-y','-i',wave,target]);files.append(target) +env=dict(os.environ,SDL_AUDIODRIVER='dummy') +run([build/'ofe_pcm_test']) +run([build/'ofe_mixer_test',*files],env) +classes=build/'java-classes';classes.mkdir(exist_ok=True) +owned=[java/'_DeLib'/name for name in ['SDLMixer.java','SDLAudioRuntime.java','MediaSource.java','MediaCrypto.java']] +vendor=list((args.sdl_source/'android-project/app/src/main/java/org/libsdl/app').glob('*.java')) +run([jdk/'javac','--release','8','-Xlint:-options','-cp',args.android_jar,'-d',classes,*owned,*vendor,tests/'NativeTest.java']) +run([jdk/'java','-Xcheck:jni','-Djava.library.path='+str(build/'lib'),'-cp',str(classes)+os.pathsep+str(args.android_jar),'_DeLib.NativeTest',wave],env) diff --git a/Extensions/Encrypter/ReadMe.md b/Extensions/Encrypter/ReadMe.md index 59dfa58f6..a645556a5 100644 --- a/Extensions/Encrypter/ReadMe.md +++ b/Extensions/Encrypter/ReadMe.md @@ -1,5 +1,32 @@ # ReadMe -En/decrypt file with WinAPI CNG +En/decrypt files through the shared C++ OpenSSL core for more information, please check the [Help File](https://github.com/defisym/OpenFusionExamples/blob/master/Extensions/Encrypter/ToInstall/Files/Help/Encrypter/Encrypter.md) + +## Android status — shared core reviewed, extension runtime deferred + +Reviewed 2026-11-04 against master `36812170` (portable encryption, PR #91). +Windows and mobile consumers can now use the same C++ OpenSSL EVP implementation, +`Encryption/KeyGen.h`, C API and seekable `DecryptReader`. + +FFMpeg and SDL_Mixer use a thin `MediaCrypto` JNI binding to that original core. +Java owns Fusion file access, private-cache materialization and source leases; +it no longer implements password expansion, AES buffering or padding. JNI supplies +UTF-16 units and explicitly selects the 32-bit seed used by existing Fusion Win32 +assets, independently of Android's process pointer size. + +The shared core and JNI tests restore the supplied old encrypted JPEG byte for +byte. They cover empty/short/long/Unicode keys, NUL/surrogate units, chunk/padding +boundaries and failures. Android API 21 crypto JNI cross-builds use all four ABIs +and the OpenSSL version selected by Windows (`EncryptVersion.cmake`). The API +21–23 32-bit file-I/O compatibility branch preserves the other platforms' paths. +A full licensed Fusion APK and new on-device encryption validation remain outstanding. + +The standalone Encrypter Fusion extension (its ACE bindings and export package) +remains deferred; sharing its core with media plugins is not that extension port. +Do not invent a separate mobile crypto implementation to work around a core +platform dependency. Identify the dependency and refactor the shared core or its +platform adaptor first. + +See the [Android media guide](../Android_Gradle/Media-JNI.md). diff --git a/Extensions/FFMpeg/Android/.gitignore b/Extensions/FFMpeg/Android/.gitignore new file mode 100644 index 000000000..6a3417b8d --- /dev/null +++ b/Extensions/FFMpeg/Android/.gitignore @@ -0,0 +1 @@ +/out/ diff --git a/Extensions/FFMpeg/Android/README.md b/Extensions/FFMpeg/Android/README.md new file mode 100644 index 000000000..4268e2c98 --- /dev/null +++ b/Extensions/FFMpeg/Android/README.md @@ -0,0 +1,204 @@ +# FFmpeg Android JNI port + +Fusion Java ACE dispatch → thin JNI binding → the original C++ `FFMpeg` class. +Android compiles `Extensions/_3rdLib/FFMpeg.h`, `FFMpeg.cpp` and the existing +`FFMpegAdapter` factory. There is no separate Android demux/decode/seek engine. +Windows keeps its existing public C++ entry points. Platform-independent fixes to +decoder/filter/resampler drain, fractional-second seek, buffer stride/size and +partial audio copies apply to both platforms. The Windows seek/preview policy now +lives in the shared class and is called by both Fusion bindings. + +`FFMpegAdapterAndroid` adds MediaCodec decoder selection, initialization and Surface +buffer release beside `FFMpegAdapterD3D11`. `CopyAdapterAndroid` implements native +RGBA copies and GLES external-image copies through the `CopyAdapter` factory. +Windows headers and D3D implementations remain behind `_WIN32`; Android APIs stay +behind `__ANDROID__`. Actual Fusion runtime access remains in Java (`ITexture`, +`GLRenderer`, application storage/files and events). See +[the combined build/file guide](../../Android_Gradle/Media-JNI.md). + +## Rendering and audio + +- Hardware: FFmpeg's native NDK MediaCodec decoder outputs to a Java `Surface` backed by + `SurfaceTexture`. Its external OES image is copied on the Fusion GL thread into + an RGBA 2D render target, using the producer's transform matrix. Fusion draws + that target through its normal renderer, including runtime ink effects/shaders. +- Software: swscale converts the decoded frame with its actual source strides to + tightly packed RGBA. JNI copies it into a reusable direct buffer; the Android + copy adapter uploads it to a regular 2D texture. Premultiplied output follows + the application's `texPremultiply` setting. Source alpha is retained. +- Hardware initialization failure falls back to the software decoder. Requested + hardware state and actual hardware state are reported separately. A decode + error after initialization stops the instance and logs the FFmpeg error. +- The external-image copy flushes Fusion's pending draws first and restores the + GL program, framebuffer, viewport, texture unit/bindings, buffer/attribute state, + color mask, blend, depth, scissor, cull and stencil enables. It never draws an + overlay View or takes over Fusion's output surface. +- The original C++ `audio_fillData` decodes/resamples to stereo S16/44.1 kHz. + Mixer X invokes it through an independent looping-chunk/effect channel in + **the same device as SDL_Mixer**, matching Windows SMI. Shared transport handles + only conversion to the actual device rate. FFmpeg's existing libavfilter + `atempo` chain preserves pitch; it no longer uses a separate Android tempo path. +- The C++ core owns audio and monotonic fallback clocks, pause, seek invalidation, + decoder/filter/resampler drain and completion timing. The audio clock describes + samples submitted through the callback, rather than measured speaker latency. + +Android API 21+ and a current GLES 2+ Fusion renderer are required. Tempo works +through the shared libavfilter chain on API 21+. Open/seek/video decode and input materialization remain +synchronous on the Fusion run thread; slow network/probe work can block it. + +## Fusion compatibility + +IDs retain Windows order: 8 conditions, 20 actions and 17 expressions. Object +properties use the Win32 version-1 payload layout (including its alignment). +Hardware device name is `mediacodec`; setting copy-to-texture requests hardware +for the next open. All displayed Android video uses a Fusion texture, including +software uploads. The Windows shared-D3D-device property has no Android equivalent. +The backend uses the per-instance Surface/MediaCodec lifetime instead. + +Actions cover open/open-at-position, close/play/pause, volume/loop, seek/seek flags, +thread count, accurate seek, cache/erase, device selection, stretch, tempo, +force-no-audio, codec override and reset display. Decode-thread count is capped at +16; configuration changes apply on the next open. Seek flags pass the low FFmpeg +flag bits and retain the high `NoGoto` / `NoRevert` bits. Accurate seeks discard +preroll without publishing discarded hardware frames. Opening at/previewing zero +rewinds the decoder so beginning audio is not consumed by the preview. + +The two Windows frame-pointer expressions (4 and 5) consume their parameters and +return **0**: Android Fusion has no `cSurface*` ABI. They must not return a GL texture +name or truncated native address. A compatible Android frame-sharing protocol +would need corresponding changes to the consuming extension. + +Inputs support readable paths, file URIs, APK assets, content URIs, HTTP(S) and +FFmpeg streaming URLs. Fusion embedded binary names and OBB/platform paths use +`CRunApp.openHFile()`. The application-owned file resolver lives in Fusion native +`CExtStorage` (FFMG), resets at Restart Application and releases on exit; instances +only release their own readers. AES/CBC and UTF-16/Win32 key derivation match +Windows. Sources needing a native seekable filename are materialized in private +cache; cache erase preserves active readers. Android scoped-storage/URI permissions +still apply: see the combined guide for paths and grants. HTTPS uses Android TLS; +no separate FFmpeg TLS library is bundled. +Finish/open-failure events execute on Fusion's calling thread, with state committed +before callbacks so actions may close or reopen the object reentrantly. +A GL context recreation closes the old codec/surface and reopens at the saved +position. `close()` is idempotent and JNI handles are registry IDs, not pointers. + +## Build and publish + +Like FTW, the publisher creates an extension ZIP with Java adapters, prebuilt JNI +libraries for all four Android ABIs, native sources and R8 rules. It does not replace +Fusion's Gradle templates, application manifest, runtime classes or installed +`RuntimeAndroid.zip`. `CExtLoad` registration is only for the repository's development +project; the exporter discovers `CRunFFMpeg` from the extension package normally. + +The source build needs Python **3.12+**, NDK r28c, CMake 3.22+, `bash` and GNU `make`. +The FFmpeg source builder runs on Linux/WSL or macOS with the matching host NDK. +Windows users build the SDK under Linux/WSL first, then supply its output with +`OFE_FFMPEG_ROOT` or `--ffmpeg-sdk`. Source building with Windows batch compiler +wrappers is not supported. The ZIP publisher and JNI CMake build work with ordinary +Windows Python, a Windows NDK and the SDK/NDK discovery used by FTW. + +```sh +# Pinned FFmpeg 9.0 LGPL shared SDK, MediaCodec/JNI enabled, four ABIs. +python3 Extensions/FFMpeg/Android/build_sdk.py \ + --ndk /path/to/android-ndk-r28c --output /path/to/ffmpeg-android-sdk + +python3 Extensions/_Script/FFMpeg.py --ffmpeg-sdk /path/to/ffmpeg-android-sdk +``` + +`ANDROID_HOME` / `ANDROID_SDK_ROOT` select SDK discovery. `--ndk` and `--cmake` +together bypass that discovery. `OFE_FFMPEG_ROOT` provides the publisher's default +SDK path. Both the publisher and Android CMake validate the SDK version/source hash and +requested ABIs against the Windows selection in +`Extensions/_3rdLib/FFmpeg-build/FFmpegVersion.cmake`. Android has no separate +version constant: that shared file selects the release and corresponding source +SHA-256; the Windows prebuilt archive retains its own archive hash. Missing or +stale SDKs trigger a source build on supported hosts. The default Android SDK +output is `Extensions/_3rdLib/FFmpeg-sdk-Android`, separate from Windows +`FFmpeg-sdk`. Set `OFE_FFMPEG_AUTO_DEPLOY=OFF` to reject missing/stale SDKs without +building during CMake configuration. A source reproduction package includes a +snapshot of the same version file next to `build_sdk.py`. Use an SDK built by `build_sdk.py`; +its manifest, matching source archive, configuration and license files are required. +FFmpeg source downloads are SHA-256 verified and the SDK is built from source. +Shared string conversion uses the existing `DeLib::Helpers` CMake interface and +FTW's pinned iconv dependency. The source package includes those same helper +headers, iconv archives, corresponding source and notices; it has no separate +FFmpeg conversion utility implementation. +The SDK enables decoding/demuxing and NDK zlib (including PNG/APNG); encoders, +muxers, external codec libraries and devices are excluded. The existing C++ tempo +path enables only abuffer, abuffersink, atempo, aformat and aresample filters. + +The output is `FFMpeg/ToInstall/Files/Data/Runtime/Android/FFMpeg.zip`. +Copy it into Fusion's `Data/Runtime/Android` extension directory for exporting. +`Extensions/_Script/FFMpeg.bat` is the Windows publishing entry point. +`Run_Only Unicode` invokes it after the Windows build; set +`/p:FFMpegPackageAndroid=false` to skip it. The development Gradle project can build +the JNI target with `-POFE_FFMPEG_ROOT=/path/to/ffmpeg-android-sdk`. +`--abi` selects a development subset; omit it for release packages. Packaging checks +ELF machine IDs, dynamic dependencies and every 64-bit load segment's 16 KB +alignment (NDK 32-bit libraries retain their supported 4 KB alignment), +and includes all six FFmpeg libraries (including avfilter), SDL2, Mixer X, the shared audio library +and NDK libc++ per ABI. The matching SDL Java bindings accompany the JNI bridge. +Both publishers use the same default audio build caches; custom FFmpeg audio +caches use `--audio-sources` and `--audio-build-root`. Rebuild/install both ZIPs +together after changing shared dependencies. +The source archive, LGPL notices and build configurations accompany the package. +Failed builds leave the prior ZIP intact. + +## Validation + +The shared C++ regressions also run independently of JNI or Android; see +[`tests/README.md`](../tests/README.md) for their command and the problem-based +core review boundaries. They include PCM fragmentation and frame/seek callbacks +that reenter playback controls. + +```sh +python3 Extensions/Android_Gradle/tests/ffmpeg/run.py \ + --ffmpeg-sdk /path/to/host-ffmpeg-sdk --audio-root /path/to/audio-builds/host --android-jar /path/to/android.jar +python3 Extensions/Android_Gradle/tests/ffmpeg/compile_android.py \ + --android-jar /path/to/android.jar +python3 Extensions/Android_Gradle/tests/object_helpers/run.py +python3 Extensions/Android_Gradle/tests/ffmpeg/sdk_version_test.py +``` + +Host tests exercise the actual JNI decoder and compare every RGBA frame against +FFmpeg CLI output, including B-frame EOF, static/alpha images, Unicode filenames, buffer bounds, accurate +seek/rewind, errors, clock and +handle lifetime. Direct C++ checks compile the original shared class and cover video/audio/filter/resampler +drain, fractional seek, memory AVIO, invalid-open cleanup and buffer bounds under +ASan/UBSan (LeakSanitizer disabled for ptrace environments). The Java compile check +uses real Android APIs and narrow Fusion boundary fixtures; it is not an APK build. +Native four-ABI compilation/ELF checks do not establish behavior on physical devices. Device checks should cover hardware and software color/orientation, +alpha, ink effects, audio sync/tempo, loop/seek and app pause/context recreation. + +Verified in the workspace: FFmpeg 9.0 SDK and JNI Release builds for +arm64-v8a, armeabi-v7a, x86 and x86_64; ZIP ABI/dependency/alignment checks; Java 8 +Android API/Fusion fixture compilation and ACE regression; host JNI frame/reference +and ASan/UBSan audio checks. Real Mixer X/JNI coexistence checks cover the +audio device clock, pause/seek, pitch-preserving tempo, DSP drain and independent +closure while SDL music remains active. The host uses FFmpeg 7.1.5 development libraries. +The current Windows selection/creation/base helpers were unchanged from their +reviewed revision; Android's 56 helper regressions passed without helper changes. +Shared Windows/Android version selection and stale version/source/ABI rejection +were verified. Android CMake automatic deployment also rebuilt/restored a missing +x86_64 ABI using the shared 9.0 selection and an existing source cache. +Full Fusion APK, Windows MSBuild and physical-device playback were not run here. + +The independent API-23 x86_64 APK probe passed actual JNI/Mixer X playback alongside +SDL music, all six tested audio codecs, file/file-URI/assets/content-URI/HTTP and +AES inputs, native Fusion-storage fixtures, software GLES upload/reference checks, +and MediaCodec Surface/SurfaceTexture/OES color/orientation, GL-state and +playback/drain checks while using the shared C++ callback clock. Delayed-audio fixtures +also verify that audio start offsets relative to video survive decoding. +The software emulator's platform codec exercises that path; this does not establish +physical hardware acceleration or replace a full licensed Fusion Runtime export. + +## Shared C++ encryption update (2026-11-04) + +Media decryption now calls the original C++ `Encryption` core through the thin +`MediaCrypto` JNI binding. Java keeps file/materialization and application lease +ownership. Both plugins ship the same `libofe_crypto.so`; rebuild/install both +export ZIPs together. Its static OpenSSL dependency follows Windows' +`EncryptVersion.cmake` (3.6.5), with an ABI/API-checked Android SDK at +`OFE_ENCRYPT_ROOT`. Automatic source deployment uses the matching host NDK under +Linux/WSL. See the [shared encryption build and validation notes](../../Android_Gradle/Media-JNI.md#shared-encryption-and-deferred-encrypter-runtime). +The standalone Encrypter Fusion extension is still deferred. diff --git a/Extensions/FFMpeg/Android/build_sdk.py b/Extensions/FFMpeg/Android/build_sdk.py new file mode 100644 index 000000000..71afcf845 --- /dev/null +++ b/Extensions/FFMpeg/Android/build_sdk.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Build the pinned LGPL FFmpeg Android SDK with an installed NDK and bash/make.""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +import re +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import urllib.request + +# One version selection for Windows and Android; the ZIP carries a snapshot of +# the same CMake file next to this reproduction recipe. +VERSION_FILE = Path(__file__).resolve().parents[2] / '_3rdLib/FFmpeg-build/FFmpegVersion.cmake' +if not VERSION_FILE.is_file(): VERSION_FILE = Path(__file__).with_name('FFmpegVersion.cmake') +def version_settings(path=VERSION_FILE): + text = path.read_text() + def setting(name): + match = re.search(r'^set\(' + name + r'\s+"([^"\n]+)"\)', text, re.MULTILINE) + if not match: raise ValueError('Missing literal ' + name + ' in ' + str(path)) + return match.group(1) + version, sha = setting('OFE_FFMPEG_VERSION'), setting('OFE_FFMPEG_SOURCE_SHA256') + if not re.fullmatch(r'[0-9]+(?:\.[0-9]+)+', version) or not re.fullmatch(r'[0-9a-fA-F]{64}', sha): + raise ValueError('Invalid FFmpeg version/source SHA256 in ' + str(path)) + return version, sha.lower() +VERSION, SHA256 = version_settings() +URL = f'https://codeload.github.com/FFmpeg/FFmpeg/tar.gz/refs/tags/n{VERSION}' +ABIS = { + 'arm64-v8a': ('aarch64', 'aarch64-linux-android', 'aarch64-linux-android'), + 'armeabi-v7a': ('arm', 'armv7a-linux-androideabi', 'arm-linux-androideabi'), + 'x86': ('x86', 'i686-linux-android', 'i686-linux-android'), + 'x86_64': ('x86_64', 'x86_64-linux-android', 'x86_64-linux-android'), +} +COMPONENTS = ('avcodec', 'avformat', 'avutil', 'swscale', 'swresample', 'avfilter') + +def sdk_matches(output, abis): + try: + manifest = json.loads((output/'manifest.json').read_text()) + return (manifest['version'] == VERSION and manifest['source_sha256'] == SHA256 + and all(abi in manifest['abis'] and (output/abi/'include/libavcodec/avcodec.h').is_file() + and all((output/abi/'lib'/('lib'+c+'.so')).is_file() for c in COMPONENTS) for abi in abis)) + except (OSError, ValueError, KeyError, TypeError): return False + + +def run(args, cwd=None): + print('+', subprocess.list2cmdline(list(map(str, args))), flush=True) + subprocess.run(list(map(str, args)), cwd=cwd, check=True) + +def source(cache: Path) -> Path: + cache.mkdir(parents=True, exist_ok=True) + archive = cache / f'ffmpeg-n{VERSION}.tar.gz' + if not archive.is_file() or hashlib.sha256(archive.read_bytes()).hexdigest() != SHA256: + temporary = archive.with_suffix('.part') + with urllib.request.urlopen(URL, timeout=120) as response, temporary.open('wb') as out: + shutil.copyfileobj(response, out) + if hashlib.sha256(temporary.read_bytes()).hexdigest() != SHA256: + temporary.unlink(); raise ValueError('FFmpeg source SHA-256 mismatch') + temporary.replace(archive) + root = cache / f'FFmpeg-n{VERSION}' + # Always verify the archive; extract into a clean staging directory once. + if not root.exists(): + with tarfile.open(archive) as tar: + tar.extractall(cache, filter='data') + return root + +def build(ndk: Path, output: Path, cache: Path, abis, jobs: int): + if sys.version_info < (3, 12): + raise RuntimeError('FFmpeg SDK source preparation requires Python 3.12 or newer') + if os.name == 'nt': + raise RuntimeError('Build the FFmpeg SDK under Linux/WSL with its Linux NDK, then set OFE_FFMPEG_ROOT to that SDK for Windows publishing.') + previous_abis = [] + try: + previous = json.loads((output/'manifest.json').read_text()) + if previous['version'] == VERSION and previous['source_sha256'] == SHA256: previous_abis = previous['abis'] + except (OSError, ValueError, KeyError): pass + src = source(cache) + hosts = list((ndk / 'toolchains/llvm/prebuilt').glob('*')) + if len(hosts) != 1: raise ValueError('NDK must contain one host LLVM toolchain') + toolchain = hosts[0] / 'bin' + suffix = '' + for abi in abis: + arch, cc, triple = ABIS[abi] + work = cache / ('build-' + abi); work.mkdir(exist_ok=True) + prefix = (output / abi).resolve() + configuration = [ + str(src / 'configure'), '--target-os=android', '--enable-cross-compile', '--arch=' + arch, + '--cc=' + str(toolchain / (cc + '21-clang')), + '--cxx=' + str(toolchain / (cc + '21-clang++')), + '--ar=' + str(toolchain / ('llvm-ar' + suffix)), '--ranlib=' + str(toolchain / ('llvm-ranlib' + suffix)), + '--strip=' + str(toolchain / ('llvm-strip' + suffix)), '--nm=' + str(toolchain / ('llvm-nm' + suffix)), + '--prefix=' + str(prefix), '--disable-autodetect', '--enable-shared', '--disable-static', + '--disable-programs', '--disable-doc', '--disable-avdevice', '--enable-avfilter', '--disable-filters', + '--enable-filter=abuffer,abuffersink,atempo,aformat,aresample', + '--disable-encoders', '--disable-muxers', '--enable-zlib', + '--enable-jni', '--enable-mediacodec', '--disable-x86asm', + '--extra-ldflags=-Wl,-z,max-page-size=16384', + ] + if abi == 'armeabi-v7a': configuration += ['--cpu=armv7-a', '--enable-neon'] + # Android forbids the absolute text relocations emitted by some 32-bit x86 inline assembly. + if abi == 'x86': configuration += ['--disable-inline-asm'] + signature = json.dumps({'configure': configuration, + 'ndk': (ndk/'source.properties').read_text(), 'source': SHA256}, sort_keys=True) + stamp = work/'ofe-build-signature.json' + if (work/'Makefile').exists() and (not stamp.exists() or stamp.read_text() != signature): + run(['make', 'distclean'], cwd=work) + run(['bash', *configuration], cwd=work) + stamp.write_text(signature) + run(['make', '-j' + str(jobs)], cwd=work) + run(['make', 'install'], cwd=work) + for name in COMPONENTS: + if not (prefix / 'lib' / f'lib{name}.so').is_file(): raise FileNotFoundError(name) + shutil.copy2(work / 'config.h', prefix / 'config.h') + shutil.copy2(work / 'ffbuild/config.mak', prefix / 'config.mak') + licenses = output / 'licenses'; licenses.mkdir(parents=True, exist_ok=True) + for item in src.glob('COPYING*'): shutil.copy2(item, licenses / item.name) + shutil.copy2(cache / f'ffmpeg-n{VERSION}.tar.gz', output / f'ffmpeg-n{VERSION}.tar.gz') + (output / 'manifest.json').write_text(json.dumps({'version': VERSION, 'source_url': URL, + 'source_sha256': SHA256, 'abis': [abi for abi in ABIS if abi in set(previous_abis) | set(abis) and all((output/abi/'lib'/('lib'+c+'.so')).is_file() for c in COMPONENTS)], 'license': 'LGPL-2.1-or-later', 'api': 21}, indent=2) + '\n') + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--ndk', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + parser.add_argument('--cache', type=Path, default=Path(__file__).parent / 'out') + parser.add_argument('--abi', action='append', choices=list(ABIS)) + parser.add_argument('--jobs', type=int, default=min(os.cpu_count() or 2, 8)) + args = parser.parse_args() + build(args.ndk.resolve(), args.output.resolve(), args.cache.resolve(), args.abi or list(ABIS), args.jobs) diff --git a/Extensions/FFMpeg/Android/cpp/CMakeLists.txt b/Extensions/FFMpeg/Android/cpp/CMakeLists.txt new file mode 100644 index 000000000..f54566773 --- /dev/null +++ b/Extensions/FFMpeg/Android/cpp/CMakeLists.txt @@ -0,0 +1,90 @@ +cmake_minimum_required(VERSION 3.22) +project(ofe_ffmpeg LANGUAGES CXX) +set(OFE_FFMPEG_ROOT "" CACHE PATH "FFmpeg SDK containing include and lib/ (or lib on host)") +if(ANDROID) + include("${CMAKE_CURRENT_LIST_DIR}/EnsureSDK.cmake") +endif() +if(ANDROID AND EXISTS "${OFE_FFMPEG_ROOT}/${ANDROID_ABI}/include/libavcodec/avcodec.h") + set(OFE_FFMPEG_ROOT "${OFE_FFMPEG_ROOT}/${ANDROID_ABI}") +endif() +if(NOT EXISTS "${OFE_FFMPEG_ROOT}/include/libavcodec/avcodec.h") + message(FATAL_ERROR "Set OFE_FFMPEG_ROOT to an FFmpeg SDK with Android MediaCodec enabled") +endif() +get_filename_component(OFE_EXTENSIONS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) +if(NOT EXISTS "${OFE_EXTENSIONS_ROOT}/_3rdLib/FFMpeg.h") + get_filename_component(OFE_EXTENSIONS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +endif() +set(OFE_SHARED_ROOT "${OFE_EXTENSIONS_ROOT}/_3rdLib" CACHE PATH "Original shared C++ decoder sources") +set(OFE_DELIB_ROOT "${OFE_EXTENSIONS_ROOT}/_DeLib" CACHE PATH "Shared C++ utilities") +add_library(ofe_ffmpeg_core STATIC + "${OFE_SHARED_ROOT}/FFMpeg.cpp" + "${OFE_SHARED_ROOT}/FFMpegAdapter.cpp" + "${OFE_SHARED_ROOT}/FFMpegAdapterAndroid.cpp") +target_include_directories(ofe_ffmpeg_core PUBLIC "${OFE_SHARED_ROOT}" "${OFE_DELIB_ROOT}") +if(NOT TARGET DeLib::Helpers) + set(FTW_CORE_INCLUDE "${OFE_DELIB_ROOT}" CACHE PATH "Shared helper headers") + set(ofe_helpers "${OFE_EXTENSIONS_ROOT}/Android_Gradle/cmake/DeLibHelpers.cmake") + if(NOT EXISTS "${ofe_helpers}") + set(ofe_helpers "${OFE_EXTENSIONS_ROOT}/cmake/DeLibHelpers.cmake") + endif() + if(ANDROID AND NOT FTW_ICONV_ROOT) + set(FTW_ICONV_ROOT "${OFE_EXTENSIONS_ROOT}/_3rdLib/iconv-android" CACHE PATH "Shared target iconv") + endif() + include("${ofe_helpers}") +endif() +target_link_libraries(ofe_ffmpeg_core PUBLIC DeLib::Helpers) +set_target_properties(ofe_ffmpeg_core PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_compile_features(ofe_ffmpeg_core PUBLIC cxx_std_20) +target_compile_options(ofe_ffmpeg_core PRIVATE -Wall -Wextra -Wno-unknown-pragmas -Wno-unused-parameter) +target_include_directories(ofe_ffmpeg_core PUBLIC "${OFE_FFMPEG_ROOT}/include") +foreach(component avformat avcodec avfilter swscale swresample avutil) + unset(OFE_${component} CACHE) + find_library(OFE_${component} NAMES ${component} + PATHS "${OFE_FFMPEG_ROOT}/lib/${ANDROID_ABI}" "${OFE_FFMPEG_ROOT}/lib" NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH REQUIRED) + add_library(ofe_${component} SHARED IMPORTED) + set_target_properties(ofe_${component} PROPERTIES IMPORTED_LOCATION "${OFE_${component}}") + target_link_libraries(ofe_ffmpeg_core PUBLIC ofe_${component}) +endforeach() +add_library(ofe_ffmpeg SHARED JNI.cpp + "${OFE_EXTENSIONS_ROOT}/FFMpeg/CopyAdapter/CopyAdapter.cpp" + "${OFE_EXTENSIONS_ROOT}/FFMpeg/CopyAdapter/CopyAdapterAndroid.cpp") +target_link_libraries(ofe_ffmpeg PRIVATE ofe_ffmpeg_core) +target_include_directories(ofe_ffmpeg PRIVATE "${OFE_EXTENSIONS_ROOT}/FFMpeg/CopyAdapter") +if(TARGET ofe_audio) + target_link_libraries(ofe_ffmpeg PRIVATE ofe_audio) + target_link_libraries(ofe_ffmpeg_core PUBLIC SDL2) +else() + set(OFE_AUDIO_ROOT "" CACHE PATH "Shared Mixer X builds, containing /lib/libofe_audio.so") + get_filename_component(ofe_audio_include "${CMAKE_CURRENT_LIST_DIR}/../../../SDL_Mixer/Android/cpp" ABSOLUTE) + if(NOT EXISTS "${ofe_audio_include}/AudioRuntime.h") + set(ofe_audio_include "${CMAKE_CURRENT_LIST_DIR}/../../SDL_Mixer/cpp") + endif() + set(OFE_AUDIO_INCLUDE "${ofe_audio_include}" CACHE PATH "Shared AudioRuntime.h directory") + unset(OFE_AUDIO_LIBRARY CACHE) + find_library(OFE_AUDIO_LIBRARY NAMES ofe_audio PATHS + "${OFE_AUDIO_ROOT}/${ANDROID_ABI}/lib" "${OFE_AUDIO_ROOT}/lib" + NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH REQUIRED) + add_library(ofe_audio_shared SHARED IMPORTED) + set_target_properties(ofe_audio_shared PROPERTIES IMPORTED_LOCATION "${OFE_AUDIO_LIBRARY}") + target_link_libraries(ofe_ffmpeg PRIVATE ofe_audio_shared) + target_include_directories(ofe_ffmpeg PRIVATE "${OFE_AUDIO_INCLUDE}") + get_filename_component(ofe_audio_libdir "${OFE_AUDIO_LIBRARY}" DIRECTORY) + get_filename_component(ofe_audio_abi "${ofe_audio_libdir}" DIRECTORY) + set(OFE_SDL_INCLUDE "${ofe_audio_abi}/codecs-sdk/include/SDL2" CACHE PATH "Shared SDL headers") + target_include_directories(ofe_ffmpeg_core PUBLIC "${OFE_SDL_INCLUDE}") + find_library(OFE_SDL_LIBRARY NAMES SDL2 SDL2-2.0 PATHS "${ofe_audio_abi}/lib" + NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH REQUIRED) + target_link_libraries(ofe_ffmpeg_core PUBLIC "${OFE_SDL_LIBRARY}") +endif() +if(ANDROID) + target_link_options(ofe_ffmpeg PRIVATE "-Wl,-z,max-page-size=16384") +else() + find_path(OFE_JNI_INCLUDE jni.h HINTS "$ENV{JAVA_HOME}/include" REQUIRED) + find_path(OFE_JNI_PLATFORM jni_md.h HINTS "$ENV{JAVA_HOME}/include/linux" + "$ENV{JAVA_HOME}/include/darwin" "$ENV{JAVA_HOME}/include/win32" REQUIRED) + target_include_directories(ofe_ffmpeg PRIVATE "${OFE_JNI_INCLUDE}" "${OFE_JNI_PLATFORM}") +endif() +if(ANDROID) + target_sources(ofe_ffmpeg PRIVATE CopyAdapter.cpp) + target_link_libraries(ofe_ffmpeg PRIVATE GLESv2 log) +endif() diff --git a/Extensions/FFMpeg/Android/cpp/CopyAdapter.cpp b/Extensions/FFMpeg/Android/cpp/CopyAdapter.cpp new file mode 100644 index 000000000..acc262777 --- /dev/null +++ b/Extensions/FFMpeg/Android/cpp/CopyAdapter.cpp @@ -0,0 +1,14 @@ +// JNI marshals platform handles; CopyAdapter owns the rendering operation. +#ifdef __ANDROID__ +#include "CopyAdapter.h" +#include +extern "C" JNIEXPORT void JNICALL Java__1DeLib_FFMpegTexture_nBlit(JNIEnv* env, jclass, + jint program, jint external, jint framebuffer, jint vbo, jint width, jint height, jfloatArray matrix) { + if (!matrix || env->GetArrayLength(matrix) != 16) return; + AndroidCopyTarget target; + target.program = program; target.external = external; target.framebuffer = framebuffer; + target.vbo = vbo; target.width = width; target.height = height; + env->GetFloatArrayRegion(matrix, 0, 16, target.transform); + if (!env->ExceptionCheck()) CopyAdapterFactory(AV_HWDEVICE_TYPE_MEDIACODEC)->CopyTexture(target, nullptr); +} +#endif diff --git a/Extensions/FFMpeg/Android/cpp/EnsureSDK.cmake b/Extensions/FFMpeg/Android/cpp/EnsureSDK.cmake new file mode 100644 index 000000000..3c86feefd --- /dev/null +++ b/Extensions/FFMpeg/Android/cpp/EnsureSDK.cmake @@ -0,0 +1,49 @@ +# Android deployment uses the same release selection as the Windows SDK. +get_filename_component(ofe_version_file "${CMAKE_CURRENT_LIST_DIR}/../../../_3rdLib/FFmpeg-build/FFmpegVersion.cmake" ABSOLUTE) +if(NOT EXISTS "${ofe_version_file}") + set(ofe_version_file "${CMAKE_CURRENT_LIST_DIR}/../FFmpegVersion.cmake") +endif() +include("${ofe_version_file}") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ofe_version_file}") +if(NOT OFE_FFMPEG_ROOT) + get_filename_component(OFE_FFMPEG_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../_3rdLib/FFmpeg-sdk-Android" ABSOLUTE) + set(OFE_FFMPEG_ROOT "${OFE_FFMPEG_ROOT}" CACHE PATH "FFmpeg Android SDK root" FORCE) +endif() +option(OFE_FFMPEG_AUTO_DEPLOY "Build a missing or stale Android SDK from the shared FFmpeg version" ON) +set(ofe_sdk_valid FALSE) +if(EXISTS "${OFE_FFMPEG_ROOT}/manifest.json") + file(READ "${OFE_FFMPEG_ROOT}/manifest.json" ofe_manifest) + string(JSON ofe_version ERROR_VARIABLE ofe_version_error GET "${ofe_manifest}" version) + string(JSON ofe_hash ERROR_VARIABLE ofe_hash_error GET "${ofe_manifest}" source_sha256) + if(NOT ofe_version_error AND NOT ofe_hash_error AND ofe_version STREQUAL OFE_FFMPEG_VERSION AND ofe_hash STREQUAL OFE_FFMPEG_SOURCE_SHA256) + set(ofe_sdk_valid TRUE) + string(JSON ofe_abis ERROR_VARIABLE ofe_abis_error GET "${ofe_manifest}" abis) + if(ofe_abis_error OR NOT ofe_abis MATCHES "\"${ANDROID_ABI}\"") + set(ofe_sdk_valid FALSE) + endif() + foreach(component avcodec avformat avutil swscale swresample avfilter) + if(NOT EXISTS "${OFE_FFMPEG_ROOT}/${ANDROID_ABI}/lib/lib${component}.so" OR + NOT EXISTS "${OFE_FFMPEG_ROOT}/${ANDROID_ABI}/include/lib${component}/version.h") + set(ofe_sdk_valid FALSE) + endif() + endforeach() + endif() +endif() +if(NOT ofe_sdk_valid) + if(NOT OFE_FFMPEG_AUTO_DEPLOY) + message(FATAL_ERROR "FFmpeg Android SDK must match ${ofe_version_file}; enable OFE_FFMPEG_AUTO_DEPLOY or rebuild ${OFE_FFMPEG_ROOT}.") + endif() + find_package(Python3 3.12 REQUIRED COMPONENTS Interpreter) + # Serialize all ABI configurations targeting the same SDK/cache. + file(MAKE_DIRECTORY "${OFE_FFMPEG_ROOT}") + file(LOCK "${OFE_FFMPEG_ROOT}/.ofe-build.lock" GUARD PROCESS TIMEOUT 1800) + message(STATUS "Preparing Android FFmpeg ${OFE_FFMPEG_VERSION} for ${ANDROID_ABI}: ${OFE_FFMPEG_ROOT}") + execute_process(COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/../build_sdk.py" + --ndk "${CMAKE_ANDROID_NDK}" --output "${OFE_FFMPEG_ROOT}" + --cache "${OFE_FFMPEG_ROOT}/.build" --abi "${ANDROID_ABI}" + RESULT_VARIABLE ofe_sdk_result) + if(NOT ofe_sdk_result EQUAL 0) + message(FATAL_ERROR "FFmpeg Android deployment failed. Build under Linux/WSL with its NDK, then set OFE_FFMPEG_ROOT to that SDK on Windows.") + endif() + file(LOCK "${OFE_FFMPEG_ROOT}/.ofe-build.lock" RELEASE) +endif() diff --git a/Extensions/FFMpeg/Android/cpp/JNI.cpp b/Extensions/FFMpeg/Android/cpp/JNI.cpp new file mode 100644 index 000000000..a36c7c2a4 --- /dev/null +++ b/Extensions/FFMpeg/Android/cpp/JNI.cpp @@ -0,0 +1,184 @@ +#include "FFMpeg.h" +#include "CopyAdapter.h" +#include "AudioRuntime.h" +#include +#include +#include +#include +#include +#include +#ifdef __ANDROID__ +#include +#include +extern "C" { +#include +} +#endif +namespace { +std::mutex registryMutex; +struct Session { + std::unique_ptr player; + std::vector pixels; + bool hasFrame = false; + uint64_t client = 0; + void* pcm = nullptr; + bool playing = false, suspended = false; + float volume = 1; + double speed = 1; + ~Session() { if (pcm) OFEAudio_DestroyPCM(pcm); if (client) OFEAudio_Release(client); } + FrameDataCallBack frame() { + return [this](const unsigned char* data, int stride, int height) { + if (data) { + const int width = player->get_width(); + pixels.resize(static_cast(width) * height * 4); + for (int row = 0; row < height; ++row) + std::memcpy(pixels.data() + static_cast(row) * width * 4, + data + static_cast(row) * stride, width * 4); + } + hasFrame = true; + }; + } + void initAudio() { + if (!player->has_audio()) return; + client = OFEAudio_Acquire(); + if (!client) throw std::runtime_error("Cannot acquire shared Mixer X output"); + pcm = OFEAudio_CreatePullPCM([](void* user, void* bytes, int count) { + auto& self = *static_cast(user); + self.player->audio_fillData(static_cast(bytes), count, std::memset, + [](void* dst, const void* src, size_t size, int volume) { + SDL_MixAudioFormat(static_cast(dst), static_cast(src), + AUDIO_S16SYS, static_cast(size), volume); + }); + }, this); + state(); + } + void state() { + player->set_pause(!playing || suspended); + player->set_sdl_volume(static_cast(std::clamp(volume, 0.f, 1.f) * SDL_MIX_MAXVOLUME)); + player->set_audioTempo(static_cast(speed)); + if (!pcm) return; + OFEAudio_PCMState(pcm, playing && !suspended, 1, 1); + OFEAudio_SetSuspended(client, suspended || !playing); + } +}; +std::unordered_map> players; +jlong nextId = 1; +std::shared_ptr get(jlong id) { + std::lock_guard lock(registryMutex); + auto it = players.find(id); + if (it == players.end()) throw std::runtime_error("FFmpeg player is closed"); + return it->second; +} +std::string utf8(JNIEnv* env, jstring value) { + if (!value) return {}; + // FFmpeg filenames are UTF-8, not JNI modified UTF-8. + const jchar* chars = env->GetStringChars(value, nullptr); + if (!chars) throw std::bad_alloc(); + std::string out; + for (jsize i = 0, size = env->GetStringLength(value); i < size; ++i) { + uint32_t c = chars[i]; + if (c >= 0xd800 && c <= 0xdbff && i + 1 < size && chars[i+1] >= 0xdc00 && chars[i+1] <= 0xdfff) + c = 0x10000 + ((c - 0xd800) << 10) + (chars[++i] - 0xdc00); + else if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd; + if (!c) { env->ReleaseStringChars(value, chars); throw std::runtime_error("NUL in FFmpeg filename"); } + if (c < 0x80) out += static_cast(c); + else if (c < 0x800) { out += static_cast(0xc0 | (c >> 6)); out += static_cast(0x80 | (c & 63)); } + else { + if (c >= 0x10000) out += static_cast(0xf0 | (c >> 18)); + out += static_cast((c >= 0x10000 ? 0x80 : 0xe0) | ((c >> 12) & (c >= 0x10000 ? 63 : 15))); + out += static_cast(0x80 | ((c >> 6) & 63)); out += static_cast(0x80 | (c & 63)); + } + } + env->ReleaseStringChars(value, chars); return out; +} +void fail(JNIEnv* env, const char* message) { + if (!env->ExceptionCheck()) env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), message); +} +} +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { +#ifdef __ANDROID__ + if (av_jni_set_java_vm(vm, nullptr) < 0) return JNI_ERR; + av_log_set_callback([](void*, int level, const char* format, va_list args) { + if (level > AV_LOG_WARNING) return; + char text[1024]; vsnprintf(text, sizeof(text), format, args); + __android_log_write(level <= AV_LOG_ERROR ? ANDROID_LOG_ERROR : ANDROID_LOG_WARN, "OFE.FFMpeg.Native", text); + }); +#else + (void)vm; +#endif + return JNI_VERSION_1_6; +} +#define JNI_NAME(name) Java__1DeLib_FFMpegPlayer_##name +extern "C" JNIEXPORT jlong JNICALL JNI_NAME(nOpen)(JNIEnv* env, jclass, jstring path, + jobject surface, jint threads, jstring video, jstring audio, jboolean mute) { + try { + auto session = std::make_shared(); + FFMpegOptions options; + options.platformSurface = surface; + options.outputFormat = AV_PIX_FMT_RGBA; + options.SetThreadCount(threads); + options.videoCodecName = utf8(env, video); + options.audioCodecName = utf8(env, audio); + if (mute) options.flag |= FFMpegFlag_ForceNoAudio; + if (surface) options.flag |= AV_HWDEVICE_TYPE_MEDIACODEC | FFMpegFlag_CopyToTexture; + session->player = std::make_unique(utf8(env, path), options); + session->initAudio(); + std::lock_guard lock(registryMutex); auto id = nextId++; players.emplace(id, std::move(session)); return id; + } catch (const std::exception& e) { fail(env, e.what()); return 0; } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nClose)(JNIEnv*, jclass, jlong id) { + std::shared_ptr removed; + { std::lock_guard lock(registryMutex); auto it = players.find(id); + if (it != players.end()) { removed = std::move(it->second); players.erase(it); } } +} +extern "C" JNIEXPORT jlongArray JNICALL JNI_NAME(nInfo)(JNIEnv* env, jclass, jlong id) { + try { + auto session = get(id); auto p = session->player.get(); + jlong values[] = {p->get_width(), p->get_height(), p->get_videoDuration(), p->get_videoPosition(), p->get_hwDecodeState(), p->get_finishState(), session->hasFrame}; + auto out = env->NewLongArray(7); if (out) env->SetLongArrayRegion(out, 0, 7, values); return out; + } catch (const std::exception& e) { fail(env, e.what()); return nullptr; } +} +extern "C" JNIEXPORT jint JNICALL JNI_NAME(nNext)(JNIEnv* env, jclass, jlong id, jlong target) { + try { auto session = get(id); auto& p = *session->player; + bool updated = false; + const int result = p.get_nextFrame([&](const unsigned char* data, int stride, int height) { + session->frame()(data, stride, height); updated = true; + }, target != INT64_MAX); + if (result < 0 && result != END_OF_QUEUE) throw FFMpegException(FFMpegException_DecodeFailed); + return updated ? 1 : (target == INT64_MAX ? p.get_finishState() : p.playback_finished()) ? 2 : 0; } + catch (const std::exception& e) { fail(env, e.what()); return -1; } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nSeek)(JNIEnv* env, jclass, jlong id, jlong ms, jint flags, jboolean accurate) { + try { auto session = get(id); + if (session->pcm) OFEAudio_PCMState(session->pcm, 0, 1, 1); + int result = session->player->seek(ms, flags, accurate, session->frame()); + if (session->pcm) OFEAudio_PCMReset(session->pcm, std::max(0, ms)); + session->state(); + if (result < 0) throw std::runtime_error("FFmpeg seek failed"); } + catch (const std::exception& e) { fail(env, e.what()); } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nCopy)(JNIEnv* env, jclass, jlong id, jobject buffer, jboolean premultiply) { + try { auto session = get(id); const auto& pixels = session->pixels; auto data = env->GetDirectBufferAddress(buffer); + auto capacity = env->GetDirectBufferCapacity(buffer); + if (!data || capacity < 0 || static_cast(capacity) < pixels.size()) throw std::runtime_error("RGBA buffer too small"); + AndroidCopyTarget target; + target.pixels = static_cast(data); target.capacity = static_cast(capacity); + target.width = session->player->get_width(); target.height = session->player->get_height(); + target.stride = target.width * 4; target.premultiply = premultiply; + if (!CopyAdapterFactory(AV_HWDEVICE_TYPE_NONE)->CopyTexture(target, pixels.data())) + throw std::runtime_error("Cannot copy decoded frame"); + } catch (const std::exception& e) { fail(env, e.what()); } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nState)(JNIEnv* env, jclass, jlong id, jboolean playing, + jboolean suspended, jfloat volume, jdouble speed) { + try { auto p = get(id); p->playing = playing; p->suspended = suspended; p->volume = volume; p->speed = speed; p->state(); } + catch (const std::exception& e) { fail(env, e.what()); } +} +extern "C" JNIEXPORT jdouble JNICALL JNI_NAME(nClock)(JNIEnv* env, jclass, jlong id) { + try { auto p = get(id); return p->player->get_playbackClock(); } + catch (const std::exception& e) { fail(env, e.what()); return -1.; } +} +extern "C" JNIEXPORT jboolean JNICALL JNI_NAME(nPending)(JNIEnv* env, jclass, jlong id) { + try { auto p = get(id); return !p->player->audio_finished(); } + catch (const std::exception& e) { fail(env, e.what()); return false; } +} diff --git a/Extensions/FFMpeg/CopyAdapter/CopyAdapter.cpp b/Extensions/FFMpeg/CopyAdapter/CopyAdapter.cpp index 9f2196c35..08fff3829 100644 --- a/Extensions/FFMpeg/CopyAdapter/CopyAdapter.cpp +++ b/Extensions/FFMpeg/CopyAdapter/CopyAdapter.cpp @@ -1,5 +1,6 @@ #include "CopyAdapter.h" +#ifdef _WIN32 #include "Common.h" #include "CopyAdapterBitmap.h" @@ -57,3 +58,10 @@ bool UpdateCopyAdapter(CopyAdapter*& pCopyAdapter, return false; } + +#else +#include "CopyAdapterAndroid.h" +std::unique_ptr CopyAdapterFactory(AVHWDeviceType type) { + return std::make_unique(type); +} +#endif diff --git a/Extensions/FFMpeg/CopyAdapter/CopyAdapter.h b/Extensions/FFMpeg/CopyAdapter/CopyAdapter.h index f2b9a4226..0d73b559f 100644 --- a/Extensions/FFMpeg/CopyAdapter/CopyAdapter.h +++ b/Extensions/FFMpeg/CopyAdapter/CopyAdapter.h @@ -6,6 +6,7 @@ extern "C" { #include } +#ifdef _WIN32 struct tagRDATA; typedef struct tagRDATA RUNDATA; typedef RUNDATA* LPRDATA; @@ -34,4 +35,22 @@ struct CopyAdapter { bool CopyAdapterSupport(LPRDATA rdPtr, const AVHWDeviceType type); // always fallback to bitmap bool UpdateCopyAdapter(CopyAdapter*& pCopyAdapter, - LPRDATA rdPtr, const AVHWDeviceType type); \ No newline at end of file + LPRDATA rdPtr, const AVHWDeviceType type); +#else +#include +#include +#include +struct AndroidCopyTarget { + uint8_t* pixels = nullptr; + size_t capacity = 0; + int width = 0, height = 0, stride = 0; + bool premultiply = false; + int program = 0, external = 0, framebuffer = 0, vbo = 0; + float transform[16] = {}; +}; +struct CopyAdapter { + virtual ~CopyAdapter() = default; + virtual bool CopyTexture(const AndroidCopyTarget&, const unsigned char*) = 0; +}; +std::unique_ptr CopyAdapterFactory(AVHWDeviceType type); +#endif diff --git a/Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.cpp b/Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.cpp new file mode 100644 index 000000000..909bd1493 --- /dev/null +++ b/Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.cpp @@ -0,0 +1,94 @@ +#include "CopyAdapterAndroid.h" +#ifndef _WIN32 +#include +namespace { +bool CopyPixels(const AndroidCopyTarget& target, const unsigned char* data) { + if (!data || !target.pixels || target.width <= 0 || target.height <= 0 + || target.stride < target.width * 4 + || target.capacity / 4 / target.width < static_cast(target.height)) return false; + for (int row = 0; row < target.height; ++row) { + auto dst = target.pixels + static_cast(row) * target.width * 4; + std::memcpy(dst, data + static_cast(row) * target.stride, target.width * 4); + if (target.premultiply) + for (int x = 0; x < target.width; ++x) + for (int channel = 0; channel < 3; ++channel) + dst[x*4+channel] = (dst[x*4+channel] * dst[x*4+3] + 127) / 255; + } + return true; +} +} +// Android equivalent of CopyAdapterD3D11: external decoder image -> Fusion 2D texture. +#ifdef __ANDROID__ + +#include +#include +namespace { +struct Attribute { + GLint enabled, size, stride, type, normalized, buffer; + void* pointer; + Attribute() { + glGetVertexAttribiv(0, GL_VERTEX_ATTRIB_ARRAY_ENABLED, &enabled); + glGetVertexAttribiv(0, GL_VERTEX_ATTRIB_ARRAY_SIZE, &size); + glGetVertexAttribiv(0, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &stride); + glGetVertexAttribiv(0, GL_VERTEX_ATTRIB_ARRAY_TYPE, &type); + glGetVertexAttribiv(0, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED, &normalized); + glGetVertexAttribiv(0, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING, &buffer); + glGetVertexAttribPointerv(0, GL_VERTEX_ATTRIB_ARRAY_POINTER, &pointer); + } + void restore() const { + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glVertexAttribPointer(0, size, type, normalized, stride, pointer); + if (enabled) glEnableVertexAttribArray(0); else glDisableVertexAttribArray(0); + } +}; +struct State { + GLint program, framebuffer, arrayBuffer, active, texture, external, viewport[4]; + GLboolean blend, depth, scissor, cull, stencil, color[4]; + Attribute attribute; + State() { + glGetIntegerv(GL_CURRENT_PROGRAM, &program); glGetIntegerv(GL_FRAMEBUFFER_BINDING, &framebuffer); + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &arrayBuffer); glGetIntegerv(GL_ACTIVE_TEXTURE, &active); + glActiveTexture(GL_TEXTURE0); + glGetIntegerv(GL_TEXTURE_BINDING_2D, &texture); glGetIntegerv(GL_TEXTURE_BINDING_EXTERNAL_OES, &external); + glGetIntegerv(GL_VIEWPORT, viewport); glGetBooleanv(GL_COLOR_WRITEMASK, color); + blend = glIsEnabled(GL_BLEND); depth = glIsEnabled(GL_DEPTH_TEST); scissor = glIsEnabled(GL_SCISSOR_TEST); + cull = glIsEnabled(GL_CULL_FACE); stencil = glIsEnabled(GL_STENCIL_TEST); + } + static void enable(GLenum cap, GLboolean value) { if (value) glEnable(cap); else glDisable(cap); } + ~State() { + attribute.restore(); glBindBuffer(GL_ARRAY_BUFFER, arrayBuffer); + glUseProgram(program); glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glViewport(viewport[0], viewport[1], viewport[2], viewport[3]); + glColorMask(color[0],color[1],color[2],color[3]); + enable(GL_BLEND, blend); enable(GL_DEPTH_TEST, depth); enable(GL_SCISSOR_TEST, scissor); + enable(GL_CULL_FACE, cull); enable(GL_STENCIL_TEST, stencil); + glBindTexture(GL_TEXTURE_2D, texture); glBindTexture(GL_TEXTURE_EXTERNAL_OES, external); glActiveTexture(active); + } +}; +} +bool CopyAdapterAndroid::CopyTexture(const AndroidCopyTarget& target, const unsigned char* data) { + if (type == AV_HWDEVICE_TYPE_NONE) return CopyPixels(target, data); + const auto& transform = target.transform; + const auto program = target.program, external = target.external; + const auto framebuffer = target.framebuffer, vbo = target.vbo; + const auto width = target.width, height = target.height; + State state; + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); glViewport(0, 0, width, height); + glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); glDisable(GL_SCISSOR_TEST); + glDisable(GL_CULL_FACE); glDisable(GL_STENCIL_TEST); glColorMask(GL_TRUE,GL_TRUE,GL_TRUE,GL_TRUE); + glUseProgram(program); glBindTexture(GL_TEXTURE_EXTERNAL_OES, external); + glUniform1i(glGetUniformLocation(program, "image"), 0); + glUniformMatrix4fv(glGetUniformLocation(program, "transform"), 1, GL_FALSE, transform); + glBindBuffer(GL_ARRAY_BUFFER, vbo); glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, nullptr); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + return true; +} +#endif + +#ifndef __ANDROID__ +bool CopyAdapterAndroid::CopyTexture(const AndroidCopyTarget& target, const unsigned char* data) { + return type == AV_HWDEVICE_TYPE_NONE && CopyPixels(target, data); +} +#endif +#endif diff --git a/Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.h b/Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.h new file mode 100644 index 000000000..15b113562 --- /dev/null +++ b/Extensions/FFMpeg/CopyAdapter/CopyAdapterAndroid.h @@ -0,0 +1,9 @@ +#pragma once +#ifndef _WIN32 +#include "CopyAdapter.h" +struct CopyAdapterAndroid final : CopyAdapter { + AVHWDeviceType type; + explicit CopyAdapterAndroid(AVHWDeviceType value) : type(value) {} + bool CopyTexture(const AndroidCopyTarget& target, const unsigned char* data) override; +}; +#endif diff --git a/Extensions/FFMpeg/FFMpeg.vcxproj b/Extensions/FFMpeg/FFMpeg.vcxproj index 71fccc7de..14e1b6728 100644 --- a/Extensions/FFMpeg/FFMpeg.vcxproj +++ b/Extensions/FFMpeg/FFMpeg.vcxproj @@ -56,6 +56,10 @@ + + true + true + <_ProjectFileVersion>16.0.31515.66 @@ -254,9 +258,11 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + @@ -303,6 +309,7 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + @@ -316,6 +323,7 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + @@ -406,4 +414,10 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + + + \ No newline at end of file diff --git a/Extensions/FFMpeg/FFMpeg.vcxproj.filters b/Extensions/FFMpeg/FFMpeg.vcxproj.filters index 712176c95..90267bfdf 100644 --- a/Extensions/FFMpeg/FFMpeg.vcxproj.filters +++ b/Extensions/FFMpeg/FFMpeg.vcxproj.filters @@ -82,6 +82,12 @@ CopyAdapter + + FFMpeg\FFMpegAdapter + + + CopyAdapter + @@ -241,6 +247,12 @@ FFMpeg + + FFMpeg\FFMpegAdapter + + + CopyAdapter + diff --git a/Extensions/FFMpeg/ReadMe.md b/Extensions/FFMpeg/ReadMe.md index 5c92c5bdb..375ccd435 100644 --- a/Extensions/FFMpeg/ReadMe.md +++ b/Extensions/FFMpeg/ReadMe.md @@ -39,3 +39,17 @@ The dependency target checks this file and its hash-named cache before downloadi For an offline build, add `-DOFE_FFMPEG_ARCHIVE="D:/Downloads/ffmpeg-n9.0-latest-win32-lgpl-shared-9.0.zip"`; the same pinned SHA-256 is still required. `-DOFE_FFMPEG_ROOT="D:/Dev/FFMpeg"` selects a different SDK destination for manual preparation; use the same path for `OfeFFmpegRoot` when compiling the plugin. After rebuilding the edit-time and runtime `.mfx` files, deploy the seven FFmpeg DLLs from the SDK's `bin` directory into the Fusion root and `Data/Runtime/Unicode`, alongside the matching SDL DLLs. Dependency preparation does not copy DLLs into a Fusion installation. Check the updated `GetDependencies()` names when replacing an existing installation. + +## Android JNI port + +See [Android build, rendering and compatibility notes](Android/README.md). The Android +backend uses FFmpeg with MediaCodec/SurfaceTexture hardware rendering, RGBA software +uploads, shared SDL Mixer X PCM output and Fusion's native Java runtime/file interfaces. `Run_Only Unicode` builds +publish the four-ABI Android ZIP through `_Script/FFMpeg.py`, like FTW. Set +`/p:FFMpegPackageAndroid=false` to build only the Windows runtime. Android publishing +requires a separately prepared Android FFmpeg SDK (`OFE_FFMPEG_ROOT`) or the source +build tools described in the Android notes. + +The [combined Android build/file guide](../Android_Gradle/Media-JNI.md) explains +building/installing both ZIPs, shared dependency versions, native application +storage, and readable paths/assets/embedded files/content URIs with Android grants. diff --git a/Extensions/SDL_Mixer/Android/.gitignore b/Extensions/SDL_Mixer/Android/.gitignore new file mode 100644 index 000000000..71e92a1fd --- /dev/null +++ b/Extensions/SDL_Mixer/Android/.gitignore @@ -0,0 +1,2 @@ +/out/ +/__pycache__/ diff --git a/Extensions/SDL_Mixer/Android/README.md b/Extensions/SDL_Mixer/Android/README.md new file mode 100644 index 000000000..99dca9bbf --- /dev/null +++ b/Extensions/SDL_Mixer/Android/README.md @@ -0,0 +1,250 @@ +# SDL Mixer Android JNI port + +The Android extension is a thin JNI binding over **SDL Mixer X**, with the same +7 conditions, 17 actions and 14 expressions as the Windows object. Fusion remains +the host: the port does not start SDLActivity or initialize an SDL video window. + +## Architecture and sharing + +```text +Fusion CRunSDL_Mixer -> Java SDLMixer -> libofe_sdl_mixer.so + | | + libofe_audio.so SoundTouch (static) + | + libSDL2_mixer_ext.so + | + libSDL2.so + | + SDL's Android audio driver +``` + +`libofe_audio.so` owns reference-counted Mixer X device access, focus and application +suspension. Its version-2 C ABI is in `cpp/AudioRuntime.h`; FFmpeg dynamically links the same library and queries its actual output +specification. PCM chunk/effect streams follow Windows SMI, with per-stream +queues, pause/volume, output-rate conversion and SoundTouch tempo. Do not compile another copy of this singleton into either plugin. +No AudioTrack or alternate mixer is implemented by this extension. SDL's stock +Android backend and Java audio bindings handle platform output. The default is +SDL2's stock `android` driver (JNI-backed AudioTrack), selected with default hint +priority so explicit SDL hints/environment settings can override it. The older +API-23 emulator image's default OpenSL ES fast-track path crashed its system +mediaserver; the stock `android` path passed the same playback checks. + +The native mixer owns the Windows object's channel policy: exclusive replacement +with optional crossfade, overlapping streams in a mixing channel, per-channel and +master volume, panning/position/distance, AB loops and repeated-note score pitch. +Paused outgoing exclusive streams are halted before replacement so they cannot +remain indefinitely paused while waiting for a fade to finish. Both channel types +use Mixer X's **music-stream** API; they do not compete for SDL +chunk-channel indices. Fusion instances acquire application data through the native +`CRun.getStorage/addStorage` API (`SDLMixerStorage extends CExtStorage`, ID SMIX). +Destroying an instance only detaches it: playback, settings, binary caches and +source leases remain in Fusion's application storage, including frames without +an SDL object. Sub-applications have independent channel/cache state while sharing +the Mixer X output device. Music objects are halted/freed before the application's +last device reference is released; one application's exit cannot close another's output. + +The supplied `CExtStorage` has no lifecycle/update callback. A 50 ms main-Looper +maintenance task handles AB loops, completed sources and cleanup even without +instances. It reads existing application state; there is no parallel global +registry and no artificial global-object marker. Restart detection reads +`CRunApp.playerNames` identity: `initGlobal()` allocates a new array on every +Restart Application (also for subapps sharing values), whereas frame changes and +Restart Frame preserve it. Ancestor restarts/exit invalidate descendants. +Activity lifecycle callbacks handle background/resume/destruction; SL_QUIT or +restart is cleaned up on the next maintenance pass, or synchronously before the +next instance acquires storage. Storage is removed using `delStorage` when a +current CRun exists, otherwise from the owning application's native storage list. +No exporter runtime classes or global/player values are replaced or modified. + +JNI handles are registry IDs, never native addresses. Audio callbacks do not call +Fusion or JNI. Completed streams are reclaimed by instance updates and application maintenance. +SoundTouch processors and scratch buffers are private to each pitched stream, +with state retained across callbacks. User pause is independent from application +suspension and audio focus. Each application suspends its own streams, so another +application can continue rendering; resuming preserves user pause. The current focus policy pauses on transient loss or +ducking and resumes on gain; it does not change individual stream pause flags. + +`MediaSource.Resolver` supplies application-owned common file/asset/content-URI/HTTP(S) materialization and +Win32-compatible AES/CBC key derivation. Both Fusion adapters use `FusionFiles`/`CRunApp.openHFile` for embedded +binaries, OBB and platform file resolution. FFmpeg now shares Mixer X output. +See [the combined build/file guide](../../Android_Gradle/Media-JNI.md) for URI +grants, scoped storage, installation and standalone/shared dependency handling. Extracted files remain leased while Mixer X streams read +them. Binary cache updates retain old readers, and release skips referenced data. + +## Upstream Android compatibility + +The manifest pins the same revisions as the Windows dependencies: + +| Component | Revision | Notes | +| --- | --- | --- | +| SDL2 2.32.10 | `5d249570393f7a37e037abf22cd6012a4cc56a71` | Stock Android JNI/Java bindings and audio drivers | +| Mixer X 2.8.0 | `72139aa7332062de541de50708742fe4ee2ae05c` | CMake supports Android; multi-music APIs have no Windows-only restriction | +| AudioCodecs | `bb11ca3cc1d7597b23a530d519b3e636dceb7b23` | Same static codec suite and LGPL/non-GPL selection as Windows | +| SoundTouch 2.3.3 | `e83424d5928ab8513d2d082779c275765dee31b9` | Native core, statically linked into the JNI extension | + +The Mixer X maintainer explicitly confirms the Android CMake build in +[issue 59](https://github.com/WohlSoft/SDL-Mixer-X/issues/59#issuecomment-1279765831). +The missing Android.mk is intentional. The upstream master source archive inspected +on 2026-10-31 matched the pinned Mixer X tree; this port does not require an upgrade. +SoundTouch's download uses a GitHub mirror retaining the exact upstream commit; +its Codeberg origin and archive SHA-256 are recorded in `dependencies.json`. + +SDL's full Android build dependencies remain compiled because its stock JNI method +registration references joystick/haptic and video symbols. Runtime initialization +is restricted to AUDIO and TIMER. The matching upstream `org.libsdl.app` Java +classes are packaged unchanged, but no SDL Activity is declared in the manifest. +Native registration depends on those exact class/method names; R8 rules retain them. +The development CMake wrapper supplies an SDL2 package discovery shim and avoids +exporting an SDL2main application dependency. No upstream checkout is modified. + +Actual playback checks exposed two upstream issues: + +- The pinned crossfade helper combines FadeOut's `1` success convention with + FadeIn's `0` success convention incorrectly. The wrapper calls those same two + primitives directly and checks the incoming stream's start result. +- PCM WAV seeking aligns offsets to a whole second instead of a sample frame. + `cpp/MixerXFixes.cmake` compiles an explicitly marked corrected source copy with + that one-line alignment change. The original source tree remains untouched; + both the original archive and correction recipe accompany binary packages. + +On the API-23 x86_64 software emulator, Clang's automatically vectorized +SoundTouch coefficient setup faulted when its allocation ended at a page boundary. +The Android x86_64 build scalarizes that one setup translation unit; explicit SSE +playback kernels remain enabled. This is a reproducible emulator compatibility +workaround, not a claim of a physical-CPU defect. + +The host GCC 14 build additionally needs `alloca=__builtin_alloca` for upstream +stb_vorbis; Android Clang does not need that definition. + +## Compatibility boundaries + +- Android API 21+; SDL2/Mixer X/SoundTouch builds cover all four export ABIs. +- The codec profile matches Windows' LGPL/non-GPL selection: built-in WAV/AIFF, + MP3, Ogg Vorbis, FLAC, QOA and PXTone; static AudioCodecs adds Opus, WavPack, + ModPlug/libXMP trackers, GME, EDMIDI and FluidLite alongside Timidity MIDI. + Windows-only native MIDI is disabled. Soundfonts/instrument data are not bundled. + The builder stages SDL headers/library, builds AudioCodecs, then links Mixer X. + For API-21 32-bit targets, WavPack's FILE seek probe and Opusfile's generated + stdio configuration avoid API-24-only large-file stdio; Mixer X uses their + 64-bit SDL RW callback interfaces. The original source archives stay untouched. +- Raw desktop pointer expressions 6 and 8 consume parameters and return an empty + string/zero. Handled-memory expression 7 works through bounded, versioned opaque + memory names; it never truncates an address into a Fusion integer. +- Negative/oversized channel IDs and invalid memory ranges are rejected. Channels + are bounded to 0..4095. Failed exclusive opens stop/fade the prior stream, as + Windows does. +- Two Windows condition mistakes are corrected here: All Channels Paused tests + all active streams (true when none are active); mixing-channel No Output tests + playing state and pause state separately. Stored channel effects/master volume + apply to newly opened streams. These intentional differences are regression tested. +- The master volume remains scoped to this extension's managed streams. It does + not silently become an application-wide gain for other Mixer X clients. +- File opening, decryption and cache loading are synchronous. SoundTouch pitch + adds processing latency; a short pitched stream's DSP tail is not drained after + the underlying Mixer X stream ends. Device hearing/latency quality needs separate + physical-device validation. + +## Build and publish + +Python 3.12+, NDK r28c and CMake 3.22+ are required. The dependency build is CMake +only and works with the appropriate host NDK; Windows uses Ninja from SDK discovery. +All archives are pinned and SHA-256 checked before extraction. + +```sh +python3 Extensions/_Script/SDL_Mixer.py +# Explicit tools and reusable dependency/build caches: +python3 Extensions/_Script/SDL_Mixer.py \ + --ndk /path/to/android-ndk-r28c --cmake /path/to/cmake \ + --sources /path/to/source-cache --build-root /path/to/native-builds +``` + +SDK discovery follows FTW (`ANDROID_HOME` / `ANDROID_SDK_ROOT`). The publisher emits +`SDL_Mixer/ToInstall/Files/Data/Runtime/Android/SDL_Mixer.zip`. `--abi` selects a +subset for development; omit it for release. The ZIP contains seven owned Java +sources, the matching SDL Java bindings, five native libraries per ABI, JNI/R8 +rules, dependency archives, correction/build recipes and license notices. +It checks ELF machines, dynamic dependency closure and 64-bit 16 KB ELF alignment. +NDK 32-bit libraries retain their supported 4 KB alignment. Failed publishing +leaves the previous ZIP intact. Fusion runtime classes, manifests and Gradle +application templates are not replaced. + +On Windows, `Extensions/_Script/SDL_Mixer_Android.bat` is the Android entry point. +`Run_Only Unicode` invokes it after the existing Windows build. Set +`/p:SDLMixerPackageAndroid=false` to build the Windows runtime alone. The existing +`SDL_Mixer.bat` release-archive workflow is retained. + +For the repository's development Gradle application, prepare sources first: + +```sh +python3 Extensions/SDL_Mixer/Android/build_android.py --prepare-only --sources /path/to/source-cache +# Add to the normal application Gradle build: +# -POFE_SDL_MIXER_SOURCES=/path/to/source-cache +``` + +## Verification + +```sh +# Build the same sources for host playback/JNI checks: +python3 Extensions/SDL_Mixer/Android/build_android.py --host \ + --sources /path/to/source-cache --build-root /path/to/builds +python3 Extensions/Android_Gradle/tests/sdl_mixer/run.py \ + --build /path/to/builds/host --sdl-source /path/to/source-cache/SDL2-5d249570393f7a37e037abf22cd6012a4cc56a71 \ + --android-jar /path/to/android.jar +python3 Extensions/Android_Gradle/tests/sdl_mixer/compile_android.py --android-jar /path/to/android.jar +python3 Extensions/Android_Gradle/tests/object_helpers/run.py +``` + +The native PCM test captures actual Mixer X output and checks tempo duration, +preserved pitch, pause, seek reset and DSP tail under ASan/UBSan. +Host playback uses the real Mixer X decoders, effects and mixing callback with SDL's +dummy device; it does not establish Android output behavior. Tests cover six codecs, +crossfade cleanup, fractional seek/AB, user/app pause, shared client lifetime, cache +updates/readers, panning and SoundTouch output frequency. The production JNI methods +run under `-Xcheck:jni`; all 7/17/14 Fusion ACEs have dispatch/parameter tests. Set +`OFE_MIXER_SANITIZE=ON` on the host CMake build to instrument the owned C++ playback +checks with ASan/UBSan (disable LeakSanitizer under ptrace). + +`tests/sdl_mixer/device/build_probe.py` builds a separate ordinary-Activity APK with +the production Java/JNI bridge. Its worker-thread probe exercises platform JNI +setup, audio callbacks, shared playback, seek/AB, suspension, AES and memory views. +Its storage probe additionally compiles narrow CRun/CRunApp/CExtStorage fixtures +with the production global-storage class and JNI bridge, checking empty-frame +AB maintenance, retained AES source leases, cache/settings, restart, subapps and +EndApp cleanup. This exercises real Android scheduling and audio; Fusion lifecycle +transitions in that probe are fixture driven. +It does not include the licensed Fusion runtime or replace a full Fusion export test. + +Verified in the 2026-10-31 workspace: four-ABI Release builds and ELF/package checks; +real host Mixer X playback under ASan/UBSan; production JNI calls under `-Xcheck:jni`; +Java 8 Android compilation and all 7/17/14 ACE dispatch tests; unchanged Windows +object helpers with all 56 Android helper regressions passing. FFmpeg's existing +JNI/frame/audio tests also passed after extracting the common media-source helper. + +The standalone APK passed on the Android 6.0/API-23 x86_64 software emulator using +production defaults, an ordinary Activity and initialization on a worker without +an Android Looper. It verified callback advancement for WAV/MP3/Ogg/FLAC/Opus/WavPack, shared +clients, crossfade, fractional seek/AB, application and user pause, AES, Unicode +handled-memory input, and device reopening after the last direct client closed. +The native-storage probe also passed with real Android scheduling/JNI: empty-frame +AB maintenance and AES leases, retained cache/settings, restart reset, independent +subapps, per-application suspension and EndApp cleanup without frame objects. No host +sound was emitted by that headless emulator. Physical-device audio quality, +Bluetooth/route changes, modern audio-focus restrictions, Windows MSBuild and a +full licensed Fusion Runtime APK have not been validated here. + +The combined probe also verifies readable paths/file URIs, APK asset forms, +ContentResolver input, HTTP and Fusion embedded-file opener fixtures, with AES. +Real FFmpeg output shares the Mixer X device; GLES checks exercise software uploads +and the native MediaCodec Surface/OES path. See the combined guide for build/install +commands, native storage lifetime and Android file grants. + +## Shared C++ encryption update (2026-11-04) + +Media decryption now calls the original C++ `Encryption` core through the thin +`MediaCrypto` JNI binding. Java keeps file/materialization and application lease +ownership. Both plugins ship the same `libofe_crypto.so`; rebuild/install both +export ZIPs together. Its static OpenSSL dependency follows Windows' +`EncryptVersion.cmake` (3.6.5), with an ABI/API-checked Android SDK at +`OFE_ENCRYPT_ROOT`. Automatic source deployment uses the matching host NDK under +Linux/WSL. See the [shared encryption build and validation notes](../../Android_Gradle/Media-JNI.md#shared-encryption-and-deferred-encrypter-runtime). +The standalone Encrypter Fusion extension is still deferred. diff --git a/Extensions/SDL_Mixer/Android/build_android.py b/Extensions/SDL_Mixer/Android/build_android.py new file mode 100644 index 000000000..58c90276a --- /dev/null +++ b/Extensions/SDL_Mixer/Android/build_android.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Prepare pinned SDL2/Mixer X/SoundTouch sources and compile the JNI package's ABIs.""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import urllib.request + +PORT=Path(__file__).resolve().parent +ABIS=('arm64-v8a','armeabi-v7a','x86','x86_64') + +def run(command): + print('+',subprocess.list2cmdline(list(map(str,command))),flush=True) + subprocess.run(list(map(str,command)),check=True) + +def extract_portable(tar, destination): + # Windows commonly lacks symlink privilege. Materialize checked source links + # as files/directories, while leaving the pinned original archive untouched. + links=[] + def member_filter(member, path): + checked=tarfile.data_filter(member,path) + if checked is not None and (checked.issym() or checked.islnk()): + links.append(checked);return None + return checked + tar.extractall(destination,filter=member_filter) + for member in links: + output=destination/member.name + target=(destination/member.linkname) if member.islnk() else (output.parent/member.linkname) + if target.is_file(): output.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(target,output) + elif target.is_dir():shutil.copytree(target,output,dirs_exist_ok=True) + # A dangling SoundTouch example link names a not-yet-built desktop DLL; + # it is not an input to the native core build. + +def prepare(cache: Path): + if sys.version_info < (3,12): raise RuntimeError('Source preparation requires Python 3.12+') + manifest=json.loads((PORT/'dependencies.json').read_text()) + sources={};cache.mkdir(parents=True,exist_ok=True) + for name,item in manifest.items(): + archive=cache/(name+'-'+item['revision']+'.tar.gz') + if not archive.is_file() or hashlib.sha256(archive.read_bytes()).hexdigest()!=item['sha256']: + partial=archive.with_suffix('.part') + url='https://codeload.github.com/'+item['repository']+'/tar.gz/'+item['revision'] + with urllib.request.urlopen(url,timeout=120) as response,partial.open('wb') as out: shutil.copyfileobj(response,out) + if hashlib.sha256(partial.read_bytes()).hexdigest()!=item['sha256']: partial.unlink();raise ValueError(name+' source hash mismatch') + partial.replace(archive) + destination=cache/(name+'-'+item['revision']) + if not destination.is_dir(): + temporary=cache/(name+'-extract') + if temporary.exists(): shutil.rmtree(temporary) + temporary.mkdir() + with tarfile.open(archive) as tar: extract_portable(tar,temporary) + roots=list(temporary.iterdir()) + if len(roots)!=1 or not (roots[0]/'CMakeLists.txt').is_file(): raise ValueError('Invalid source archive: '+name) + roots[0].replace(destination);temporary.rmdir() + sources[name]=destination + return sources + +def build(sources, build_root: Path, cmake: Path, ndk: Path | None, abis, jobs=4, tests=False, ninja=None, codecs=True): + builds={} + if ninja is None and os.name=='nt' and cmake.with_name('ninja.exe').is_file(): ninja=cmake.with_name('ninja.exe') + for abi in abis: + directory=build_root/abi + command=[cmake,'-S',PORT/'cpp','-B',directory,'-DCMAKE_BUILD_TYPE=Release', + '-DOFE_SDL_SOURCE='+str(sources['SDL2']),'-DOFE_MIXERX_SOURCE='+str(sources['MixerX']), + '-DOFE_SOUNDTOUCH_SOURCE='+str(sources['SoundTouch'])] + if os.environ.get('OFE_ENCRYPT_ROOT'): + command += ['-DOFE_ENCRYPT_ROOT='+str(Path(os.environ['OFE_ENCRYPT_ROOT']).resolve())] + if ninja: + command += ['-G','Ninja','-DCMAKE_MAKE_PROGRAM='+str(ninja)] + elif os.name=='nt': + command += ['-G','Ninja'] + if ndk: + command += ['-DCMAKE_TOOLCHAIN_FILE='+str(ndk/'build/cmake/android.toolchain.cmake'), + '-DANDROID_ABI='+abi,'-DANDROID_PLATFORM=android-21','-DANDROID_STL=c++_shared', + '-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON'] + elif tests: command += ['-DOFE_MIXER_TESTS=ON'] + if codecs: + # Build SDL first, then the same static AudioCodecs revision/profile as + # Windows, and finally Mixer X. Reuse the SDL target; no second SDL copy. + stage=directory/'codecs-sdk' + ready=all((stage/'lib'/name).exists() for name in ['libopusfile.a','libwavpack.a','libxmp.a']) + bootstrap=['-DOFE_AUDIOCODECS_ROOT='+str(stage),'-DOFE_AUDIOCODECS_SOURCE='+str(sources['AudioCodecs'])] if ready else ['-DOFE_AUDIOCODECS_ROOT='] + run(command+bootstrap) + run([cmake,'--build',directory,'--target','SDL2','--parallel',str(jobs)]) + include=stage/'include/SDL2';include.mkdir(parents=True,exist_ok=True) + for header in (sources['SDL2']/'include').glob('*.h'):shutil.copy2(header,include/header.name) + config=directory/'sdl/include-config-release/SDL2/SDL_config.h' + if not config.exists():config=directory/'sdl/include-config-Release/SDL2/SDL_config.h' + shutil.copy2(config,include/'SDL_config.h') + (stage/'lib').mkdir(exist_ok=True) + sdl_library=directory/'lib/libSDL2.so' + if not sdl_library.exists(): + sdl_library=next((directory/'lib'/name for name in ['libSDL2-2.0.so','libSDL2.dylib','libSDL2-2.0.dylib'] if (directory/'lib'/name).exists()),sdl_library) + shutil.copy2(sdl_library,stage/'lib'/('libSDL2.dylib' if sdl_library.suffix=='.dylib' else 'libSDL2.so')) + codec_command=[cmake,'-S',sources['AudioCodecs'],'-B',directory/'codecs-build', + '-DCMAKE_BUILD_TYPE=Release','-DCMAKE_INSTALL_PREFIX='+str(stage), + '-DCMAKE_POLICY_VERSION_MINIMUM=3.5','-DCMAKE_POSITION_INDEPENDENT_CODE=ON', + '-DSDL2_REPO_PATH='+str(stage),'-DSDL2_INSTALL_DIR='+str(stage), + '-DDOWNLOAD_SDL2_DEPENDENCY=OFF','-DUSE_LOCAL_SDL2=OFF', + '-DCODECS_BUILD_SHARED=OFF','-DCODECS_BUILD_STATIC=ON', + '-DBUILD_ADLMIDI=OFF','-DBUILD_OPNMIDI=OFF','-DBUILD_MPG123=OFF','-DBUILD_FLAC=OFF', + '-DGME_YM2612_EMU=GENS'] + if ninja:codec_command+=['-G','Ninja','-DCMAKE_MAKE_PROGRAM='+str(ninja)] + elif os.name=='nt':codec_command+=['-G','Ninja'] + if ndk and abi in ('armeabi-v7a','x86'):codec_command+=['-DHAVE_FSEEKO:BOOL=OFF'] + if ndk:codec_command+=['-DCMAKE_TOOLCHAIN_FILE='+str(ndk/'build/cmake/android.toolchain.cmake'), + '-DANDROID_ABI='+abi,'-DANDROID_PLATFORM=android-21','-DANDROID_STL=c++_shared'] + run(codec_command) + if ndk and abi in ('armeabi-v7a','x86'): + # WavPack's cached large-file probe leaks into Opusfile's config. + # API 21 LP32 has no 64-bit stdio entry points (introduced in 24). + # Mixer X uses Opusfile's 64-bit RW callbacks, not its FILE helper. + header=directory/'codecs-build/libopusfile/config/config.h' + header.write_text(header.read_text().replace('#define _FILE_OFFSET_BITS 64','#define _FILE_OFFSET_BITS 32').replace('/* #undef _FILE_OFFSET_BITS */','#define _FILE_OFFSET_BITS 32')) + run([cmake,'--build',directory/'codecs-build','--parallel',str(jobs)]) + run([cmake,'--install',directory/'codecs-build']) + command+=['-DOFE_AUDIOCODECS_ROOT='+str(stage),'-DOFE_AUDIOCODECS_SOURCE='+str(sources['AudioCodecs'])] + run(command);run([cmake,'--build',directory,'--parallel',str(jobs)]) + builds[abi]=directory + return builds + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--ndk',type=Path) + parser.add_argument('--cmake',type=Path,default=Path(shutil.which('cmake') or 'cmake')) + parser.add_argument('--sources',type=Path,default=PORT/'out/sources') + parser.add_argument('--build-root',type=Path,default=PORT/'out/build') + parser.add_argument('--abi',action='append',choices=ABIS) + parser.add_argument('--host',action='store_true') + parser.add_argument('--prepare-only',action='store_true') + parser.add_argument('--jobs',type=int,default=min(os.cpu_count() or 2,6)) + args=parser.parse_args() + if not args.host and not args.ndk and not args.prepare_only: parser.error('--ndk is required unless --host is selected') + sources=prepare(args.sources.resolve()) + if args.prepare_only: + print(json.dumps({name:str(path) for name,path in sources.items()},indent=2));sys.exit(0) + build(sources,args.build_root.resolve(),args.cmake.resolve(),None if args.host else args.ndk.resolve(), + ['host'] if args.host else args.abi or ABIS,args.jobs,args.host) diff --git a/Extensions/SDL_Mixer/Android/build_crypto_sdk.py b/Extensions/SDL_Mixer/Android/build_crypto_sdk.py new file mode 100644 index 000000000..05e6ad6ec --- /dev/null +++ b/Extensions/SDL_Mixer/Android/build_crypto_sdk.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Build the Windows-selected OpenSSL release for Android, with source verification.""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import urllib.request + +VERSION_FILE = Path(__file__).resolve().parents[2]/'_3rdLib/Encrypt-build/EncryptVersion.cmake' +if not VERSION_FILE.is_file(): VERSION_FILE=Path(__file__).with_name('EncryptVersion.cmake') +def settings(path=VERSION_FILE): + text=path.read_text() + def value(name): + found=re.search(r'^set\('+name+r'\s+"([^"\n]+)"\)',text,re.M) + if not found: raise ValueError('Missing '+name+' in '+str(path)) + return found.group(1) + version,commit=value('OFE_ENCRYPT_OPENSSL_VERSION'),value('OFE_ENCRYPT_VCPKG_COMMIT') + if not re.fullmatch(r'\d+(?:\.\d+)+',version) or not re.fullmatch(r'[0-9a-f]{40}',commit):raise ValueError('Invalid OpenSSL version pin') + return version,commit +VERSION,COMMIT=settings() +ABIS={'arm64-v8a':'android-arm64','armeabi-v7a':'android-arm','x86':'android-x86','x86_64':'android-x86_64'} + +def matches(root,abis,api=21): + try: + data=json.loads((root/'manifest.json').read_text()) + return data['version']==VERSION and data['vcpkg_commit']==COMMIT and data['api']==api and all( + abi in data['abis'] and (root/abi/'lib/libcrypto.a').is_file() + and (root/abi/'include/openssl/evp.h').is_file() for abi in abis) + except (OSError,KeyError,ValueError,TypeError):return False + +def download(url,path): + temporary=path.with_suffix(path.suffix+'.part') + with urllib.request.urlopen(url,timeout=120) as response,temporary.open('wb') as output:shutil.copyfileobj(response,output) + temporary.replace(path) + +def prepare(cache): + cache.mkdir(parents=True,exist_ok=True) + port=cache/('openssl-port-'+COMMIT+'.cmake') + if not port.exists():download('https://raw.githubusercontent.com/microsoft/vcpkg/'+COMMIT+'/ports/openssl/portfile.cmake',port) + text=port.read_text() + found=re.search(r'vcpkg_from_github\([\s\S]*?REPO\s+openssl/openssl[\s\S]*?SHA512\s+([0-9a-f]{128})',text) + if not found:raise ValueError('Pinned OpenSSL port has no source SHA512') + sha=found.group(1) + archive=cache/('openssl-'+VERSION+'.tar.gz') + if not archive.exists() or hashlib.sha512(archive.read_bytes()).hexdigest()!=sha: + download('https://github.com/openssl/openssl/archive/refs/tags/openssl-'+VERSION+'.tar.gz',archive) + if hashlib.sha512(archive.read_bytes()).hexdigest()!=sha:raise ValueError('OpenSSL source SHA512 mismatch') + source=cache/('openssl-openssl-'+VERSION) + if not source.exists(): + with tarfile.open(archive) as tar:tar.extractall(cache,filter='data') + if not (source/'Configure').is_file():raise ValueError('Missing verified OpenSSL Configure') + return source,archive,sha,port + +def build(ndk,output,cache,abis,api,jobs): + if os.name=='nt':raise RuntimeError('Prepare Android OpenSSL under Linux/WSL using its Linux NDK, then use that SDK with the Windows publisher') + source,archive,sha,port=prepare(cache) + toolchain=next((ndk/'toolchains/llvm/prebuilt').iterdir())/'bin' + environment=dict(os.environ,ANDROID_NDK_ROOT=str(ndk),PATH=str(toolchain)+os.pathsep+os.environ['PATH']) + previous=[] + if matches(output,[],api):previous=json.loads((output/'manifest.json').read_text())['abis'] + for abi in abis: + prefix=output/abi + if matches(output,[abi],api):continue + work=cache/('build-'+abi);work.mkdir(exist_ok=True) + # Out-of-source OpenSSL builds keep the verified source immutable. + command=['perl',source/'Configure',ABIS[abi],'-D__ANDROID_API__='+str(api), + 'no-shared','no-module','no-tests','no-apps','no-docs','no-engine','no-legacy','-fPIC', + '--prefix='+str(prefix),'--openssldir='+str(prefix/'ssl')] + subprocess.run(list(map(str,command)),cwd=work,env=environment,check=True) + subprocess.run(['make','-j'+str(jobs),'build_libs'],cwd=work,env=environment,check=True) + subprocess.run(['make','install_dev'],cwd=work,env=environment,check=True) + previous.append(abi) + output.mkdir(parents=True,exist_ok=True) + shutil.copy2(archive,output/archive.name);shutil.copy2(port,output/port.name) + shutil.copy2(source/'LICENSE.txt',output/'LICENSE.txt') + data=dict(version=VERSION,vcpkg_commit=COMMIT,source_sha512=sha,api=api,abis=sorted(set(previous))) + (output/'manifest.json').write_text(json.dumps(data,indent=2)+'\n') + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--ndk',type=Path,required=True);parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--cache',type=Path);parser.add_argument('--abi',choices=ABIS,action='append') + parser.add_argument('--api',type=int,default=21);parser.add_argument('--jobs',type=int,default=4) + args=parser.parse_args();output=args.output.resolve() + build(args.ndk.resolve(),output,(args.cache or output/'.build').resolve(),args.abi or list(ABIS),args.api,args.jobs) diff --git a/Extensions/SDL_Mixer/Android/cpp/AudioRuntime.cpp b/Extensions/SDL_Mixer/Android/cpp/AudioRuntime.cpp new file mode 100644 index 000000000..c7888478c --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/AudioRuntime.cpp @@ -0,0 +1,74 @@ +#include "AudioRuntime.h" +#include +#include +#include +#include +#include +#ifdef __ANDROID__ +#include +#endif +namespace { +std::mutex mutex; +std::unordered_map clients; +uint64_t serial = 1; +bool ownsMixer = false, ownsAudio = false, ownsTimer = false, focused = true; +uint64_t failAcquire() { + const std::string error = SDL_GetError(); + if (ownsMixer && Mix_QuerySpec(nullptr,nullptr,nullptr)) Mix_CloseAudio(); + if (ownsAudio) SDL_QuitSubSystem(SDL_INIT_AUDIO); + if (ownsTimer) SDL_QuitSubSystem(SDL_INIT_TIMER); + ownsMixer = ownsAudio = ownsTimer = false; + SDL_SetError("%s", error.c_str()); return 0; +} +void updatePause() { + bool suspended = !clients.empty(); + for (const auto& client : clients) suspended &= client.second; + if (!clients.empty()) Mix_PauseAudio(!focused || suspended); +} +} +extern "C" unsigned OFEAudio_ABIVersion() { return 2; } +extern "C" uint64_t OFEAudio_Acquire() { + std::lock_guard lock(mutex); + if (clients.empty()) { + SDL_SetMainReady(); +#ifdef __ANDROID__ + // The stock JNI-backed driver works with Fusion's Activity on API 21+. + // Explicit caller/environment hints retain priority over this default. + SDL_SetHintWithPriority(SDL_HINT_AUDIODRIVER, "android", SDL_HINT_DEFAULT); +#endif + ownsAudio = !SDL_WasInit(SDL_INIT_AUDIO); ownsTimer = !SDL_WasInit(SDL_INIT_TIMER); + Uint32 flags = (ownsAudio ? SDL_INIT_AUDIO : 0) | (ownsTimer ? SDL_INIT_TIMER : 0); + if (flags && SDL_InitSubSystem(flags) < 0) return failAcquire(); + ownsMixer = Mix_QuerySpec(nullptr, nullptr, nullptr) == 0; + if (ownsMixer && Mix_OpenAudioDevice(44100, AUDIO_S16SYS, 2, 1024, nullptr, SDL_AUDIO_ALLOW_FREQUENCY_CHANGE) < 0) { + return failAcquire(); + } + int frequency, channels; Uint16 format; + Mix_QuerySpec(&frequency, &format, &channels); + if (format != AUDIO_S16SYS || channels != 2) { + SDL_SetError("OFE audio requires signed 16-bit stereo Mixer X output"); + return failAcquire(); + } + } + auto id = serial++; clients.emplace(id, false); updatePause(); return id; +} +extern "C" void OFEAudio_Release(uint64_t id) { + std::lock_guard lock(mutex); + if (!clients.erase(id)) return; + if (clients.empty()) { + if (ownsMixer) Mix_CloseAudio(); + if (ownsAudio) SDL_QuitSubSystem(SDL_INIT_AUDIO); + if (ownsTimer) SDL_QuitSubSystem(SDL_INIT_TIMER); + ownsMixer = ownsAudio = ownsTimer = false; + } else updatePause(); +} +extern "C" void OFEAudio_SetSuspended(uint64_t id, int suspended) { + std::lock_guard lock(mutex); + auto it = clients.find(id); if (it != clients.end()) it->second = suspended != 0; + updatePause(); +} +extern "C" void OFEAudio_SetFocus(int value) { std::lock_guard lock(mutex); focused = value != 0; updatePause(); } +extern "C" int OFEAudio_GetSpec(int* frequency, uint16_t* format, int* channels) { return Mix_QuerySpec(frequency, format, channels); } +#ifdef __ANDROID__ +extern "C" JNIEXPORT void JNICALL Java__1DeLib_SDLAudioRuntime_nFocus(JNIEnv*, jclass, jboolean focus) { OFEAudio_SetFocus(focus); } +#endif diff --git a/Extensions/SDL_Mixer/Android/cpp/AudioRuntime.h b/Extensions/SDL_Mixer/Android/cpp/AudioRuntime.h new file mode 100644 index 000000000..768cdd123 --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/AudioRuntime.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +// One implementation in libofe_audio.so; all native plugins dynamically depend on it. +uint64_t OFEAudio_Acquire(void); +void OFEAudio_Release(uint64_t client); +void OFEAudio_SetSuspended(uint64_t client, int suspended); +void OFEAudio_SetFocus(int focused); +int OFEAudio_GetSpec(int* frequency, uint16_t* format, int* channels); +unsigned OFEAudio_ABIVersion(void); +// Native-only owned PCM stream. Device acquisition outlives the stream. +// Control/write methods may throw C++ exceptions, caught at the JNI boundary. +void* OFEAudio_CreatePCM(void); +// Pull mode lets existing C++ engines supply their PCM directly. Callback output is +// S16 stereo at 44100 Hz; the transport only converts to the actual device rate. +// No decoder, media clock or tempo policy is installed in this transport. +typedef void (*OFEAudio_PCMCallback)(void* user, void* bytes, int count); +void* OFEAudio_CreatePullPCM(OFEAudio_PCMCallback callback, void* user); +void OFEAudio_DestroyPCM(void* stream); +void OFEAudio_PCMWrite(void* stream, const void* bytes, size_t count, double timestamp_ms); +void OFEAudio_PCMFlush(void* stream); +void OFEAudio_PCMReset(void* stream, double position_ms); +void OFEAudio_PCMState(void* stream, int running, float volume, double tempo); +double OFEAudio_PCMClock(void* stream); +size_t OFEAudio_PCMPending(void* stream); +#ifdef __cplusplus +} +#endif diff --git a/Extensions/SDL_Mixer/Android/cpp/CMakeLists.txt b/Extensions/SDL_Mixer/Android/cpp/CMakeLists.txt new file mode 100644 index 000000000..716621dba --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/CMakeLists.txt @@ -0,0 +1,125 @@ +cmake_minimum_required(VERSION 3.22) +project(OFESDLMixerAndroid LANGUAGES C CXX) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +add_subdirectory(crypto) +foreach(dependency SDL MIXERX SOUNDTOUCH) + if(NOT EXISTS "${OFE_${dependency}_SOURCE}/CMakeLists.txt") + message(FATAL_ERROR "Set OFE_${dependency}_SOURCE to the pinned upstream source directory") + endif() +endforeach() +set(SDL_SHARED ON CACHE BOOL "" FORCE) +set(SDL_STATIC OFF CACHE BOOL "" FORCE) +set(SDL_TEST OFF CACHE BOOL "" FORCE) +set(SDL_TESTS OFF CACHE BOOL "" FORCE) +set(SDL2_DISABLE_INSTALL ON CACHE BOOL "" FORCE) +# Keep the stock Android JNI dependencies compiled; only AUDIO|TIMER are initialized. +if(NOT ANDROID) + set(SDL_VIDEO OFF CACHE BOOL "" FORCE) + set(SDL_RENDER OFF CACHE BOOL "" FORCE) +endif() +add_subdirectory("${OFE_SDL_SOURCE}" sdl) +# Mixer X expects find_package(SDL2); both projects are built together without installation. +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/sdl-find") +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/sdl-find/SDL2Config.cmake" "set(SDL2_FOUND TRUE)\nset(SDL2_LIBRARIES SDL2::SDL2)\n") +set(SDL2_DIR "${CMAKE_CURRENT_BINARY_DIR}/sdl-find" CACHE PATH "" FORCE) +set(USE_SYSTEM_SDL2 ON CACHE BOOL "" FORCE) +set(SDL_MIXER_X_SHARED ON CACHE BOOL "" FORCE) +set(SDL_MIXER_X_STATIC OFF CACHE BOOL "" FORCE) +set(MIXERX_DISABLE_SO_VERSION ON CACHE BOOL "" FORCE) +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +# Match Windows' LGPL/non-GPL codec selection. Built-in-only bootstrap builds +# SDL before the static AudioCodecs stage is available. +set(OFE_AUDIOCODECS_ROOT "" CACHE PATH "Prepared static AudioCodecs/SDL SDK") +foreach(feature OPUS WAVPACK MODPLUG XMP GME MIDI_EDMIDI MIDI_FLUIDLITE) + if(OFE_AUDIOCODECS_ROOT) + set(USE_${feature} ON CACHE BOOL "" FORCE) + else() + set(USE_${feature} OFF CACHE BOOL "" FORCE) + endif() +endforeach() +foreach(feature MP3_MPG123 FLAC FFMPEG CMD MIDI_ADLMIDI MIDI_OPNMIDI MIDI_FLUIDSYNTH MIDI_NATIVE) + set(USE_${feature} OFF CACHE BOOL "" FORCE) +endforeach() +set(AUDIO_CODECS_INSTALL_PATH "${OFE_AUDIOCODECS_ROOT}" CACHE PATH "" FORCE) +set(AUDIO_CODECS_REPO_PATH "${OFE_AUDIOCODECS_SOURCE}" CACHE PATH "" FORCE) +if(NOT OFE_AUDIOCODECS_ROOT) + set(AUDIO_CODECS_REPO_PATH "" CACHE PATH "" FORCE) +endif() +set(MIXERX_ENABLE_LGPL ON CACHE BOOL "" FORCE) +set(MIXERX_ENABLE_GPL OFF CACHE BOOL "" FORCE) +set(DOWNLOAD_AUDIO_CODECS_DEPENDENCY OFF CACHE BOOL "" FORCE) +set(USE_MIDI ON CACHE BOOL "" FORCE) +set(USE_MIDI_TIMIDITY ON CACHE BOOL "" FORCE) +add_subdirectory("${OFE_MIXERX_SOURCE}" mixer) +include("${CMAKE_CURRENT_LIST_DIR}/MixerXFixes.cmake") +if(OFE_AUDIOCODECS_ROOT) + target_compile_definitions(SDL2_mixer_ext PRIVATE "MODPLUG_HEADER=\"${OFE_AUDIOCODECS_ROOT}/include/libmodplug/modplug.h\"") +endif() +# SDL2's Android install exports its target twice. We ship binaries directly, so +# link its build output without exporting an SDL application/SDL2main dependency. +get_target_property(mixer_links SDL2_mixer_ext LINK_LIBRARIES) +set(build_links) +foreach(dependency IN LISTS mixer_links) + if(dependency STREQUAL "SDL2::SDL2main") + continue() + elseif(dependency STREQUAL "SDL2::SDL2" OR dependency STREQUAL "SDL2") + list(APPEND build_links "$") + else() + list(APPEND build_links "${dependency}") + endif() +endforeach() +set_property(TARGET SDL2_mixer_ext PROPERTY LINK_LIBRARIES "${build_links}") +add_dependencies(SDL2_mixer_ext SDL2) +if(NOT ANDROID AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_definitions(SDL2_mixer_ext PRIVATE alloca=__builtin_alloca) +endif() +target_include_directories(SDL2_mixer_ext PRIVATE "${OFE_SDL_SOURCE}/include" "${CMAKE_CURRENT_BINARY_DIR}/sdl/include-config-$") +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(SOUNDSTRETCH OFF CACHE BOOL "" FORCE) +set(SOUNDTOUCH_DLL OFF CACHE BOOL "" FORCE) +set(OPENMP OFF CACHE BOOL "" FORCE) +add_subdirectory("${OFE_SOUNDTOUCH_SOURCE}" soundtouch) +if(ANDROID AND ANDROID_ABI STREQUAL "x86_64") + # The API-23 software emulator faults on Clang's packed float-to-double load + # when the small coefficient allocation ends at a page boundary. Keep the + # explicit SSE audio kernels; only scalarize coefficient setup in this file. + set_source_files_properties("${OFE_SOUNDTOUCH_SOURCE}/source/SoundTouch/FIRFilter.cpp" + TARGET_DIRECTORY SoundTouch PROPERTIES COMPILE_OPTIONS "-fno-vectorize;-fno-slp-vectorize") +endif() +add_library(ofe_audio SHARED AudioRuntime.cpp PCMStream.cpp) +target_compile_features(ofe_audio PUBLIC cxx_std_17) +target_include_directories(ofe_audio PUBLIC "${CMAKE_CURRENT_LIST_DIR}") +target_link_libraries(ofe_audio PUBLIC SDL2 SDL2_mixer_ext PRIVATE SoundTouch) +add_library(ofe_sdl_mixer SHARED Mixer.cpp JNI.cpp) +target_compile_features(ofe_sdl_mixer PRIVATE cxx_std_17) +target_compile_options(ofe_sdl_mixer PRIVATE -Wall -Wextra -Wpedantic) +target_link_libraries(ofe_sdl_mixer PRIVATE ofe_audio SoundTouch) +foreach(target SDL2 SDL2_mixer_ext ofe_audio ofe_sdl_mixer) + set_target_properties(${target} PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") +endforeach() +if(ANDROID) + foreach(target SDL2 SDL2_mixer_ext ofe_audio ofe_sdl_mixer) + target_link_options(${target} PRIVATE "-Wl,-z,max-page-size=16384") + endforeach() +else() + find_path(OFE_JNI_INCLUDE jni.h HINTS "$ENV{JAVA_HOME}/include" REQUIRED) + find_path(OFE_JNI_PLATFORM jni_md.h HINTS "$ENV{JAVA_HOME}/include/linux" "$ENV{JAVA_HOME}/include/darwin" REQUIRED) + target_include_directories(ofe_sdl_mixer PRIVATE "${OFE_JNI_INCLUDE}" "${OFE_JNI_PLATFORM}") + if(OFE_MIXER_TESTS) + enable_testing() + add_executable(ofe_mixer_test "${CMAKE_CURRENT_LIST_DIR}/../../../Android_Gradle/tests/sdl_mixer/MixerTest.cpp" Mixer.cpp) + target_compile_features(ofe_mixer_test PRIVATE cxx_std_17) + target_include_directories(ofe_mixer_test PRIVATE "${CMAKE_CURRENT_LIST_DIR}") + target_link_libraries(ofe_mixer_test PRIVATE ofe_audio SoundTouch) + add_executable(ofe_pcm_test "${CMAKE_CURRENT_LIST_DIR}/../../../Android_Gradle/tests/sdl_mixer/PCMTest.cpp" AudioRuntime.cpp PCMStream.cpp) + target_compile_features(ofe_pcm_test PRIVATE cxx_std_17) + target_include_directories(ofe_pcm_test PRIVATE "${CMAKE_CURRENT_LIST_DIR}") + target_link_libraries(ofe_pcm_test PRIVATE SDL2 SDL2_mixer_ext SoundTouch) + if(OFE_MIXER_SANITIZE) + target_compile_options(ofe_pcm_test PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(ofe_pcm_test PRIVATE -fsanitize=address,undefined) + target_compile_options(ofe_mixer_test PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(ofe_mixer_test PRIVATE -fsanitize=address,undefined) + endif() + endif() +endif() diff --git a/Extensions/SDL_Mixer/Android/cpp/JNI.cpp b/Extensions/SDL_Mixer/Android/cpp/JNI.cpp new file mode 100644 index 000000000..6f79c870b --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/JNI.cpp @@ -0,0 +1,107 @@ +#include "Mixer.h" +#include +#include +#include +#include +#include +#include +namespace { +std::mutex mutex; +std::weak_ptr sharedMixer; +struct Client { uint64_t token; std::shared_ptr engine; bool isolated; }; +std::unordered_map clients; +jlong serial = 1; +std::string string(JNIEnv* env, jstring value) { + if (!value) return {}; + auto data = env->GetStringChars(value, nullptr); if (!data) throw std::bad_alloc(); + std::u16string text(reinterpret_cast(data), env->GetStringLength(value)); + env->ReleaseStringChars(value, data); + if (text.find(u'\0') != std::u16string::npos) throw std::invalid_argument("NUL in audio string"); + return std::wstring_convert, char16_t>{}.to_bytes(text); +} +jstring string(JNIEnv* env, const std::string& value) { + auto text = std::wstring_convert, char16_t>{}.from_bytes(value); + return env->NewString(reinterpret_cast(text.data()), static_cast(text.size())); +} +void fail(JNIEnv* env, const std::exception& error) { + if (!env->ExceptionCheck()) env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what()); +} +ofe::audio::Mixer& get(jlong id) { + auto it = clients.find(id); + if (it == clients.end()) throw std::runtime_error("SDL Mixer client is closed"); + return *it->second.engine; +} +} +#define JNI_NAME(name) Java__1DeLib_SDLMixer_##name +namespace { +jlong create(JNIEnv* env, bool isolated) { + try { + std::lock_guard lock(mutex); + auto token = OFEAudio_Acquire(); if (!token) throw std::runtime_error(SDL_GetError()); + try { + auto engine = isolated ? nullptr : sharedMixer.lock(); + if (!engine) engine = std::make_shared(); + if (!isolated) sharedMixer = engine; + auto id = serial++; clients.emplace(id, Client{token, std::move(engine), isolated}); return id; + } catch (...) { OFEAudio_Release(token); throw; } + } catch (const std::exception& error) { fail(env, error); return 0; } +} +} +extern "C" JNIEXPORT jlong JNICALL JNI_NAME(nCreate)(JNIEnv* env, jclass) { return create(env, false); } +extern "C" JNIEXPORT jlong JNICALL JNI_NAME(nCreateIsolated)(JNIEnv* env, jclass) { return create(env, true); } +extern "C" JNIEXPORT void JNICALL JNI_NAME(nDestroy)(JNIEnv* env, jclass, jlong id) { + try { std::lock_guard lock(mutex); auto it = clients.find(id); if (it == clients.end()) return; + auto token = it->second.token; clients.erase(it); OFEAudio_Release(token); + } catch (const std::exception& error) { fail(env, error); } +} +extern "C" JNIEXPORT jlong JNICALL JNI_NAME(nPlay)(JNIEnv* env, jclass, jlong id, jstring name, jstring path, + jint channel, jboolean exclusive, jint loops, jint fade) { + try { std::lock_guard lock(mutex); return get(id).play(string(env, name), string(env, path), channel, exclusive, loops, fade); } + catch (const std::exception& error) { fail(env, error); return 0; } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nAction)(JNIEnv* env, jclass, jlong id, jint op, + jint channel, jboolean exclusive, jdouble a, jdouble b, jstring text) { + try { std::lock_guard lock(mutex); get(id).action(op, channel, exclusive, a, b, string(env, text)); } + catch (const std::exception& error) { fail(env, error); } +} +extern "C" JNIEXPORT jboolean JNICALL JNI_NAME(nCondition)(JNIEnv* env, jclass, jlong id, jint op, + jint channel, jboolean exclusive, jstring name) { + try { std::lock_guard lock(mutex); return get(id).condition(op, channel, exclusive, string(env, name)); } + catch (const std::exception& error) { fail(env, error); return false; } +} +extern "C" JNIEXPORT jdouble JNICALL JNI_NAME(nNumber)(JNIEnv* env, jclass, jlong id, jint op, + jint channel, jboolean exclusive, jstring name) { + try { std::lock_guard lock(mutex); return get(id).number(op, channel, exclusive, string(env, name)); } + catch (const std::exception& error) { fail(env, error); return 0; } +} +extern "C" JNIEXPORT jstring JNICALL JNI_NAME(nName)(JNIEnv* env, jclass, jlong id, jint channel) { + try { std::lock_guard lock(mutex); return string(env, get(id).channelName(channel)); } + catch (const std::exception& error) { fail(env, error); return nullptr; } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nCache)(JNIEnv* env, jclass, jlong id, jstring name, jstring path, jboolean replace) { + try { std::lock_guard lock(mutex); get(id).cache(string(env, name), string(env, path), replace); } + catch (const std::exception& error) { fail(env, error); } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nErase)(JNIEnv* env, jclass, jlong id, jstring name) { + try { std::lock_guard lock(mutex); get(id).erase(string(env, name)); } + catch (const std::exception& error) { fail(env, error); } +} +extern "C" JNIEXPORT jstring JNICALL JNI_NAME(nMemoryName)(JNIEnv* env, jclass, jlong id, + jstring access, jstring binary, jlong offset, jlong size) { + try { std::lock_guard lock(mutex); return string(env, get(id).memoryName(string(env, access), string(env, binary), offset, size)); } + catch (const std::exception& error) { fail(env, error); return nullptr; } +} +extern "C" JNIEXPORT jlongArray JNICALL JNI_NAME(nUpdate)(JNIEnv* env, jclass, jlong id) { + try { + std::lock_guard lock(mutex); auto& engine = get(id); engine.update(); auto voices = engine.liveVoices(); + std::vector ids(voices.begin(), voices.end()); auto out = env->NewLongArray(static_cast(ids.size())); + if (out && !ids.empty()) env->SetLongArrayRegion(out, 0, static_cast(ids.size()), ids.data()); + return out; + } catch (const std::exception& error) { fail(env, error); return nullptr; } +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nSuspend)(JNIEnv* env, jclass, jlong id, jboolean suspend) { + try { std::lock_guard lock(mutex); auto& engine = get(id); const auto& client = clients.at(id); + if (client.isolated) engine.setSuspended(suspend); + OFEAudio_SetSuspended(client.token, suspend); } + catch (const std::exception& error) { fail(env, error); } +} diff --git a/Extensions/SDL_Mixer/Android/cpp/Mixer.cpp b/Extensions/SDL_Mixer/Android/cpp/Mixer.cpp new file mode 100644 index 000000000..c69691df3 --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/Mixer.cpp @@ -0,0 +1,208 @@ +#include "Mixer.h" +#include +#include +#include +#include +#include +#include +#include +#include +namespace ofe::audio { +namespace { +std::atomic epochSerial{1}; +void channelValid(int channel) { if (channel < 0 || channel > 4095) throw std::invalid_argument("Channel must be in 0..4095"); } +void checked(int result) { if (result < 0) throw std::runtime_error(Mix_GetError()); } +int volume(int value) { return std::clamp(value, 0, 100); } +} +Mixer::Mixer() : epoch(epochSerial.fetch_add(1, std::memory_order_relaxed)) {} +Voice::~Voice() { + if (music) { Mix_HaltMusicStream(music); Mix_FreeMusic(music); } + if (rw) SDL_RWclose(rw); +} +Mixer::~Mixer() { voices.clear(); } +Settings& Mixer::settings(int channel, bool excl) { channelValid(channel); return (excl ? exclusiveSettings : mixingSettings)[channel]; } +Voice* Mixer::current(int channel) { + auto it = exclusive.find(channel); if (it == exclusive.end()) return nullptr; + for (auto& voice : voices) if (voice->id == it->second) return voice.get(); + return nullptr; +} +void Mixer::apply(Voice& voice) { + const auto& state = settings(voice.channel, voice.exclusive); + Mix_VolumeMusicStream(voice.music, state.volume * master * MIX_MAX_VOLUME / 10000); + if (state.spatialMode == 1) { + if (!Mix_SetMusicEffectPanning(voice.music, state.left, state.right)) throw std::runtime_error(Mix_GetError()); + if (state.distance && !Mix_SetMusicEffectDistance(voice.music, state.distance)) throw std::runtime_error(Mix_GetError()); + } else if (state.spatialMode == 2 && !Mix_SetMusicEffectPosition(voice.music, state.angle, state.distance)) throw std::runtime_error(Mix_GetError()); + else if (state.distance && !Mix_SetMusicEffectDistance(voice.music, state.distance)) throw std::runtime_error(Mix_GetError()); +} +void SDLCALL Mixer::pitchCallback(Mix_Music*, void* buffer, int length, void* data) { + try { + auto& voice = *static_cast(data); + // Fixed scratch capacity; SoundTouch state is retained between callbacks and is private to this voice. + const int frames = length / (2 * sizeof(int16_t)); + if (frames <= 0 || static_cast(frames * 2) > voice.input.size()) return; + auto src = static_cast(buffer); + for (int i = 0; i < frames * 2; ++i) voice.input[i] = src[i] / 32768.f; + voice.pitch->putSamples(voice.input.data(), frames); + const auto count = voice.pitch->receiveSamples(voice.output.data(), frames); + std::memset(buffer, 0, length); + for (size_t i = 0; i < count * 2; ++i) src[i] = static_cast(std::clamp(voice.output[i] * 32768.f, -32768.f, 32767.f)); + } catch (...) { std::memset(buffer, 0, length); } +} +uint64_t Mixer::play(const std::string& name, const std::string& path, int channel, bool excl, int loops, int fade) { + try { return playPrepared(name, path, channel, excl, loops, fade); } + catch (...) { + if (excl && channel >= 0 && channel <= 4095) action(3, channel, true, fade); + throw; + } +} +uint64_t Mixer::playPrepared(const std::string& name, const std::string& path, int channel, bool excl, int loops, int fade) { + auto& state = settings(channel, excl); + auto voice = std::make_unique(); voice->name = name; voice->channel = channel; voice->exclusive = excl; + auto view = views.find(name); + if (view != views.end()) { + voice->bytes = view->second.bytes.lock(); + if (!voice->bytes) throw std::runtime_error("Binary memory view has expired"); + voice->name = view->second.access; voice->binary = view->second.binary; + voice->rw = SDL_RWFromConstMem(voice->bytes->data() + view->second.offset, static_cast(view->second.size)); + } else voice->rw = SDL_RWFromFile(path.c_str(), "rb"); + if (!voice->rw) throw std::runtime_error(SDL_GetError()); + voice->music = Mix_LoadMUS_RW(voice->rw, 0); + if (!voice->music) throw std::runtime_error(Mix_GetError()); + voice->id = serial++; + apply(*voice); + if (!excl) { + auto& record = records[voice->name]; auto now = std::chrono::steady_clock::now(); + if (now - record.time > std::chrono::seconds(1)) record.count = 0; + if (state.scoreEnabled && record.count && !state.score.empty()) { + voice->pitch = std::make_unique(); + int frequency, channels; uint16_t format; OFEAudio_GetSpec(&frequency, &format, &channels); + voice->pitch->setSampleRate(frequency); voice->pitch->setChannels(channels); + voice->pitch->setPitchSemiTones(state.score[record.count % state.score.size()] - state.base); + voice->input.resize(65536); voice->output.resize(65536); + if (!Mix_RegisterMusicEffect(voice->music, pitchCallback, nullptr, voice.get())) throw std::runtime_error(Mix_GetError()); + } + record.time = now; ++record.count; + } + auto old = excl ? current(channel) : nullptr; + if (old && Mix_PausedMusicStream(old->music)) { Mix_HaltMusicStream(old->music); old = nullptr; } + int repeat = loops <= 0 ? -1 : loops; + if (fade < 0) { if (old) Mix_HaltMusicStream(old->music); checked(Mix_PlayMusicStream(voice->music, repeat)); } + else if (old) { + // Pinned Mixer X's CrossFade helper combines FadeOut's 1-success and FadeIn's + // 0-success return values incorrectly. Use the same two primitives directly. + Mix_FadeOutMusicStream(old->music, fade); + checked(Mix_FadeInMusicStream(voice->music, repeat, fade)); + } + else checked(Mix_FadeInMusicStream(voice->music, repeat, fade)); + if (suspended) Mix_PauseMusicStream(voice->music); + auto id = voice->id; voices.push_back(std::move(voice)); + if (excl) exclusive[channel] = id; + return id; +} +void Mixer::setSuspended(bool value) { + suspended = value; + for (auto& voice : voices) { + if (suspended || voice->userPaused) Mix_PauseMusicStream(voice->music); + else Mix_ResumeMusicStream(voice->music); + } +} +void Mixer::update() { + for (auto& [channel, id] : exclusive) { + auto voice = current(channel); const auto& state = exclusiveSettings[channel]; + if (!voice || !state.ab || Mix_PausedMusicStream(voice->music)) continue; + double end = state.end == 0 ? Mix_MusicDuration(voice->music) : state.end; + if (end > state.start && Mix_GetMusicPosition(voice->music) >= end) checked(Mix_SetMusicPositionStream(voice->music, state.start)); + } + voices.erase(std::remove_if(voices.begin(), voices.end(), [](const auto& voice) { + return !Mix_PlayingMusicStream(voice->music); + }), voices.end()); + for (auto it = views.begin(); it != views.end();) { if (it->second.bytes.expired()) it = views.erase(it); else ++it; } +} +std::vector Mixer::liveVoices() const { std::vector ids; for (const auto& voice : voices) ids.push_back(voice->id); return ids; } +void Mixer::action(int op, int channel, bool excl, double a, double b, const std::string& text) { + if (!std::isfinite(a) || !std::isfinite(b)) throw std::invalid_argument("Invalid audio parameter"); + if (op == 13) { master = volume(static_cast(a)); for (auto& voice : voices) Mix_VolumeMusicStream(voice->music, settings(voice->channel, voice->exclusive).volume * master * MIX_MAX_VOLUME / 10000); return; } + if (op == 7) { for (auto& voice : voices) { if (a < 0 || Mix_PausedMusicStream(voice->music)) Mix_HaltMusicStream(voice->music); else Mix_FadeOutMusicStream(voice->music, static_cast(a)); } return; } + auto& state = settings(channel, excl); + if (op == 2) state.volume = volume(static_cast(a)); + if (op == 8) { if (a < 0 || b < 0 || (b != 0 && b <= a)) throw std::invalid_argument("Invalid AB loop bounds"); state.ab = true; state.start = a; state.end = b; return; } + if (op == 9) { + std::string normalized = text; for (auto& ch : normalized) if (ch == '{' || ch == '}' || ch == ',') ch = ' '; + std::istringstream input(normalized); std::vector notes; float note; + while (input >> note) { if (!std::isfinite(note)) throw std::invalid_argument("Invalid score"); notes.push_back(note); } + if (!input.eof()) throw std::invalid_argument("Invalid score note"); + if (!notes.empty()) state.score = std::move(notes); + else if (a != 0) throw std::invalid_argument("Score is empty"); + state.scoreEnabled = a != 0; state.base = b; return; + } + if (op == 14) { state.spatialMode = 1; state.left = static_cast(static_cast(a)); state.right = static_cast(static_cast(b)); } + if (op == 15) { state.spatialMode = 2; state.angle = static_cast(static_cast(a)); state.distance = static_cast(static_cast(b)); } + if (op == 16) state.distance = static_cast(static_cast(a)); + for (auto& voice : voices) { + if (voice->channel != channel || voice->exclusive != excl || (excl && current(channel) != voice.get())) continue; + switch (op) { + case 2: Mix_VolumeMusicStream(voice->music, state.volume * master * MIX_MAX_VOLUME / 10000); break; + case 3: if (a < 0 || Mix_PausedMusicStream(voice->music)) Mix_HaltMusicStream(voice->music); else Mix_FadeOutMusicStream(voice->music, static_cast(a)); break; + case 4: voice->userPaused = true; Mix_PauseMusicStream(voice->music); break; + case 5: voice->userPaused = false; if (!suspended) Mix_ResumeMusicStream(voice->music); break; + case 6: checked(Mix_SetMusicPositionStream(voice->music, std::max(0., a))); break; + case 14: if (!Mix_SetMusicEffectPanning(voice->music, state.left, state.right)) throw std::runtime_error(Mix_GetError()); break; + case 15: if (!Mix_SetMusicEffectPosition(voice->music, state.angle, state.distance)) throw std::runtime_error(Mix_GetError()); break; + case 16: if (!Mix_SetMusicEffectDistance(voice->music, state.distance)) throw std::runtime_error(Mix_GetError()); break; + default: break; + } + } +} +bool Mixer::condition(int op, int channel, bool excl, const std::string& name) { + if (op == 6) return unreferenced(name); + if (op != 1 && op != 4) channelValid(channel); + bool playing = false, paused = false, allPaused = true; + for (auto& voice : voices) { + bool active = Mix_PlayingMusicStream(voice->music) != 0; + bool pause = voice->userPaused; + if (op != 1 && op != 4 && (voice->channel != channel || voice->exclusive != excl || (excl && current(channel) != voice.get()))) continue; + playing |= active; paused |= active && pause; if (active && !pause) allPaused = false; + } + switch (op) { + case 0: return playing; case 1: return !playing; + case 2: { auto voice = current(channel); return !voice || Mix_FadingMusicStream(voice->music) == MIX_NO_FADING; } + case 3: return paused; case 4: return allPaused; case 5: return !playing || allPaused; + default: return false; + } +} +double Mixer::number(int op, int channel, bool excl, const std::string& name) { + if (op == 9) return master; + if (op == 4) { for (const auto& voice : voices) if (voice->exclusive == excl && voice->name == name && Mix_PlayingMusicStream(voice->music)) return voice->channel; return -1; } + auto& state = settings(channel, excl); + switch (op) { + case 0: return state.volume; case 1: return condition(0, channel, excl); + case 2: { auto voice = current(channel); return voice ? Mix_GetMusicPosition(voice->music) : -1; } + case 3: { auto voice = current(channel); return voice ? Mix_MusicDuration(voice->music) : -1; } + case 10: return state.left; case 11: return state.right; case 12: return state.angle; case 13: return state.distance; + default: return 0; + } +} +std::string Mixer::channelName(int channel) { channelValid(channel); auto voice = current(channel); return voice ? voice->name : ""; } +void Mixer::cache(const std::string& name, const std::string& path, bool replace) { + if (!replace && binaries.count(name)) return; + std::ifstream input(path, std::ios::binary | std::ios::ate); + if (!input) throw std::runtime_error("Cannot open audio binary"); + auto size = input.tellg(); if (size <= 0 || size > std::numeric_limits::max()) throw std::runtime_error("Invalid audio binary size"); + auto bytes = std::make_shared>(static_cast(size)); input.seekg(0); + if (!input.read(reinterpret_cast(bytes->data()), size)) throw std::runtime_error("Cannot read audio binary"); + binaries[name] = std::move(bytes); +} +bool Mixer::unreferenced(const std::string& name) const { if (name.empty()) return true; for (const auto& voice : voices) if (voice->binary == name) return false; return true; } +void Mixer::erase(const std::string& name) { + for (auto it = binaries.begin(); it != binaries.end();) { + if ((name.empty() || it->first == name) && unreferenced(it->first)) it = binaries.erase(it); else ++it; + } +} +std::string Mixer::memoryName(const std::string& access, const std::string& binary, int64_t offset, int64_t size) { + auto it = binaries.find(binary); if (it == binaries.end()) return ""; + if (offset < 0 || size < 0 || static_cast(offset) > it->second->size() || static_cast(size) > it->second->size() - offset) throw std::invalid_argument("Binary view outside loaded data"); + std::string id = "ofe-mixer-memory://" + std::to_string(epoch) + "/" + std::to_string(viewSerial++); + views.emplace(id, View{it->second, access, binary, static_cast(offset), static_cast(size)}); return id; +} +} diff --git a/Extensions/SDL_Mixer/Android/cpp/Mixer.h b/Extensions/SDL_Mixer/Android/cpp/Mixer.h new file mode 100644 index 000000000..c65b74115 --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/Mixer.h @@ -0,0 +1,66 @@ +#pragma once +#include "AudioRuntime.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace ofe::audio { +struct Settings { + int spatialMode = 0; + int volume = 100, left = 255, right = 255, angle = 0, distance = 0; + bool ab = false, scoreEnabled = false; + double start = 0, end = 0, base = 0; + std::vector score{0,-1,-2,-3,-4,-5,-6,-7,-8,-9,-10,-11,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1}; +}; +struct Voice { + uint64_t id = 0; + int channel = 0; + bool exclusive = false, userPaused = false; + std::string name, binary; + Mix_Music* music = nullptr; + SDL_RWops* rw = nullptr; + std::shared_ptr> bytes; + std::unique_ptr pitch; + std::vector input, output; + ~Voice(); +}; +class Mixer final { + struct View { std::weak_ptr> bytes; std::string access, binary; size_t offset, size; }; + struct Record { std::chrono::steady_clock::time_point time; size_t count = 0; }; + std::vector> voices; + std::map exclusive; + std::map exclusiveSettings, mixingSettings; + std::map>> binaries; + std::map views; + std::map records; + uint64_t serial = 1, viewSerial = 1, epoch; + int master = 100; + bool suspended = false; + Settings& settings(int channel, bool excl); + Voice* current(int channel); + void apply(Voice& voice); + uint64_t playPrepared(const std::string&, const std::string&, int, bool, int, int); + static void SDLCALL pitchCallback(Mix_Music*, void*, int, void*); +public: + Mixer(); + ~Mixer(); + Mixer(const Mixer&) = delete; + uint64_t play(const std::string& name, const std::string& path, int channel, bool excl, int loops, int fade); + void update(); + void setSuspended(bool value); + std::vector liveVoices() const; + void action(int op, int channel, bool excl, double a = 0, double b = 0, const std::string& text = ""); + bool condition(int op, int channel, bool excl, const std::string& name = ""); + double number(int op, int channel, bool excl, const std::string& name = ""); + std::string channelName(int channel); + void cache(const std::string& name, const std::string& path, bool replace); + void erase(const std::string& name); + bool unreferenced(const std::string& name) const; + std::string memoryName(const std::string& access, const std::string& binary, int64_t offset, int64_t size); +}; +} diff --git a/Extensions/SDL_Mixer/Android/cpp/MixerXFixes.cmake b/Extensions/SDL_Mixer/Android/cpp/MixerXFixes.cmake new file mode 100644 index 000000000..f48fcca69 --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/MixerXFixes.cmake @@ -0,0 +1,24 @@ +# OFE's explicit, reproducible overlay for pinned Mixer X 72139aa. +# The PCM WAV seek path aligns offsets to one second instead of one sample frame. +# Keep the upstream checkout untouched and compile an identified corrected copy. +set(wav_source "${OFE_MIXERX_SOURCE}/src/codecs/music_wav.c") +file(READ "${wav_source}" wav_code) +set(broken "Sint64 sample_size = music->spec.freq * music->samplesize;") +string(FIND "${wav_code}" "${broken}" match) +if(match LESS 0) + message(FATAL_ERROR "Mixer X WAV seek overlay needs review against the selected source") +endif() +string(REPLACE "${broken}" "Sint64 sample_size = music->samplesize; /* OFE: align to a sample frame, not a whole second. */" wav_code "${wav_code}") +set(corrected "${CMAKE_CURRENT_BINARY_DIR}/upstream-fixes/music_wav.c") +set(wav_code "/* Altered by OFE: fractional PCM WAV seek alignment. */\n${wav_code}") +set(existing "") +if(EXISTS "${corrected}") + file(READ "${corrected}" existing) +endif() +if(NOT existing STREQUAL wav_code) + file(WRITE "${corrected}" "${wav_code}") +endif() +get_target_property(mixer_sources SDL2_mixer_ext SOURCES) +list(REMOVE_ITEM mixer_sources "${wav_source}") +list(APPEND mixer_sources "${corrected}") +set_property(TARGET SDL2_mixer_ext PROPERTY SOURCES "${mixer_sources}") diff --git a/Extensions/SDL_Mixer/Android/cpp/PCMStream.cpp b/Extensions/SDL_Mixer/Android/cpp/PCMStream.cpp new file mode 100644 index 000000000..b875ae4f4 --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/PCMStream.cpp @@ -0,0 +1,171 @@ +#include "AudioRuntime.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace { +std::mutex channels; +struct PCM { + std::mutex mutex; + OFEAudio_PCMCallback pull = nullptr; + void* pullUser = nullptr; + int frequency = 0, channel = -1; + Mix_Chunk* chunk = nullptr; + SDL_AudioStream* conversion = nullptr; + std::vector silence = std::vector(4096, 0); + struct Sample { int16_t left, right; double end; }; + std::vector ring; + size_t read = 0, size = 0; + soundtouch::SoundTouch tempo; + double speed = 1, position = 0, writePosition = 0, inputEnd = -1; + float volume = 1; + bool dsp = false, flushed = false, running = false; + std::vector floats = std::vector(8192); + std::vector output = std::vector(8192); + PCM() { + uint16_t format; int count; + if (!OFEAudio_GetSpec(&frequency, &format, &count) || format != AUDIO_S16SYS || count != 2) + throw std::runtime_error("PCM output requires an acquired S16 stereo Mixer X device"); + ring.resize(static_cast(frequency) * 2); + tempo.setSampleRate(44100); tempo.setChannels(2); + tempo.setSetting(SETTING_SEQUENCE_MS, 20); tempo.setSetting(SETTING_SEEKWINDOW_MS, 10); + conversion = SDL_NewAudioStream(AUDIO_S16SYS, 2, 44100, format, 2, frequency); + if (!conversion) throw std::runtime_error(SDL_GetError()); + chunk = Mix_QuickLoad_RAW(silence.data(), static_cast(silence.size())); + if (!chunk) { SDL_FreeAudioStream(conversion); throw std::runtime_error(Mix_GetError()); } + } + ~PCM() { + if (channel >= 0) { Mix_HaltChannel(channel); Mix_UnregisterAllEffects(channel); } + if (chunk) Mix_FreeChunk(chunk); + if (conversion) SDL_FreeAudioStream(conversion); + } + void drain() { + for (;;) { + size_t free = ring.size() - size; + if (!free) return; + if (SDL_AudioStreamAvailable(conversion) < 4 && dsp) { + unsigned count = tempo.receiveSamples(floats.data(), static_cast(std::min(free, 4096))); + if (!count) return; + for (size_t i = 0; i < count * 2; ++i) output[i] = static_cast(std::clamp(floats[i] * 32768.f, -32768.f, 32767.f)); + if (SDL_AudioStreamPut(conversion, output.data(), count * 4) < 0) throw std::runtime_error(SDL_GetError()); + } + int count = SDL_AudioStreamGet(conversion, output.data(), static_cast(std::min(free, 4096)) * 4); + if (count < 0) throw std::runtime_error(SDL_GetError()); + if (!count) return; + for (int i = 0; i < count / 4; ++i) { + writePosition += speed * 1000. / frequency; + ring[(read + size++) % ring.size()] = {output[i*2], output[i*2+1], writePosition}; + } + } + } + static void SDLCALL render(int, void* data, int bytes, void* user) { + auto& self = *static_cast(user); + std::lock_guard lock(self.mutex); + auto dst = static_cast(data); std::memset(data, 0, bytes); + if (!self.running) return; + if (self.pull) { + try { + if (self.frequency == 44100) self.pull(self.pullUser, data, bytes); + else { + while (SDL_AudioStreamAvailable(self.conversion) < bytes) { + self.pull(self.pullUser, self.output.data(), 4096); + if (SDL_AudioStreamPut(self.conversion, self.output.data(), 4096) < 0) break; + } + SDL_AudioStreamGet(self.conversion, data, bytes); + } + } catch (...) { + // Exceptions cannot unwind through SDL's audio thread. + std::memset(data, 0, bytes); + self.running = false; + } + return; + } + const double step = self.speed * 1000. / self.frequency; + for (int i = 0; i < bytes / 4; ++i) { + while (self.size && self.ring[self.read].end < self.position - step * .5) { + self.read = (self.read + 1) % self.ring.size(); --self.size; + } + if (self.size && self.ring[self.read].end <= self.position + step * 1.5) { + const auto sample = self.ring[self.read]; + dst[i*2] = static_cast(sample.left * self.volume); dst[i*2+1] = static_cast(sample.right * self.volume); + self.position = std::max(self.position, sample.end); + self.read = (self.read + 1) % self.ring.size(); --self.size; + } else self.position += step; // Silence/underruns advance the same device clock. + } + } +}; +PCM& pcm(void* value) { if (!value) throw std::runtime_error("PCM stream is closed"); return *static_cast(value); } +} +extern "C" void* OFEAudio_CreatePCM() { + std::lock_guard lock(channels); + auto stream = std::make_unique(); + int channel = Mix_PlayChannel(-1, stream->chunk, -1); + if (channel < 0) { int count = Mix_AllocateChannels(-1); Mix_AllocateChannels(count + 8); channel = Mix_PlayChannel(-1, stream->chunk, -1); } + if (channel < 0) throw std::runtime_error(Mix_GetError()); + stream->channel = channel; + // A looping silent chunk supplies an independent Mixer X channel, as on Windows. + // The effect replaces only this channel's samples; music-stream playback is untouched. + if (!Mix_RegisterEffect(channel, PCM::render, nullptr, stream.get())) throw std::runtime_error(Mix_GetError()); + return stream.release(); +} +extern "C" void* OFEAudio_CreatePullPCM(OFEAudio_PCMCallback callback, void* user) { + if (!callback) throw std::runtime_error("PCM callback is required"); + auto stream = static_cast(OFEAudio_CreatePCM()); + std::lock_guard lock(stream->mutex); + stream->pull = callback; + stream->pullUser = user; + return stream; +} +extern "C" void OFEAudio_DestroyPCM(void* value) { std::lock_guard lock(channels); delete static_cast(value); } +extern "C" void OFEAudio_PCMWrite(void* value, const void* bytes, size_t count, double timestamp) { + auto& self = pcm(value); std::lock_guard lock(self.mutex); + if (count % 4 || !std::isfinite(timestamp)) throw std::runtime_error("Invalid stereo PCM input"); + if (self.flushed) throw std::runtime_error("Cannot write PCM after EOF"); + self.drain(); + if (self.inputEnd < 0 || std::abs(timestamp - self.inputEnd) > 100) self.writePosition = timestamp; + self.inputEnd = timestamp + (count / 4) * 1000. / 44100.; + if (self.dsp) { + auto input = static_cast(bytes); + for (size_t offset = 0; offset < count / 4;) { + auto frames = std::min(4096, count / 4 - offset); + for (size_t i = 0; i < frames * 2; ++i) self.floats[i] = input[offset * 2 + i] / 32768.f; + self.tempo.putSamples(self.floats.data(), static_cast(frames)); offset += frames; self.drain(); + } + } else { + if (SDL_AudioStreamPut(self.conversion, bytes, static_cast(count)) < 0) throw std::runtime_error(SDL_GetError()); + self.drain(); + } +} +extern "C" void OFEAudio_PCMFlush(void* value) { + auto& self = pcm(value); std::lock_guard lock(self.mutex); + if (!self.flushed) { if (self.dsp) self.tempo.flush(); self.flushed = true; } + self.drain(); + if (!self.dsp || self.tempo.numSamples() == 0) SDL_AudioStreamFlush(self.conversion); + self.drain(); +} +extern "C" void OFEAudio_PCMReset(void* value, double position) { + auto& self = pcm(value); std::lock_guard lock(self.mutex); + self.tempo.clear(); SDL_AudioStreamClear(self.conversion); self.read = self.size = 0; + self.position = self.writePosition = position; self.inputEnd = -1; self.flushed = false; +} +extern "C" void OFEAudio_PCMState(void* value, int running, float volume, double speed) { + auto& self = pcm(value); std::lock_guard lock(self.mutex); + if (!std::isfinite(speed) || speed <= 0 || speed > 32) throw std::runtime_error("Invalid PCM tempo"); + self.running = running != 0; + self.speed = speed; self.tempo.setTempo(speed); + self.dsp = self.dsp || speed != 1.; + self.volume = std::clamp(volume, 0.f, 1.f); +} +extern "C" double OFEAudio_PCMClock(void* value) { + auto& self = pcm(value); std::lock_guard lock(self.mutex); return self.position; +} +extern "C" size_t OFEAudio_PCMPending(void* value) { + auto& self = pcm(value); std::lock_guard lock(self.mutex); self.drain(); + return self.size + SDL_AudioStreamAvailable(self.conversion) / 4 + self.tempo.numSamples(); +} diff --git a/Extensions/SDL_Mixer/Android/cpp/crypto/CMakeLists.txt b/Extensions/SDL_Mixer/Android/cpp/crypto/CMakeLists.txt new file mode 100644 index 000000000..5360d63bd --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/crypto/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.22) +project(OFEMediaCrypto LANGUAGES C CXX) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +get_filename_component(ofe_extensions "${CMAKE_CURRENT_LIST_DIR}/../../../.." ABSOLUTE) +if(NOT EXISTS "${ofe_extensions}/_DeLib/Encryption/CMakeLists.txt") + get_filename_component(ofe_extensions "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) +endif() +if(ANDROID) + include("${CMAKE_CURRENT_LIST_DIR}/EnsureSDK.cmake") +endif() +function(ofe_add_encryption) + # The Windows implementation and this JNI target compile the same sources. + # Keep OpenSSL inside the owned JNI library rather than adding a global loader dependency. + set(BUILD_SHARED_LIBS OFF) + add_subdirectory("${ofe_extensions}/_DeLib/Encryption" "${CMAKE_CURRENT_BINARY_DIR}/encryption") +endfunction() +if(NOT TARGET encryption) + ofe_add_encryption() +endif() +add_library(ofe_crypto SHARED JNI.cpp) +target_compile_features(ofe_crypto PRIVATE cxx_std_17) +target_link_libraries(ofe_crypto PRIVATE encryption) +set_target_properties(ofe_crypto PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") +if(ANDROID) + target_link_options(ofe_crypto PRIVATE "-Wl,-z,max-page-size=16384" "-Wl,--exclude-libs,ALL") +else() + find_path(OFE_CRYPTO_JNI jni.h HINTS "$ENV{JAVA_HOME}/include" REQUIRED) + find_path(OFE_CRYPTO_JNI_PLATFORM jni_md.h HINTS "$ENV{JAVA_HOME}/include/linux" "$ENV{JAVA_HOME}/include/darwin" "$ENV{JAVA_HOME}/include/win32" REQUIRED) + target_include_directories(ofe_crypto PRIVATE "${OFE_CRYPTO_JNI}" "${OFE_CRYPTO_JNI_PLATFORM}") +endif() diff --git a/Extensions/SDL_Mixer/Android/cpp/crypto/EnsureSDK.cmake b/Extensions/SDL_Mixer/Android/cpp/crypto/EnsureSDK.cmake new file mode 100644 index 000000000..6a2b7ecae --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/crypto/EnsureSDK.cmake @@ -0,0 +1,58 @@ +if(DEFINED ANDROID_PLATFORM_LEVEL) + set(ofe_encrypt_api "${ANDROID_PLATFORM_LEVEL}") +else() + string(REGEX REPLACE "^android-" "" ofe_encrypt_api "${ANDROID_PLATFORM}") +endif() +if(NOT ofe_encrypt_api MATCHES "^[0-9]+$") + message(FATAL_ERROR "Cannot determine the Android API level for OpenSSL") +endif() +get_filename_component(ofe_encrypt_version "${ofe_extensions}/_3rdLib/Encrypt-build/EncryptVersion.cmake" ABSOLUTE) +if(NOT EXISTS "${ofe_encrypt_version}") + set(ofe_encrypt_version "${CMAKE_CURRENT_LIST_DIR}/../../EncryptVersion.cmake") +endif() +include("${ofe_encrypt_version}") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ofe_encrypt_version}") +if(NOT DEFINED OFE_ENCRYPT_ROOT AND DEFINED ENV{OFE_ENCRYPT_ROOT}) + set(OFE_ENCRYPT_ROOT "$ENV{OFE_ENCRYPT_ROOT}" CACHE PATH "Existing Android OpenSSL SDK") +endif() +set(OFE_ENCRYPT_ROOT "${ofe_extensions}/_3rdLib/Encrypt-sdk-Android" CACHE PATH "Windows-version-matched Android OpenSSL SDK") +option(OFE_ENCRYPT_AUTO_DEPLOY "Prepare a missing or stale Android OpenSSL SDK" ON) +set(ofe_encrypt_ready FALSE) +if(EXISTS "${OFE_ENCRYPT_ROOT}/manifest.json") + file(READ "${OFE_ENCRYPT_ROOT}/manifest.json" ofe_manifest) + string(JSON ofe_version ERROR_VARIABLE ofe_version_error GET "${ofe_manifest}" version) + string(JSON ofe_commit ERROR_VARIABLE ofe_commit_error GET "${ofe_manifest}" vcpkg_commit) + string(JSON ofe_api ERROR_VARIABLE ofe_api_error GET "${ofe_manifest}" api) + string(JSON ofe_abis ERROR_VARIABLE ofe_abis_error GET "${ofe_manifest}" abis) + if(NOT ofe_abis_error AND ofe_abis MATCHES "\"${ANDROID_ABI}\"" + AND NOT ofe_version_error AND NOT ofe_commit_error AND NOT ofe_api_error + AND ofe_version STREQUAL OFE_ENCRYPT_OPENSSL_VERSION AND ofe_commit STREQUAL OFE_ENCRYPT_VCPKG_COMMIT + AND ofe_api EQUAL ofe_encrypt_api + AND EXISTS "${OFE_ENCRYPT_ROOT}/${ANDROID_ABI}/lib/libcrypto.a" + AND EXISTS "${OFE_ENCRYPT_ROOT}/${ANDROID_ABI}/include/openssl/evp.h") + set(ofe_encrypt_ready TRUE) + endif() +endif() +if(NOT ofe_encrypt_ready) + if(NOT OFE_ENCRYPT_AUTO_DEPLOY) + message(FATAL_ERROR "Android OpenSSL SDK must match ${ofe_encrypt_version} and API ${ofe_encrypt_api}; prepare OFE_ENCRYPT_ROOT or enable OFE_ENCRYPT_AUTO_DEPLOY") + endif() + find_package(Python3 3.12 REQUIRED COMPONENTS Interpreter) + file(MAKE_DIRECTORY "${OFE_ENCRYPT_ROOT}") + file(LOCK "${OFE_ENCRYPT_ROOT}/.ofe-build.lock" GUARD PROCESS TIMEOUT 1800) + execute_process(COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/../../build_crypto_sdk.py" + --ndk "${CMAKE_ANDROID_NDK}" --output "${OFE_ENCRYPT_ROOT}" --abi "${ANDROID_ABI}" --api "${ofe_encrypt_api}" + RESULT_VARIABLE ofe_encrypt_result) + if(NOT ofe_encrypt_result EQUAL 0) + message(FATAL_ERROR "OpenSSL deployment failed; prepare the Android SDK under Linux/WSL using its matching NDK") + endif() + file(LOCK "${OFE_ENCRYPT_ROOT}/.ofe-build.lock" RELEASE) +endif() +set(OPENSSL_USE_STATIC_LIBS TRUE) +set(OPENSSL_INCLUDE_DIR "${OFE_ENCRYPT_ROOT}/${ANDROID_ABI}/include" CACHE PATH "" FORCE) +set(OPENSSL_CRYPTO_LIBRARY "${OFE_ENCRYPT_ROOT}/${ANDROID_ABI}/lib/libcrypto.a" CACHE FILEPATH "" FORCE) +# FindOpenSSL's host pkg-config probe must not add host compression libraries. +set(CMAKE_DISABLE_FIND_PACKAGE_PkgConfig TRUE) +set(PKG_CONFIG_FOUND FALSE) +set(_OPENSSL_STATIC_LIBRARIES "") +set(_OPENSSL_STATIC_LDFLAGS_OTHER "") diff --git a/Extensions/SDL_Mixer/Android/cpp/crypto/JNI.cpp b/Extensions/SDL_Mixer/Android/cpp/crypto/JNI.cpp new file mode 100644 index 000000000..88ceca91a --- /dev/null +++ b/Extensions/SDL_Mixer/Android/cpp/crypto/JNI.cpp @@ -0,0 +1,80 @@ +// Thin JNI ownership/marshalling around the same Encryption C API used on Windows. +#include "Encryption/CAPI.h" +#include +#include +#include +#include +#include +#include +namespace { +struct Destroy { void operator()(EncryptionCipherContext* p) const { Encryption_CipherDestroy(p); } }; +using Cipher = std::unique_ptr; +std::mutex mutex; +std::unordered_map ciphers; +jlong serial = 1; +bool check(JNIEnv* env, EncryptionResult result) { + if (result == ENCRYPTION_OK) return true; + const char* type = result == ENCRYPTION_INVALID_PADDING ? "javax/crypto/BadPaddingException" + : result == ENCRYPTION_INVALID_ARGUMENT ? "java/lang/IllegalArgumentException" + : result == ENCRYPTION_OUT_OF_MEMORY ? "java/lang/OutOfMemoryError" + : "java/security/GeneralSecurityException"; + if (!env->ExceptionCheck()) env->ThrowNew(env->FindClass(type), "Shared C++ encryption operation failed"); + return false; +} +bool material(JNIEnv* env, jstring text, std::array& output) { + if (!text) return check(env, ENCRYPTION_INVALID_ARGUMENT); + const jchar* chars = env->GetStringChars(text, nullptr); + if (!chars) return false; + struct Release { JNIEnv* env; jstring text; const jchar* chars; + ~Release() { env->ReleaseStringChars(text, chars); } + } release{env,text,chars}; + const auto size = env->GetStringLength(text); + // JNI strings are UTF-16 units, including embedded NUL; native wchar_t and + // process pointer width must not change the legacy Fusion Win32 mapping. + std::vector bytes(static_cast(size) * 2); + for (jsize i = 0; i < size; ++i) { bytes[i*2] = chars[i] & 255; bytes[i*2+1] = chars[i] >> 8; } + return check(env, Encryption_DeriveKeyAndIvWithSeedWidth(bytes.data(), bytes.size(), 32, + output.data(), output.data()+16)); +} +jbyteArray array(JNIEnv* env, const uint8_t* bytes, size_t size) { + auto result = env->NewByteArray(static_cast(size)); + if (result && size) env->SetByteArrayRegion(result, 0, static_cast(size), reinterpret_cast(bytes)); + return result; +} +} +#define JNI_NAME(name) Java__1DeLib_MediaCrypto_##name +extern "C" JNIEXPORT jbyteArray JNICALL JNI_NAME(nMaterial)(JNIEnv* env, jclass, jstring text) { + try { std::array bytes{}; return material(env,text,bytes) ? array(env,bytes.data(),bytes.size()) : nullptr; } + catch (const std::bad_alloc&) { check(env,ENCRYPTION_OUT_OF_MEMORY); return nullptr; } +} +extern "C" JNIEXPORT jlong JNICALL JNI_NAME(nOpen)(JNIEnv* env, jclass, jstring text) { + try { + std::array bytes{}; if (!material(env,text,bytes)) return 0; + EncryptionCipherContext* context = nullptr; + if (!check(env,Encryption_CipherCreate(ENCRYPTION_DECRYPT,bytes.data(),bytes.data()+16,&context))) return 0; + Cipher owned(context); + std::lock_guard lock(mutex); const auto id = serial++; ciphers.emplace(id,std::move(owned)); return id; + } catch (const std::bad_alloc&) { check(env,ENCRYPTION_OUT_OF_MEMORY); return 0; } +} +extern "C" JNIEXPORT jbyteArray JNICALL JNI_NAME(nUpdate)(JNIEnv* env, jclass, jlong id, jbyteArray input, jint count) { + try { + if (!input || count < 0 || count > env->GetArrayLength(input)) { check(env,ENCRYPTION_INVALID_ARGUMENT); return nullptr; } + std::vector bytes(static_cast(count)), output(static_cast(count)+16); + if (count) env->GetByteArrayRegion(input,0,count,reinterpret_cast(bytes.data())); + if (env->ExceptionCheck()) return nullptr; + std::lock_guard lock(mutex); auto found = ciphers.find(id); + if (found == ciphers.end()) { check(env,ENCRYPTION_INVALID_STATE); return nullptr; } + size_t size = 0; + return check(env,Encryption_CipherUpdate(found->second.get(),bytes.data(),bytes.size(),output.data(),output.size(),&size)) + ? array(env,output.data(),size) : nullptr; + } catch (const std::bad_alloc&) { check(env,ENCRYPTION_OUT_OF_MEMORY); return nullptr; } +} +extern "C" JNIEXPORT jbyteArray JNICALL JNI_NAME(nFinish)(JNIEnv* env, jclass, jlong id) { + std::lock_guard lock(mutex); auto found = ciphers.find(id); + if (found == ciphers.end()) { check(env,ENCRYPTION_INVALID_STATE); return nullptr; } + std::array output{}; size_t size = 0; + return check(env,Encryption_CipherFinal(found->second.get(),output.data(),output.size(),&size)) ? array(env,output.data(),size) : nullptr; +} +extern "C" JNIEXPORT void JNICALL JNI_NAME(nClose)(JNIEnv*, jclass, jlong id) { + std::lock_guard lock(mutex); ciphers.erase(id); +} diff --git a/Extensions/SDL_Mixer/Android/dependencies.json b/Extensions/SDL_Mixer/Android/dependencies.json new file mode 100644 index 000000000..e1199b1f4 --- /dev/null +++ b/Extensions/SDL_Mixer/Android/dependencies.json @@ -0,0 +1,23 @@ +{ + "SDL2": { + "repository": "libsdl-org/SDL", + "revision": "5d249570393f7a37e037abf22cd6012a4cc56a71", + "sha256": "10f1194f8d2e4a73ca1c7c553b3189d0b68ab9ef3544e8d2a268e4429456b373" + }, + "MixerX": { + "repository": "WohlSoft/SDL-Mixer-X", + "revision": "72139aa7332062de541de50708742fe4ee2ae05c", + "sha256": "e4e273beba581a860814f33d7c8ae5bb6309470e49545b2d948d3987a0a97210" + }, + "SoundTouch": { + "repository": "azahar-emu/soundtouch", + "origin": "https://codeberg.org/soundtouch/soundtouch.git", + "revision": "e83424d5928ab8513d2d082779c275765dee31b9", + "sha256": "8729abae5175970ec79e2ce6b98b28c4d490004043bcf5997ebe860ab6f93d72" + }, + "AudioCodecs": { + "repository": "WohlSoft/AudioCodecs", + "revision": "bb11ca3cc1d7597b23a530d519b3e636dceb7b23", + "sha256": "66b28463f73255f9f108e905e0713996654206955527a95cb531f9910daaa5f9" + } +} diff --git a/Extensions/SDL_Mixer/ReadMe.md b/Extensions/SDL_Mixer/ReadMe.md index 0cd94549e..158719995 100644 --- a/Extensions/SDL_Mixer/ReadMe.md +++ b/Extensions/SDL_Mixer/ReadMe.md @@ -146,3 +146,19 @@ freely, subject to the following restrictions: 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 3. This notice may not be removed or altered from any source distribution. + +## Android JNI port + +The Android runtime is a JNI wrapper over the pinned SDL Mixer X backend. It +preserves the Windows ACE IDs and supports exclusive/mixing streams, fades, AB +loops, effects, encrypted inputs and handled-memory playback. See the +[Android build and compatibility notes](Android/README.md) for upstream fixes, +codec coverage, shared-device lifetime and validation limits. + +`Run_Only Unicode` publishes the four-ABI Android extension ZIP through +`_Script/SDL_Mixer_Android.bat`; set `/p:SDLMixerPackageAndroid=false` to skip it. +The existing Windows SDK and release-archive workflow remain available. + +See the [combined Android build/file guide](../Android_Gradle/Media-JNI.md) for +coexistence with FFmpeg, application-owned cross-frame playback, the Windows-matched +codec suite and Android file/URI permission requirements. diff --git a/Extensions/SDL_Mixer/SDL_Mixer.vcxproj b/Extensions/SDL_Mixer/SDL_Mixer.vcxproj index af8498999..8e42fd77e 100644 --- a/Extensions/SDL_Mixer/SDL_Mixer.vcxproj +++ b/Extensions/SDL_Mixer/SDL_Mixer.vcxproj @@ -56,6 +56,10 @@ + + true + true + <_ProjectFileVersion>16.0.31515.66 @@ -322,4 +326,10 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + + + \ No newline at end of file diff --git a/Extensions/_3rdLib/FFMpegAdapter.cpp b/Extensions/_3rdLib/FFMpegAdapter.cpp index 88d1c180f..a74879120 100644 --- a/Extensions/_3rdLib/FFMpegAdapter.cpp +++ b/Extensions/_3rdLib/FFMpegAdapter.cpp @@ -2,12 +2,17 @@ #ifdef _WIN32 #include "FFMpegAdapterD3D11.h" +#elif defined(__ANDROID__) +#include "FFMpegAdapterAndroid.h" #endif bool FFMpegAdapterSupport(const AVHWDeviceType type) { do { +#ifdef __ANDROID__ + if (type == AV_HWDEVICE_TYPE_MEDIACODEC) { return true; } +#endif #ifdef _WIN32 - if (type == AV_HWDEVICE_TYPE_D3D11VA && pHWDCtx) { return true; } + if (type == AV_HWDEVICE_TYPE_D3D11VA) { return true; } #endif } while (false); @@ -17,6 +22,11 @@ bool FFMpegAdapterSupport(const AVHWDeviceType type) { std::unique_ptr FFMpegAdapterFactory(const AVHWDeviceType type, AVHWDeviceContext* pHWDCtx) { do { +#ifdef __ANDROID__ + if (type == AV_HWDEVICE_TYPE_MEDIACODEC) { + return std::make_unique(pHWDCtx); + } +#endif #ifdef _WIN32 if (type == AV_HWDEVICE_TYPE_D3D11VA && pHWDCtx) { return std::make_unique(pHWDCtx); diff --git a/Extensions/_3rdLib/FFMpegAdapterAndroid.cpp b/Extensions/_3rdLib/FFMpegAdapterAndroid.cpp new file mode 100644 index 000000000..1dd8f2620 --- /dev/null +++ b/Extensions/_3rdLib/FFMpegAdapterAndroid.cpp @@ -0,0 +1,44 @@ +#include "FFMpegAdapterAndroid.h" + +#ifdef __ANDROID__ +#include +#include +extern "C" { +#include +#include +} + +const AVCodec* FFMpegAdapterAndroid::SelectDecoder(const AVCodec* codec) { + const std::string name = std::string(avcodec_get_name(codec->id)) + "_mediacodec"; + const auto hardware = avcodec_find_decoder_by_name(name.c_str()); + return hardware ? hardware : codec; +} +AVPixelFormat FFMpegAdapterAndroid::PixelFormat(const AVCodec* codec) { + const std::string name = codec->name; + return name.ends_with("_mediacodec") ? AV_PIX_FMT_MEDIACODEC : AV_PIX_FMT_NONE; +} +int FFMpegAdapterAndroid::InitDecoder(AVCodecContext* context, void* surface) { + if (!surface) return AVERROR(EINVAL); + auto media = av_mediacodec_alloc_context(); + if (!media) return AVERROR(ENOMEM); + // Use NDK codec discovery, including software codecs exposed on emulator devices. + if (av_opt_set_int(context->priv_data, "ndk_codec", 1, 0) < 0) { + av_free(media); + return AVERROR(EINVAL); + } + const auto result = av_mediacodec_default_init(context, media, surface); + if (result < 0) av_free(media); + return result; +} +void FFMpegAdapterAndroid::ReleaseDecoder(AVCodecContext* context) { + if (context && context->hwaccel_context) av_mediacodec_default_free(context); +} +void FFMpegAdapterAndroid::convert_textureFrame(AVCodecContext*, AVFrame* frame, + const FrameDataCallBack& callback) { + auto buffer = reinterpret_cast(frame->data[3]); + if (!buffer || av_mediacodec_release_buffer(buffer, 1) < 0) + throw std::runtime_error("Cannot render MediaCodec Surface buffer"); + // No CPU pointer exists for a Surface frame. Signal presentation to the binding. + callback(nullptr, frame->width, frame->height); +} +#endif diff --git a/Extensions/_3rdLib/FFMpegAdapterAndroid.h b/Extensions/_3rdLib/FFMpegAdapterAndroid.h new file mode 100644 index 000000000..bb9e02f52 --- /dev/null +++ b/Extensions/_3rdLib/FFMpegAdapterAndroid.h @@ -0,0 +1,17 @@ +#pragma once + +#ifdef __ANDROID__ +#include "FFMpegAdapter.h" + +// MediaCodec owns Surface buffers; the Java binding owns the Surface itself. +struct FFMpegAdapterAndroid final : FFMpegAdapter { + using FFMpegAdapter::FFMpegAdapter; + const AVCodec* SelectDecoder(const AVCodec* codec) override; + AVPixelFormat PixelFormat(const AVCodec* codec) override; + bool OwnsDecoderContext() const override { return true; } + int InitDecoder(AVCodecContext* context, void* surface) override; + void ReleaseDecoder(AVCodecContext* context) override; + void convert_textureFrame(AVCodecContext* context, AVFrame* frame, + const FrameDataCallBack& callback) override; +}; +#endif diff --git a/Extensions/_3rdLib/FFmpeg-build/FFmpegVersion.cmake b/Extensions/_3rdLib/FFmpeg-build/FFmpegVersion.cmake index 5df5401f6..a9f208f64 100644 --- a/Extensions/_3rdLib/FFmpeg-build/FFmpegVersion.cmake +++ b/Extensions/_3rdLib/FFmpeg-build/FFmpegVersion.cmake @@ -6,3 +6,7 @@ set(OFE_FFMPEG_VARIANT "lgpl-shared") set(OFE_FFMPEG_RELEASE "latest") set(OFE_FFMPEG_REPOSITORY "defisym/FFmpeg-Builds-Win32") set(OFE_FFMPEG_SHA256 "ba7fe27fc6b0925f405783531171061ec831933229c4b6875ebc6ffcf30d2c14") + +# Corresponding upstream source for every platform. Android builds this release +# with its NDK; the Windows SHA256 above pins the separately packaged Win32 SDK. +set(OFE_FFMPEG_SOURCE_SHA256 "d97647ace36a307f17ba2bca052d68937487bed8682e1eb9b6737076a9c442b7") diff --git a/Extensions/_DeLib/Encryption/Core.h b/Extensions/_DeLib/Encryption/Core.h index 55ab5e9e3..38554d0fb 100644 --- a/Extensions/_DeLib/Encryption/Core.h +++ b/Extensions/_DeLib/Encryption/Core.h @@ -23,6 +23,9 @@ #include #include +#if defined(__ANDROID__) && !defined(__LP64__) && __ANDROID_API__ < 24 +#include +#endif namespace encryption { @@ -280,7 +283,12 @@ class FileSource final : public RandomAccessSource { return _fseeki64(file, offset, whence); #else static_assert(sizeof(off_t) >= 8, "Build with _FILE_OFFSET_BITS=64"); +#if defined(__ANDROID__) && !defined(__LP64__) && __ANDROID_API__ < 24 + // API 21-23 supports the 64-bit descriptor APIs but not 64-bit stdio. + return lseek(fileno(file), static_cast(offset), whence) < 0 ? -1 : 0; +#else return fseeko(file, static_cast(offset), whence); +#endif #endif } @@ -296,6 +304,8 @@ class FileSource final : public RandomAccessSource { require(seek(file_.get(), 0, SEEK_END) == 0, ErrorCode::io, "Seek asset"); #ifdef _WIN32 const auto size = _ftelli64(file_.get()); +#elif defined(__ANDROID__) && !defined(__LP64__) && __ANDROID_API__ < 24 + const auto size = lseek(fileno(file_.get()), 0, SEEK_CUR); #else const auto size = ftello(file_.get()); #endif @@ -314,11 +324,19 @@ class FileSource final : public RandomAccessSource { if (!count) return 0; std::lock_guard lock(mutex_); +#if defined(__ANDROID__) && !defined(__LP64__) && __ANDROID_API__ < 24 + const auto read = pread(fileno(file_.get()), output, + (std::min)(count, static_cast((std::numeric_limits::max)())), + static_cast(offset)); + require(read >= 0, ErrorCode::io, "Read asset"); + return static_cast(read); +#else require(seek(file_.get(), static_cast(offset), SEEK_SET) == 0, ErrorCode::io, "Seek asset"); const auto read = std::fread(output, 1, count, file_.get()); require(!std::ferror(file_.get()), ErrorCode::io, "Read asset"); return read; +#endif } }; diff --git a/Extensions/_DeLib/Encryption/README.md b/Extensions/_DeLib/Encryption/README.md index a5f55184f..5d12d7816 100644 --- a/Extensions/_DeLib/Encryption/README.md +++ b/Extensions/_DeLib/Encryption/README.md @@ -186,6 +186,12 @@ consumer's native API level. Supply ABI-specific OpenSSL headers/libraries. The plugin's publisher must prebuild/package its owned native libraries and required STL/dependency binaries under jniLibs; do not replace Fusion's Gradle templates. The core needs no Java Runtime or JNI changes by itself. +On Android's 32-bit ABIs at API 21–23, FileSource uses the available 64-bit +descriptor I/O rather than unavailable 64-bit stdio declarations. The branch is +guarded by Android/ABI/API macros; Windows, other operating systems, 64-bit +Android and API 24+ retain their existing stdio path. Build with +`_FILE_OFFSET_BITS=64`, as the CMake target already specifies. + For iOS, compile separate device/simulator libraries with Apple's SDK and matching OpenSSL slices, then include the required libraries/headers in the consuming diff --git a/Extensions/_DeLib/Encryption/tests/core.cpp b/Extensions/_DeLib/Encryption/tests/core.cpp index af200dfad..36d1dbdcc 100644 --- a/Extensions/_DeLib/Encryption/tests/core.cpp +++ b/Extensions/_DeLib/Encryption/tests/core.cpp @@ -2,6 +2,7 @@ #include "EncryptionStream.h" #include "Encryption/CAPI.h" #include +#include #include #include @@ -316,9 +317,34 @@ void facade_defaults_and_failures() { CHECK(!encryption.EncryptFileDirectly(nullptr) && !encryption.DecryptFileDirectly(L"")); } +void sparse_file_offsets() { +#ifndef _WIN32 + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto path = std::filesystem::temp_directory_path() / ("encryption-lfs-" + std::to_string(stamp)); + struct Remove { std::filesystem::path path; ~Remove() { std::error_code error; std::filesystem::remove(path,error); } } remove{path}; + constexpr std::uint64_t offset = (std::uint64_t{1} << 32) + 17; + const std::array expected{{'L','F','S','!'}}; + { + std::ofstream file(path,std::ios::binary); + file.seekp(static_cast(offset)); + file.write(expected.data(),expected.size()); + CHECK(file.good()); + } + FileSource source(path); + CHECK(source.size() == offset + expected.size()); + std::array output{}; + CHECK(source.read_at(offset,output.data(),output.size()) == output.size()); + CHECK(std::equal(expected.begin(),expected.end(),output.begin())); + CHECK(source.read_at(0,output.data(),output.size()) == output.size()); + CHECK(std::all_of(output.begin(),output.end(),[](Byte b) { return b == 0; })); + CHECK(source.read_at(offset,output.data(),output.size()) == output.size()); + CHECK(std::equal(expected.begin(),expected.end(),output.begin())); +#endif +} + int main(int argc, char** argv) { try { - test_windows_headers(); known_answers(); keygen_strings(); streaming_and_seeking(); large_offsets(); compatibility(); + test_windows_headers(); known_answers(); keygen_strings(); streaming_and_seeking(); large_offsets(); sparse_file_offsets(); compatibility(); facade_defaults_and_failures(); CHECK(test_c_api() == 0); test_existing_asset(argc, argv); diff --git a/Extensions/_Script/AndroidNative.py b/Extensions/_Script/AndroidNative.py new file mode 100644 index 000000000..6e96a017c --- /dev/null +++ b/Extensions/_Script/AndroidNative.py @@ -0,0 +1,66 @@ +"""Shared Android ABI checks and SDK/NDK discovery for native extension publishers.""" +from pathlib import Path +import importlib.util +import re +import subprocess + +ABIS = {'arm64-v8a': ('aarch64-linux-android', 'AArch64'), + 'armeabi-v7a': ('arm-linux-androideabi', 'ARM'), + 'x86': ('i686-linux-android', 'Intel 80386'), 'x86_64': ('x86_64-linux-android', 'Advanced Micro Devices X86-64')} + +def check_elf(path, readelf, machine): + result = subprocess.run([str(readelf), '-h', '-l', '-d', str(path)], check=True, capture_output=True, text=True).stdout + match = re.search(r'Machine:\s*(.*)', result) + if not match or match.group(1).strip() != machine: raise ValueError(f'Wrong ABI: {path}') + loads = [line.split()[-1] for line in result.splitlines() if line.strip().startswith('LOAD ')] + # Android's 16 KB page-size requirement applies to 64-bit ABIs. NDK's 32-bit libc++ uses 4 KB. + alignment = 16384 if machine in ('AArch64', 'Advanced Micro Devices X86-64') else 4096 + if not loads or any(int(value, 16) < alignment for value in loads): raise ValueError(f'Invalid ELF load alignment ({alignment} required): {path}') + return re.findall(r'\(NEEDED\).*?\[(.*?)\]', result) + +def native_tools(): + # Reuse FTW's established SDK discovery/installation behavior. + spec=importlib.util.spec_from_file_location('ofe_ftw_tools',Path(__file__).with_name('FindTheWay.py')) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + return module.native_tools() + + +def copy_audio_sources(sources, source_cache, port, stage, builds): + """Identical shared runtime sources/notices in either standalone extension ZIP.""" + import hashlib + import json + import shutil + guide=port.parents[1]/'Android_Gradle/Media-JNI.md' + if guide.is_file():shutil.copy2(guide,stage/'Android-Media.md') + shutil.copytree(port/'cpp', stage/'Sources/SDL_Mixer/cpp') + for name in ['build_android.py','build_crypto_sdk.py','dependencies.json','README.md']: + target=stage/'Sources/SDL_Mixer'/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(port/name,target) + # The original C++ implementation and the Windows dependency selection are + # included once in each standalone export, alongside verified OpenSSL sources. + extensions=port.parents[1] + shutil.copy2(extensions/'_3rdLib/Encrypt-build/EncryptVersion.cmake',stage/'Sources/SDL_Mixer/EncryptVersion.cmake') + encryption=stage/'Sources/_DeLib/Encryption';encryption.mkdir(parents=True,exist_ok=True) + for name in ['Core.h','KeyGen.h','CAPI.h','CAPI.cpp','CMakeLists.txt','README.md']: + shutil.copy2(extensions/'_DeLib/Encryption'/name,encryption/name) + for name in ['Encryption.h','Encryption.cpp','EncryptionStream.h','UnicodeConversion.h']: + shutil.copy2(extensions/'_DeLib'/name,encryption.parent/name) + roots=set() + for build in builds.values(): + cache=(build/'CMakeCache.txt').read_text() + found=re.search(r'^OFE_ENCRYPT_ROOT:PATH=(.+)$',cache,re.M) + if not found:raise ValueError('Missing shared OpenSSL SDK in '+str(build)) + roots.add(Path(found.group(1))) + if len(roots)!=1:raise ValueError('All packaged ABIs must share the same OpenSSL SDK') + sdk=next(iter(roots));metadata=json.loads((sdk/'manifest.json').read_text()) + archive=sdk/('openssl-'+metadata['version']+'.tar.gz') + if hashlib.sha512(archive.read_bytes()).hexdigest()!=metadata['source_sha512']:raise ValueError('OpenSSL source hash mismatch') + openssl=stage/'Sources/third_party/OpenSSL';openssl.mkdir(parents=True,exist_ok=True) + for name in [archive.name,'manifest.json','LICENSE.txt','openssl-port-'+metadata['vcpkg_commit']+'.cmake']: + shutil.copy2(sdk/name,openssl/name) + for name,item in json.loads((port/'dependencies.json').read_text()).items(): + archive=source_cache/(name+'-'+item['revision']+'.tar.gz') + if hashlib.sha256(archive.read_bytes()).hexdigest()!=item['sha256']:raise ValueError('Source archive hash mismatch: '+name) + target=stage/'Sources/third_party'/archive.name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(archive,target) + for license in (sources[name].rglob('*') if name=='AudioCodecs' else sources[name].iterdir()): + if license.is_file() and license.name.upper().startswith(('LICENSE','COPYING')): + target=stage/'Sources/third_party'/name/license.relative_to(sources[name]);target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(license,target) diff --git a/Extensions/_Script/FFMpeg.bat b/Extensions/_Script/FFMpeg.bat new file mode 100644 index 000000000..15427189d --- /dev/null +++ b/Extensions/_Script/FFMpeg.bat @@ -0,0 +1,10 @@ +@echo off +setlocal EnableExtensions +py -3 --version >nul 2>nul +if not errorlevel 1 ( + py -3 "%~dp0FFMpeg.py" %* + if errorlevel 1 exit /b 1 + exit /b 0 +) +python "%~dp0FFMpeg.py" %* +exit /b %errorlevel% diff --git a/Extensions/_Script/FFMpeg.py b/Extensions/_Script/FFMpeg.py new file mode 100644 index 000000000..2541df69e --- /dev/null +++ b/Extensions/_Script/FFMpeg.py @@ -0,0 +1,147 @@ +"""Publish FFmpeg's Android JNI extension, using the FTW SDK/NDK discovery rules.""" +from __future__ import annotations +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import zipfile +import sys +sys.path.insert(0,str(Path(__file__).resolve().parent)) +from AndroidNative import ABIS, check_elf, copy_audio_sources + +EXTENSIONS = Path(__file__).resolve().parent.parent +ANDROID = EXTENSIONS / 'Android_Gradle' +PORT = EXTENSIONS / 'FFMpeg/Android' +JAVA = ANDROID / 'app/src/main/java' +SEEDS = ['Extensions/CRunFFMpeg.java', '_DeLib/FFMpegPlayer.java', '_DeLib/FFMpegTexture.java', '_DeLib/FFMpegSource.java', '_DeLib/MediaSource.java', '_DeLib/MediaCrypto.java', '_DeLib/SDLAudioRuntime.java', '_DeLib/FusionStorage.java', '_DeLib/FusionFiles.java', '_DeLib/FFMpegStorage.java'] +COMPONENTS = ('avformat', 'avcodec', 'avutil', 'swscale', 'swresample', 'avfilter') + +def sdk_builder(): + spec = importlib.util.spec_from_file_location('ofe_ffmpeg_sdk', PORT/'build_sdk.py') + builder = importlib.util.module_from_spec(spec); spec.loader.exec_module(builder) + return builder + + +def ftw_tools(): + spec = importlib.util.spec_from_file_location('ofe_ftw_publisher', Path(__file__).with_name('FindTheWay.py')) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module + +def run(command): + print('+', subprocess.list2cmdline(list(map(str,command))), flush=True) + subprocess.run(list(map(str,command)), check=True) + +def copy(source, destination): + destination.parent.mkdir(parents=True, exist_ok=True); shutil.copy2(source, destination) + + +def package(sdk, ndk, cmake, output, abis, build_root, audio_sources, audio_cache, audio_builds, ninja=None): + host = next((ndk/'toolchains/llvm/prebuilt').iterdir()) + suffix = '.exe' if os.name == 'nt' else '' + readelf = host/'bin'/('llvm-readelf'+suffix) + # SDKs must carry their source and license material alongside binary dependencies. + manifest = sdk/'manifest.json' + if not manifest.is_file(): raise FileNotFoundError('FFmpeg SDK manifest missing; use Android/build_sdk.py') + metadata = json.loads(manifest.read_text()) + if not sdk_builder().sdk_matches(sdk, abis): + raise ValueError('FFmpeg Android SDK does not match FFmpegVersion.cmake or requested ABIs; rebuild with Android/build_sdk.py') + source = sdk/f"ffmpeg-n{metadata['version']}.tar.gz" + if hashlib.sha256(source.read_bytes()).hexdigest() != metadata['source_sha256']: + raise ValueError('FFmpeg corresponding source hash mismatch') + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='ffmpeg-package-', dir=output.parent) as temp: + stage = Path(temp)/'stage'; stage.mkdir() + for name in SEEDS: copy(JAVA/name, stage/'app/src/main/java'/name) + vendor=audio_sources['SDL2']/'android-project/app/src/main/java/org/libsdl/app' + for source_java in vendor.glob('*.java'):copy(source_java,stage/'app/src/main/java/org/libsdl/app'/source_java.name) + copy_audio_sources(audio_sources,audio_cache,EXTENSIONS/'SDL_Mixer/Android',stage,audio_builds) + copy(EXTENSIONS.parent/'LICENSE',stage/'Sources/SDL_Mixer/LICENSE') + shutil.copytree(PORT/'cpp', stage/'Sources/FFMpeg/cpp') + for name in ('FFMpeg.h', 'FFMpeg.cpp', 'FFMpegDefinition.h', + 'FFMpegAdapter.h', 'FFMpegAdapter.cpp', 'FFMpegAdapterAndroid.h', + 'FFMpegAdapterAndroid.cpp', 'PacketQueue.h', 'MemView.h'): + copy(EXTENSIONS/'_3rdLib'/name, stage/'Sources/_3rdLib'/name) + for name in ('LockHelper.h', 'HoldHelper.h', 'ArithmeticTraits.h', 'ArithmeticUtilities.h', + 'StringTraits.h', 'StringUtilities.h', 'StringConversion.h'): + copy(EXTENSIONS/'_DeLib'/name, stage/'Sources/_DeLib'/name) + for name in ('CopyAdapter.h', 'CopyAdapter.cpp', 'CopyAdapterAndroid.h', 'CopyAdapterAndroid.cpp'): + copy(EXTENSIONS/'FFMpeg/CopyAdapter'/name, stage/'Sources/FFMpeg/CopyAdapter'/name) + copy(PORT/'build_sdk.py', stage/'Sources/FFMpeg/build_sdk.py') + copy(EXTENSIONS/'_3rdLib/FFmpeg-build/FFmpegVersion.cmake', stage/'Sources/FFMpeg/FFmpegVersion.cmake') + # Use the repository's shared conversion dependency and build interface. + module=stage/'Sources/cmake/DeLibHelpers.cmake'; module.parent.mkdir(parents=True,exist_ok=True) + copy(ANDROID/'cmake/DeLibHelpers.cmake',module) + ftw=ftw_tools(); ftw.source_archive() + shutil.copytree(EXTENSIONS/'_3rdLib/iconv-android',stage/'Sources/_3rdLib/iconv-android') + copy(PORT/'README.md', stage/'FFMpeg-JNI.md') + copy(source, stage/'Sources/FFmpeg'/source.name) + copy(manifest, stage/'Sources/FFmpeg/manifest.json') + if not (sdk/'licenses/COPYING.LGPLv2.1').is_file(): raise FileNotFoundError('FFmpeg LGPL license missing') + shutil.copytree(sdk/'licenses', stage/'Sources/FFmpeg/licenses') + copy(EXTENSIONS.parent/'LICENSE', stage/'Sources/FFMpeg/LICENSE') + for notice in ('NOTICE', 'NOTICE.toolchain'): + copy(ndk/notice, stage/'Sources/Android-NDK'/notice) + copy(ndk/'source.properties', stage/'Sources/Android-NDK/source.properties') + rules = stage/'app/libs/FFMpeg-JNI-rules.jar'; rules.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(rules, 'w', zipfile.ZIP_DEFLATED) as jar: + jar.writestr('META-INF/proguard/FFMpeg.pro', '-keep class _DeLib.FFMpegPlayer { *; }\n-keep class _DeLib.FFMpegTexture { *; }\n-keep class org.libsdl.app.** { *; }\n-keep class _DeLib.SDLAudioRuntime { *; }\n-keep class _DeLib.MediaCrypto { *; }\n') + for abi in abis: + triple, machine = ABIS[abi] + build = build_root/abi; lib = stage/'app/src/main/jniLibs'/abi + command = [cmake, '-S', PORT/'cpp', '-B', build, + '-DCMAKE_TOOLCHAIN_FILE='+str(ndk/'build/cmake/android.toolchain.cmake'), + '-DANDROID_ABI='+abi, '-DANDROID_PLATFORM=android-21', '-DANDROID_STL=c++_shared', + '-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON', '-DOFE_FFMPEG_ROOT='+str(sdk), '-DOFE_AUDIO_ROOT='+str(audio_builds[abi]), '-DCMAKE_BUILD_TYPE=Release'] + if ninja: command += ['-G', 'Ninja', '-DCMAKE_MAKE_PROGRAM='+str(ninja)] + run(command); run([cmake, '--build', build, '--parallel', '4']) + copy(build/'libofe_ffmpeg.so', lib/'libofe_ffmpeg.so') + for name in ['SDL2','SDL2_mixer_ext','ofe_audio','ofe_crypto']:copy(audio_builds[abi]/'lib'/('lib'+name+'.so'),lib/('lib'+name+'.so')) + for component in COMPONENTS: copy(sdk/abi/'lib'/f'lib{component}.so', lib/f'lib{component}.so') + copy(host/'sysroot/usr/lib'/triple/'libc++_shared.so', lib/'libc++_shared.so') + for config in ('config.h', 'config.mak'): copy(sdk/abi/config, stage/'Sources/FFmpeg'/abi/config) + system = {'libc.so','libm.so','libdl.so','liblog.so','libandroid.so','libmediandk.so','libz.so','libGLESv2.so','libOpenSLES.so','libGLESv1_CM.so','libEGL.so'} + names = {p.name for p in lib.glob('*.so')} + for binary in lib.glob('*.so'): + run([host/'bin'/('llvm-strip'+suffix), '--strip-unneeded', binary]) + for dependency in check_elf(binary, readelf, machine): + if dependency not in system | names: raise ValueError(f'Unpackaged dependency {dependency}: {binary}') + hashes = {str(p.relative_to(stage)).replace('\\','/'): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(stage.rglob('*')) if p.is_file()} + (stage/'FFMpeg-package-manifest.json').write_text(json.dumps(hashes, indent=2)+'\n') + archive = Path(temp)/'FFMpeg.zip' + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as zipped: + for p in sorted(stage.rglob('*')): + if p.is_file(): zipped.write(p, p.relative_to(stage)) + archive.replace(output) + print('Published:', output) + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--ffmpeg-sdk', type=Path, default=Path(os.environ.get('OFE_FFMPEG_ROOT', str(EXTENSIONS/'_3rdLib/FFmpeg-sdk-Android')))) + parser.add_argument('--ndk', type=Path) + parser.add_argument('--cmake', type=Path) + parser.add_argument('--output', type=Path, default=EXTENSIONS/'FFMpeg/ToInstall/Files/Data/Runtime/Android/FFMpeg.zip') + parser.add_argument('--audio-sources',type=Path,default=EXTENSIONS/'SDL_Mixer/Android/out/sources') + parser.add_argument('--audio-build-root',type=Path,default=EXTENSIONS/'SDL_Mixer/Android/out/build') + parser.add_argument('--build-root', type=Path, default=PORT/'out/jni') + parser.add_argument('--abi', choices=list(ABIS), action='append', help='Subset for development; release packages need all four ABIs') + parser.add_argument('--no-pause', action='store_true', help=argparse.SUPPRESS) + args = parser.parse_args() + ninja = None + if args.ndk and args.cmake: ndk, cmake = args.ndk, args.cmake + else: + ndk, cmake, ninja = ftw_tools().native_tools() + ndk = args.ndk or ndk; cmake = args.cmake or cmake + sdk = args.ffmpeg_sdk.resolve() + if not sdk_builder().sdk_matches(sdk, args.abi or list(ABIS)): + run([os.sys.executable, PORT/'build_sdk.py', '--ndk', ndk, '--output', sdk]) + audio_spec=importlib.util.spec_from_file_location('ofe_audio_builder',EXTENSIONS/'SDL_Mixer/Android/build_android.py') + audio_builder=importlib.util.module_from_spec(audio_spec);audio_spec.loader.exec_module(audio_builder) + audio_sources=audio_builder.prepare(args.audio_sources.resolve()) + audio_builds=audio_builder.build(audio_sources,args.audio_build_root.resolve(),cmake.resolve(),ndk.resolve(),args.abi or list(ABIS),ninja=ninja) + package(sdk, ndk.resolve(), cmake.resolve(), args.output.resolve(), args.abi or list(ABIS), args.build_root.resolve(), audio_sources, args.audio_sources.resolve(), audio_builds, ninja) diff --git a/Extensions/_Script/SDL_Mixer.py b/Extensions/_Script/SDL_Mixer.py new file mode 100644 index 000000000..902ccb70a --- /dev/null +++ b/Extensions/_Script/SDL_Mixer.py @@ -0,0 +1,75 @@ +"""Publish the Android SDL Mixer X JNI extension without replacing Fusion templates.""" +from __future__ import annotations +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import zipfile + +EXTENSIONS=Path(__file__).resolve().parent.parent +PORT=EXTENSIONS/'SDL_Mixer/Android' +JAVA=EXTENSIONS/'Android_Gradle/app/src/main/java' + +def module(name,path): + spec=importlib.util.spec_from_file_location(name,path);loaded=importlib.util.module_from_spec(spec);spec.loader.exec_module(loaded);return loaded + +def copy(source,target): + target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source,target) + +def package(sources,source_cache,builds,ndk,output): + common=module('ofe_android_native',Path(__file__).with_name('AndroidNative.py')) + host=next((ndk/'toolchains/llvm/prebuilt').iterdir());suffix='.exe' if os.name=='nt' else '' + output.parent.mkdir(parents=True,exist_ok=True) + with tempfile.TemporaryDirectory(prefix='sdl-mixer-package-',dir=output.parent) as temporary: + stage=Path(temporary)/'stage';stage.mkdir() + for name in ['Extensions/CRunSDL_Mixer.java','_DeLib/SDLMixer.java','_DeLib/SDLMixerStorage.java','_DeLib/SDLAudioRuntime.java','_DeLib/MediaSource.java', '_DeLib/MediaCrypto.java','_DeLib/FusionStorage.java','_DeLib/FusionFiles.java']: + copy(JAVA/name,stage/'app/src/main/java'/name) + vendor=sources['SDL2']/'android-project/app/src/main/java/org/libsdl/app' + for source in vendor.glob('*.java'):copy(source,stage/'app/src/main/java/org/libsdl/app'/source.name) + common.copy_audio_sources(sources,source_cache,PORT,stage,builds) + copy(EXTENSIONS.parent/'LICENSE',stage/'Sources/SDL_Mixer/LICENSE') + for name in ['NOTICE','NOTICE.toolchain','source.properties']:copy(ndk/name,stage/'Sources/Android-NDK'/name) + rules=stage/'app/libs/SDL_Mixer-JNI-rules.jar';rules.parent.mkdir(parents=True,exist_ok=True) + with zipfile.ZipFile(rules,'w',zipfile.ZIP_DEFLATED) as jar: + jar.writestr('META-INF/proguard/SDL_Mixer.pro','-keep class org.libsdl.app.** { *; }\n-keep class _DeLib.SDLMixer { *; }\n-keep class _DeLib.SDLAudioRuntime { *; }\n-keep class _DeLib.MediaCrypto { *; }\n') + system={'libc.so','libm.so','libdl.so','liblog.so','libandroid.so','libOpenSLES.so','libGLESv1_CM.so','libGLESv2.so','libEGL.so','libz.so'} + for abi,directory in builds.items(): + target=stage/'app/src/main/jniLibs'/abi + for library in ['SDL2','SDL2_mixer_ext','ofe_audio','ofe_sdl_mixer','ofe_crypto']:copy(directory/'lib'/('lib'+library+'.so'),target/('lib'+library+'.so')) + triple,machine=common.ABIS[abi] + copy(host/'sysroot/usr/lib'/triple/'libc++_shared.so',target/'libc++_shared.so') + names={p.name for p in target.glob('*.so')} + for binary in target.glob('*.so'): + subprocess.run([str(host/'bin'/('llvm-strip'+suffix)),'--strip-unneeded',str(binary)],check=True) + for dependency in common.check_elf(binary,host/'bin'/('llvm-readelf'+suffix),machine): + if dependency not in names|system:raise ValueError(f'Unpackaged native dependency {dependency}: {binary}') + hashes={p.relative_to(stage).as_posix():hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(stage.rglob('*')) if p.is_file()} + (stage/'SDL_Mixer-package-manifest.json').write_text(json.dumps(hashes,indent=2)+'\n') + archive=Path(temporary)/'SDL_Mixer.zip' + with zipfile.ZipFile(archive,'w',zipfile.ZIP_DEFLATED) as zip: + for path in sorted(stage.rglob('*')): + if path.is_file():zip.write(path,path.relative_to(stage)) + archive.replace(output) + print('Published:',output) + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--ndk',type=Path);parser.add_argument('--cmake',type=Path) + parser.add_argument('--sources',type=Path,default=PORT/'out/sources') + parser.add_argument('--build-root',type=Path,default=PORT/'out/build') + parser.add_argument('--output',type=Path,default=EXTENSIONS/'SDL_Mixer/ToInstall/Files/Data/Runtime/Android/SDL_Mixer.zip') + parser.add_argument('--abi',action='append',choices=['arm64-v8a','armeabi-v7a','x86','x86_64']) + parser.add_argument('--no-pause',action='store_true',help=argparse.SUPPRESS) + args=parser.parse_args() + builder=module('ofe_mixer_builder',PORT/'build_android.py') + ninja=None + if args.ndk and args.cmake:ndk,cmake=args.ndk,args.cmake + else:ndk,cmake,ninja=module('ofe_android_tools',Path(__file__).with_name('AndroidNative.py')).native_tools() + sources=builder.prepare(args.sources.resolve()) + builds=builder.build(sources,args.build_root.resolve(),cmake.resolve(),ndk.resolve(),args.abi or builder.ABIS,ninja=ninja) + package(sources,args.sources.resolve(),builds,ndk.resolve(),args.output.resolve()) diff --git a/Extensions/_Script/SDL_Mixer_Android.bat b/Extensions/_Script/SDL_Mixer_Android.bat new file mode 100644 index 000000000..54cba6451 --- /dev/null +++ b/Extensions/_Script/SDL_Mixer_Android.bat @@ -0,0 +1,10 @@ +@echo off +setlocal EnableExtensions +py -3 --version >nul 2>nul +if not errorlevel 1 ( + py -3 "%~dp0SDL_Mixer.py" %* + if errorlevel 1 exit /b 1 + exit /b 0 +) +python "%~dp0SDL_Mixer.py" %* +exit /b %errorlevel%