diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java index 7b6362e..7b00d17 100644 --- a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java +++ b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java @@ -82,4 +82,9 @@ public final class CodecNegotiator { public static boolean isHevc(String mime) { return MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime); } + + public static boolean isVpx(String mime) { + return MediaFormat.MIMETYPE_VIDEO_VP8.equals(mime) + || MediaFormat.MIMETYPE_VIDEO_VP9.equals(mime); + } } diff --git a/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java index 4fffee9..af790f0 100644 --- a/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java +++ b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java @@ -1,6 +1,7 @@ package com.foxx.androidcast.media; import android.graphics.Bitmap; +import android.media.Image; /** * ARGB to YUV420 planar (I420) or semi-planar (NV12) for MediaCodec buffer input. @@ -32,6 +33,35 @@ public final class RgbToYuv420 { return width * height + (width * height) / 2; } + /** Fills {@code scratch} with I420 from an RGBA {@link Image} (reuses scratch when large enough). */ + public static byte[] fromRgbaImage(Image image, byte[] scratch) { + if (image == null) { + return null; + } + int width = image.getWidth(); + int height = image.getHeight(); + int need = bufferSize(width, height); + byte[] out = scratch != null && scratch.length >= need ? scratch : new byte[need]; + Image.Plane plane = image.getPlanes()[0]; + java.nio.ByteBuffer buffer = plane.getBuffer(); + int rowStride = plane.getRowStride(); + int pixelStride = plane.getPixelStride(); + int[] argb = new int[width * height]; + int offset = 0; + for (int row = 0; row < height; row++) { + int rowStart = row * rowStride; + for (int col = 0; col < width; col++) { + int pixel = buffer.getInt(rowStart + col * pixelStride); + argb[offset++] = pixel; + } + } + byte[] i420 = toI420(argb, width, height); + if (out != i420) { + System.arraycopy(i420, 0, out, 0, i420.length); + } + return out; + } + private static byte[] toI420(int[] argb, int width, int height) { int frameSize = width * height; int chromaW = (width + 1) / 2; diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxCapableVideoDecoder.java b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxCapableVideoDecoder.java new file mode 100644 index 0000000..bbb48d5 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxCapableVideoDecoder.java @@ -0,0 +1,52 @@ +package com.foxx.androidcast.media.codec; + +import android.util.Log; +import android.view.Surface; + +import com.foxx.androidcast.media.CodecNegotiator; +import com.foxx.androidcast.receiver.LibvpxVideoDecoder; +import com.foxx.androidcast.receiver.VideoDecoder; + +import java.io.IOException; + +/** + * Uses libvpx for VP8/VP9 when linked; otherwise or on failure falls back to {@link VideoDecoder}. + */ +public final class LibvpxCapableVideoDecoder implements VideoDecoderSink { + private static final String TAG = "LibvpxCapableVideoDecoder"; + + private final VideoDecoder hwDecoder = new VideoDecoder(); + private final LibvpxVideoDecoder libvpxDecoder = new LibvpxVideoDecoder(); + private VideoDecoderSink active; + + @Override + public void configure(String mime, int width, int height, byte[] csd0, byte[] csd1, Surface surface, + Listener listener) throws IOException { + if (CodecNegotiator.isVpx(mime)) { + try { + libvpxDecoder.configure(mime, width, height, csd0, csd1, surface, listener); + active = libvpxDecoder; + Log.i(TAG, "Using libvpx for " + mime); + return; + } catch (IOException e) { + Log.w(TAG, "libvpx decode unavailable, trying MediaCodec: " + e.getMessage()); + } + } + hwDecoder.configure(mime, width, height, csd0, csd1, surface, listener); + active = hwDecoder; + } + + @Override + public void queueFrame(long ptsUs, boolean keyFrame, byte[] data) { + if (active != null) { + active.queueFrame(ptsUs, keyFrame, data); + } + } + + @Override + public void release() { + hwDecoder.release(); + libvpxDecoder.release(); + active = null; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java index 5127d11..8030046 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java @@ -1,22 +1,31 @@ package com.foxx.androidcast.media.codec; import android.graphics.Bitmap; +import android.graphics.PixelFormat; +import android.media.Image; +import android.media.ImageReader; import android.media.MediaFormat; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; import android.view.Surface; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastTuningEngine; import com.foxx.androidcast.media.RgbToYuv420; import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; +import com.foxx.androidcast.media.codec.jni.VpxEncodedFrame; import com.foxx.androidcast.sender.VideoEncoder; import java.io.IOException; /** * Software VP8/VP9 via libvpx when {@link NativeCodecBridge#isLibvpxAvailable()}. - * Surface capture still uses hardware {@link VideoEncoder} (libvpx is buffer-input only today). + * Screen capture uses an {@link ImageReader} surface; calibration may use buffer or GLES paths. */ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { + private static final String TAG = "LibvpxVideoEncoder"; + private final CastSettings.VideoCodec preference; private final VideoEncoder hwFallback = new VideoEncoder(); @@ -29,12 +38,19 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { private byte[] yuvScratch; private boolean forceKeyframe; + private HandlerThread captureThread; + private Handler captureHandler; + private ImageReader imageReader; + public LibvpxVideoEncoderSink(CastSettings.VideoCodec preference) { this.preference = preference; } @Override public Surface getInputSurface() { + if (nativeActive && imageReader != null) { + return imageReader.getSurface(); + } return hwFallback.getInputSurface(); } @@ -51,14 +67,37 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { @Override public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params, Callback callback) throws IOException { - nativeActive = false; - CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime); - return hwFallback.prepare(width, height, mime, params, callback); + releaseCapture(); + this.callback = callback; + this.width = width; + this.height = height; + this.mime = mime; + yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)]; + + int bitrateKbps = Math.max(500, params.videoBitrate / 1000); + nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps); + nativeActive = nativeHandle != 0L; + if (!nativeActive) { + Log.w(TAG, "libvpx encoder init failed; using MediaCodec for surface capture"); + CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime); + return hwFallback.prepare(width, height, mime, params, callback); + } + + CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime); + captureThread = new HandlerThread("libvpx-capture"); + captureThread.start(); + captureHandler = new Handler(captureThread.getLooper()); + imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2); + imageReader.setOnImageAvailableListener(this::onImageAvailable, captureHandler); + callback.onConfig(width, height, null, null); + Log.i(TAG, "libvpx surface encoder " + width + "x" + height + " mime=" + mime); + return imageReader.getSurface(); } @Override public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params, Callback callback) throws IOException { + releaseCapture(); this.width = width; this.height = height; this.mime = mime; @@ -76,6 +115,33 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { hwFallback.prepareBufferInput(width, height, mime, params, callback); } + private void onImageAvailable(ImageReader reader) { + if (!nativeActive || callback == null) { + return; + } + try (Image image = reader.acquireLatestImage()) { + if (image == null) { + return; + } + byte[] yuv = RgbToYuv420.fromRgbaImage(image, yuvScratch); + if (yuv == null) { + return; + } + long ptsUs = image.getTimestamp() / 1000L; + emitEncodedFrame(yuv, ptsUs); + } catch (Exception e) { + Log.w(TAG, "Capture frame skipped: " + e.getMessage()); + } + } + + private void emitEncodedFrame(byte[] yuv, long ptsUs) { + VpxEncodedFrame frame = NativeCodecBridge.vpxEncodeYuvFrame(nativeHandle, yuv, ptsUs, forceKeyframe); + forceKeyframe = false; + if (frame != null && frame.data != null && callback != null) { + callback.onEncodedFrame(ptsUs, frame.keyFrame, frame.data); + } + } + @Override public void queueBitmapFrame(Bitmap bitmap, long ptsUs) { if (!nativeActive) { @@ -83,7 +149,7 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { return; } byte[] yuv = RgbToYuv420.fromBitmap(bitmap, RgbToYuv420.Layout.I420); - queueYuvFrame(yuv, ptsUs); + emitEncodedFrame(yuv, ptsUs); } @Override @@ -92,12 +158,7 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { hwFallback.queueYuvFrame(yuv, ptsUs); return; } - byte[] frame = NativeCodecBridge.vpxEncodeYuv(nativeHandle, yuv, ptsUs, forceKeyframe); - forceKeyframe = false; - if (frame != null && callback != null) { - boolean key = frame.length > 0 && (frame[0] & 0x01) == 0; - callback.onEncodedFrame(ptsUs, key, frame); - } + emitEncodedFrame(yuv, ptsUs); } @Override @@ -107,9 +168,22 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { nativeHandle = 0L; nativeActive = false; } + releaseCapture(); hwFallback.stop(); } + private void releaseCapture() { + if (imageReader != null) { + imageReader.close(); + imageReader = null; + } + if (captureThread != null) { + captureThread.quitSafely(); + captureThread = null; + captureHandler = null; + } + } + static String mimeFor(CastSettings.VideoCodec preference) { if (preference == CastSettings.VideoCodec.LIBVPX_VP9) { return MediaFormat.MIMETYPE_VIDEO_VP9; diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java b/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java index aa5a527..5d2b324 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java @@ -4,6 +4,7 @@ import android.media.MediaFormat; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.media.CodecNegotiator; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; /** * Maps UI / future software codec choices to wire negotiation and HW backends. @@ -30,7 +31,8 @@ public final class PassthroughCodecPolicy { public static VideoCodecBackend videoBackendFor(CastSettings.VideoCodec preference) { if (preference == CastSettings.VideoCodec.LIBVPX_VP8 || preference == CastSettings.VideoCodec.LIBVPX_VP9) { - return VideoCodecBackend.LIBVPX_STUB; + return NativeCodecBridge.isLibvpxAvailable() + ? VideoCodecBackend.LIBVPX_NATIVE : VideoCodecBackend.LIBVPX_STUB; } if (preference == CastSettings.VideoCodec.PASSTHROUGH) { return VideoCodecBackend.PASSTHROUGH; diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/VideoDecoderFactory.java b/app/src/main/java/com/foxx/androidcast/media/codec/VideoDecoderFactory.java index 2e61fc8..bf05d9c 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/VideoDecoderFactory.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/VideoDecoderFactory.java @@ -1,5 +1,6 @@ package com.foxx.androidcast.media.codec; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; import com.foxx.androidcast.receiver.VideoDecoder; /** Creates video decoder sinks. */ @@ -7,6 +8,9 @@ public final class VideoDecoderFactory { private VideoDecoderFactory() {} public static VideoDecoderSink createDecoder() { + if (NativeCodecBridge.isLibvpxAvailable()) { + return new LibvpxCapableVideoDecoder(); + } return new VideoDecoder(); } } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java index 2d048ee..ae6aff9 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java @@ -1,6 +1,7 @@ package com.foxx.androidcast.media.codec.jni; import android.util.Log; +import android.view.Surface; /** JNI probes for third-party codecs built from {@code ndk/} + {@code third-party/}. */ public final class NativeCodecBridge { @@ -47,10 +48,36 @@ public final class NativeCodecBridge { } public static byte[] vpxEncodeYuv(long handle, byte[] i420, long ptsUs, boolean forceKeyframe) { + VpxEncodedFrame frame = vpxEncodeYuvFrame(handle, i420, ptsUs, forceKeyframe); + return frame != null ? frame.data : null; + } + + public static VpxEncodedFrame vpxEncodeYuvFrame(long handle, byte[] i420, long ptsUs, + boolean forceKeyframe) { if (handle == 0L || i420 == null) { return null; } - return nativeVpxEncodeYuv(handle, i420, ptsUs, forceKeyframe); + return nativeVpxEncodeYuvFrame(handle, i420, ptsUs, forceKeyframe); + } + + public static long vpxDecoderCreate(String mime) { + if (!isLibvpxAvailable()) { + return 0L; + } + return nativeVpxDecoderCreate(mime); + } + + public static boolean vpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface) { + if (handle == 0L || data == null || surface == null) { + return false; + } + return nativeVpxDecodeFrame(handle, data, ptsUs, surface); + } + + public static void vpxDecoderRelease(long handle) { + if (handle != 0L) { + nativeVpxDecoderRelease(handle); + } } public static void vpxEncoderRelease(long handle) { @@ -75,7 +102,16 @@ public final class NativeCodecBridge { private static native byte[] nativeVpxEncodeYuv(long handle, byte[] yuv, long ptsUs, boolean forceKeyframe); + private static native VpxEncodedFrame nativeVpxEncodeYuvFrame(long handle, byte[] yuv, long ptsUs, + boolean forceKeyframe); + private static native void nativeVpxEncoderRelease(long handle); private static native void nativeVpxRequestKeyframe(long handle); + + private static native long nativeVpxDecoderCreate(String mime); + + private static native boolean nativeVpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface); + + private static native void nativeVpxDecoderRelease(long handle); } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/jni/VpxEncodedFrame.java b/app/src/main/java/com/foxx/androidcast/media/codec/jni/VpxEncodedFrame.java new file mode 100644 index 0000000..d3850f9 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/codec/jni/VpxEncodedFrame.java @@ -0,0 +1,12 @@ +package com.foxx.androidcast.media.codec.jni; + +/** One compressed VP8/VP9 frame from libvpx. */ +public final class VpxEncodedFrame { + public final byte[] data; + public final boolean keyFrame; + + public VpxEncodedFrame(byte[] data, boolean keyFrame) { + this.data = data; + this.keyFrame = keyFrame; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java new file mode 100644 index 0000000..a1c3493 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java @@ -0,0 +1,85 @@ +package com.foxx.androidcast.receiver; + +import android.util.Log; +import android.view.Surface; + +import com.foxx.androidcast.media.codec.VideoDecoderSink; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; + +import java.io.IOException; + +/** Software VP8/VP9 decode via libvpx, rendering to a {@link Surface}. */ +public final class LibvpxVideoDecoder implements VideoDecoderSink { + private static final String TAG = "LibvpxVideoDecoder"; + + private final Object lock = new Object(); + private long nativeHandle; + private Surface outputSurface; + private Listener listener; + private boolean released; + private boolean firstFrameNotified; + + @Override + public void configure(String mime, int width, int height, byte[] csd0, byte[] csd1, Surface surface, + Listener listener) throws IOException { + synchronized (lock) { + releaseLocked(); + if (!NativeCodecBridge.isLibvpxAvailable()) { + throw new IOException("libvpx not available"); + } + nativeHandle = NativeCodecBridge.vpxDecoderCreate(mime); + if (nativeHandle == 0L) { + throw new IOException("libvpx decoder init failed for " + mime); + } + this.outputSurface = surface; + this.listener = listener; + this.released = false; + this.firstFrameNotified = false; + Log.i(TAG, "Configured libvpx decoder " + mime + " " + width + "x" + height); + } + } + + @Override + public void queueFrame(long ptsUs, boolean keyFrame, byte[] data) { + synchronized (lock) { + if (released || nativeHandle == 0L || outputSurface == null || data == null || data.length == 0) { + return; + } + if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) { + notifyRenderedLocked(); + } + } + } + + @Override + public void release() { + synchronized (lock) { + releaseLocked(); + } + } + + private void releaseLocked() { + released = true; + listener = null; + outputSurface = null; + firstFrameNotified = false; + if (nativeHandle != 0L) { + NativeCodecBridge.vpxDecoderRelease(nativeHandle); + nativeHandle = 0L; + } + } + + private void notifyRenderedLocked() { + Listener cb = listener; + if (cb == null) { + return; + } + if (!firstFrameNotified) { + firstFrameNotified = true; + Log.i(TAG, "First frame rendered (libvpx)"); + cb.onFirstFrameRendered(); + } else { + cb.onFrameRendered(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java index 5d786af..f4ff2aa 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java +++ b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java @@ -6,6 +6,7 @@ import android.media.MediaFormat; import android.os.Build; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -121,6 +122,10 @@ public final class CodecCatalog { if (out.isEmpty()) { out.add(MediaFormat.MIMETYPE_VIDEO_AVC); } + if (NativeCodecBridge.isLibvpxAvailable()) { + out.add(MediaFormat.MIMETYPE_VIDEO_VP8); + out.add(MediaFormat.MIMETYPE_VIDEO_VP9); + } return new ArrayList<>(out); } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 05125da..15240da 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -475,8 +475,6 @@ public class ScreenCastService extends Service implements } videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec()); - CodecSessionRegistry.setVideoBackend( - PassthroughCodecPolicy.videoBackendFor(settings.getVideoCodec()), mime); CodecSessionRegistry.setProtectionSummary(PassthroughCodecPolicy.protectionDescription( settings.getStreamProtection(), settings.getTransport(), settings.getAudioCodec())); VideoEncoderSink.Callback videoCb = buildVideoEncoderCallback(mime); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 841f1a8..737d051 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,10 +22,11 @@ Enable in **Settings → Stream protection (UDP)** on both sender and receiver, | Item | Status | |------|--------| | `libandroidcast_codecs.so` + JNI probes | Done | -| Cross-compile `libvpx.a` (`scripts/build-native-codecs.sh`) | Script ready (needs submodule + NDK build) | -| JNI encode in `libvpx_bridge.c` | Done (when `ANDROIDCAST_LIBVPX_LIB` set) | -| `LibvpxVideoEncoderSink` (buffer / calibration path) | Done | -| libvpx decoder on receiver | TODO | +| Cross-compile `libvpx.a` (`scripts/build-native-codecs.sh`) | Done | +| JNI encode/decode in `libvpx_bridge.c` | Done (when `libvpx.a` linked per ABI) | +| `LibvpxVideoEncoderSink` (surface + buffer) | Done | +| libvpx decoder on receiver (`LibvpxCapableVideoDecoder`) | Done | +| Surface → libvpx without MediaCodec passthrough on cast | Done (ImageReader) | | Opus / Speex build + sinks | TODO | See [ndk/README.md](../ndk/README.md). diff --git a/ndk/CMakeLists.txt b/ndk/CMakeLists.txt index 9a5a89b..ebb6839 100644 --- a/ndk/CMakeLists.txt +++ b/ndk/CMakeLists.txt @@ -46,4 +46,5 @@ else() endif() find_library(log-lib log) -target_link_libraries(androidcast_codecs ${log-lib}) +find_library(android-lib android) +target_link_libraries(androidcast_codecs ${log-lib} ${android-lib}) diff --git a/ndk/README.md b/ndk/README.md index 87a604e..3de1b50 100644 --- a/ndk/README.md +++ b/ndk/README.md @@ -8,13 +8,16 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft |-----------|------|--------|-------| | FEC 3/4 + RS | Yes | — | v2 **per-shard** on wire (`FecShardWire`); v1 monolithic decode still accepted | | NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled | -| libvpx VP8/VP9 | Stub | Probe only | HW passthrough until `ANDROIDCAST_LIBVPX_LIB` is linked | +| libvpx VP8/VP9 | Native | Encode + decode | Screen cast via ImageReader; receiver libvpx or MediaCodec fallback | | Opus / Speex | Stub | Probe only | Submodules under `third-party/` | ## Enable libvpx (developer) 1. `git submodule update --init third-party/libvpx` -2. Build `libvpx.a` for your ABI: `./scripts/build-native-codecs.sh arm64-v8a` +2. Build `libvpx.a` per ABI (NDK is auto-detected; see `scripts/android-ndk.sh`): + + `./scripts/build-native-codecs.sh arm64-v8a` + (If the repo path has **spaces**, the script uses `/tmp/androidcast-libvpx-$USER` automatically.) 3. Build **each ABI** you ship (Gradle links `build/native//libvpx.a` automatically): diff --git a/ndk/jni/libvpx_bridge.c b/ndk/jni/libvpx_bridge.c index 533aa7b..e5e3ed4 100644 --- a/ndk/jni/libvpx_bridge.c +++ b/ndk/jni/libvpx_bridge.c @@ -1,4 +1,6 @@ #include +#include +#include #include #include @@ -14,6 +16,7 @@ #endif #define VPX_ENC_MAGIC 0x56505845L /* VPXE */ +#define VPX_DEC_MAGIC 0x56505844L /* VPXD */ #if ANDROIDCAST_HAVE_LIBVPX @@ -34,6 +37,13 @@ static vpx_codec_iface_t *pick_encoder_iface(const char *mime) { return vpx_codec_vp8_cx(); } +static vpx_codec_iface_t *pick_decoder_iface(const char *mime) { + if (mime != NULL && strstr(mime, "VP9") != NULL) { + return vpx_codec_vp9_dx(); + } + return vpx_codec_vp8_dx(); +} + static vpx_img_fmt_t img_fmt(void) { return VPX_IMG_FMT_I420; } @@ -63,6 +73,75 @@ static VpxEncoderCtx *enc_from_handle(jlong handle) { return ctx; } +typedef struct { + unsigned long magic; + vpx_codec_ctx_t codec; +} VpxDecoderCtx; + +static VpxDecoderCtx *dec_from_handle(jlong handle) { + VpxDecoderCtx *ctx = (VpxDecoderCtx *) (intptr_t) handle; + if (ctx == NULL || ctx->magic != VPX_DEC_MAGIC) { + return NULL; + } + return ctx; +} + +static void render_i420_to_window(ANativeWindow *window, const vpx_image_t *img) { + if (window == NULL || img == NULL) { + return; + } + const int width = (int) img->d_w; + const int height = (int) img->d_h; + if (width <= 0 || height <= 0) { + return; + } + ANativeWindow_setBuffersGeometry(window, width, height, WINDOW_FORMAT_RGBA_8888); + ANativeWindow_Buffer buffer; + if (ANativeWindow_lock(window, &buffer, NULL) != 0) { + return; + } + uint8_t *dst = (uint8_t *) buffer.bits; + const int dst_stride = buffer.stride * 4; + const uint8_t *y_plane = img->planes[VPX_PLANE_Y]; + const uint8_t *u_plane = img->planes[VPX_PLANE_U]; + const uint8_t *v_plane = img->planes[VPX_PLANE_V]; + const int y_stride = img->stride[VPX_PLANE_Y]; + const int u_stride = img->stride[VPX_PLANE_U]; + const int v_stride = img->stride[VPX_PLANE_V]; + + for (int row = 0; row < height; row++) { + uint8_t *row_dst = dst + row * dst_stride; + for (int col = 0; col < width; col++) { + int y = y_plane[row * y_stride + col]; + int u = u_plane[(row / 2) * u_stride + (col / 2)] - 128; + int v = v_plane[(row / 2) * v_stride + (col / 2)] - 128; + int r = y + ((351 * v) >> 8); + int g = y - ((179 * v + 86 * u) >> 8); + int b = y + ((443 * u) >> 8); + if (r < 0) { + r = 0; + } else if (r > 255) { + r = 255; + } + if (g < 0) { + g = 0; + } else if (g > 255) { + g = 255; + } + if (b < 0) { + b = 0; + } else if (b > 255) { + b = 255; + } + row_dst[col * 4] = (uint8_t) r; + row_dst[col * 4 + 1] = (uint8_t) g; + row_dst[col * 4 + 2] = (uint8_t) b; + row_dst[col * 4 + 3] = 255; + } + } + ANativeWindow_unlockAndPost(window); +} + #endif /* ANDROIDCAST_HAVE_LIBVPX */ JNIEXPORT jboolean JNICALL @@ -132,8 +211,8 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncoderCrea #endif } -JNIEXPORT jbyteArray JNICALL -Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncodeYuv( +JNIEXPORT jobject JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncodeYuvFrame( JNIEnv *env, jclass clazz, jlong handle, jbyteArray yuv, jlong ptsUs, jboolean forceKeyframe) { (void) clazz; #if !ANDROIDCAST_HAVE_LIBVPX @@ -167,7 +246,7 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncodeYuv( } if (vpx_codec_encode(&ctx->codec, &ctx->img, (vpx_codec_pts_t) ptsUs, 1, flags, - VPX_DL_REALTIME) != VPX_CODEC_OK) { + VPX_DL_REALTIME) != VPX_CODEC_OK) { return NULL; } @@ -176,18 +255,47 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncodeYuv( while ((pkt = vpx_codec_get_cx_data(&ctx->codec, &iter)) != NULL) { if (pkt->kind == VPX_CODEC_CX_FRAME_PKT) { jbyteArray out = (*env)->NewByteArray(env, (jsize) pkt->data.frame.sz); - if (out != NULL) { - (*env)->SetByteArrayRegion(env, out, 0, (jsize) pkt->data.frame.sz, - (const jbyte *) pkt->data.frame.buf); + if (out == NULL) { + return NULL; } + (*env)->SetByteArrayRegion(env, out, 0, (jsize) pkt->data.frame.sz, + (const jbyte *) pkt->data.frame.buf); + jboolean key = (pkt->data.frame.flags & VPX_FRAME_IS_KEY) ? JNI_TRUE : JNI_FALSE; + jclass frame_cls = (*env)->FindClass( + env, "com/foxx/androidcast/media/codec/jni/VpxEncodedFrame"); + jmethodID ctor = (*env)->GetMethodID(env, frame_cls, "", "([BZ)V"); + jobject frame = (*env)->NewObject(env, frame_cls, ctor, out, key); ctx->frame_count++; - return out; + return frame; } } return NULL; #endif } +JNIEXPORT jbyteArray JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncodeYuv( + JNIEnv *env, jclass clazz, jlong handle, jbyteArray yuv, jlong ptsUs, jboolean forceKeyframe) { +#if !ANDROIDCAST_HAVE_LIBVPX + (void) env; + (void) clazz; + (void) handle; + (void) yuv; + (void) ptsUs; + (void) forceKeyframe; + return NULL; +#else + jobject frame = Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncodeYuvFrame( + env, clazz, handle, yuv, ptsUs, forceKeyframe); + if (frame == NULL) { + return NULL; + } + jclass frame_cls = (*env)->GetObjectClass(env, frame); + jfieldID data_f = (*env)->GetFieldID(env, frame_cls, "data", "[B"); + return (jbyteArray) (*env)->GetObjectField(env, frame, data_f); +#endif +} + JNIEXPORT void JNICALL Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncoderRelease( JNIEnv *env, jclass clazz, jlong handle) { @@ -217,3 +325,95 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxRequestKeyf } #endif } + +JNIEXPORT jlong JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCreate( + JNIEnv *env, jclass clazz, jstring mime) { + (void) clazz; +#if !ANDROIDCAST_HAVE_LIBVPX + (void) env; + (void) mime; + return 0L; +#else + const char *mime_utf = (*env)->GetStringUTFChars(env, mime, NULL); + VpxDecoderCtx *ctx = (VpxDecoderCtx *) calloc(1, sizeof(VpxDecoderCtx)); + if (ctx == NULL) { + (*env)->ReleaseStringUTFChars(env, mime, mime_utf); + return 0L; + } + ctx->magic = VPX_DEC_MAGIC; + vpx_codec_dec_cfg_t cfg = {0}; + cfg.threads = 1; + if (vpx_codec_dec_init(&ctx->codec, pick_decoder_iface(mime_utf), &cfg, 0) != VPX_CODEC_OK) { + free(ctx); + (*env)->ReleaseStringUTFChars(env, mime, mime_utf); + return 0L; + } + (*env)->ReleaseStringUTFChars(env, mime, mime_utf); + return (jlong) (intptr_t) ctx; +#endif +} + +JNIEXPORT jboolean JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecodeFrame( + JNIEnv *env, jclass clazz, jlong handle, jbyteArray data, jlong ptsUs, jobject surface) { + (void) clazz; + (void) ptsUs; +#if !ANDROIDCAST_HAVE_LIBVPX + (void) env; + (void) handle; + (void) data; + (void) surface; + return JNI_FALSE; +#else + VpxDecoderCtx *ctx = dec_from_handle(handle); + if (ctx == NULL || data == NULL || surface == NULL) { + return JNI_FALSE; + } + jsize len = (*env)->GetArrayLength(env, data); + if (len <= 0) { + return JNI_FALSE; + } + jbyte *bytes = (*env)->GetByteArrayElements(env, data, NULL); + if (bytes == NULL) { + return JNI_FALSE; + } + vpx_codec_err_t err = vpx_codec_decode(&ctx->codec, (const uint8_t *) bytes, (unsigned int) len, + NULL, 0); + (*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT); + if (err != VPX_CODEC_OK) { + return JNI_FALSE; + } + + ANativeWindow *window = ANativeWindow_fromSurface(env, surface); + if (window == NULL) { + return JNI_FALSE; + } + + vpx_codec_iter_t iter = NULL; + vpx_image_t *img; + jboolean rendered = JNI_FALSE; + while ((img = vpx_codec_get_frame(&ctx->codec, &iter)) != NULL) { + render_i420_to_window(window, img); + rendered = JNI_TRUE; + } + ANativeWindow_release(window); + return rendered; +#endif +} + +JNIEXPORT void JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderRelease( + JNIEnv *env, jclass clazz, jlong handle) { + (void) env; + (void) clazz; +#if ANDROIDCAST_HAVE_LIBVPX + VpxDecoderCtx *ctx = dec_from_handle(handle); + if (ctx == NULL) { + return; + } + ctx->magic = 0; + vpx_codec_destroy(&ctx->codec); + free(ctx); +#endif +} diff --git a/rebuild.sh b/rebuild.sh index 9dc784b..a504f46 100755 --- a/rebuild.sh +++ b/rebuild.sh @@ -107,9 +107,9 @@ reconnect_known_devices() { adb devices -l } -# shellcheck source=android_find_latest_ndk.sh -source "$ROOT/android_find_latest_ndk.sh" -export ANDROID_NDK_HOME="$(android_find_ndk_root "$ROOT")" +# shellcheck source=scripts/android-ndk.sh +source "$ROOT/scripts/android-ndk.sh" +export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")" export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" echo "==> Using NDK: $ANDROID_NDK_HOME" diff --git a/scripts/android-ndk.sh b/scripts/android-ndk.sh new file mode 100755 index 0000000..770fd9e --- /dev/null +++ b/scripts/android-ndk.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Android NDK discovery for AndroidCast build scripts. +# Source this file: source "$(dirname "$0")/android-ndk.sh" +# +# Optional env: +# ANDROIDCAST_NDK_VERSION — pin a version under $HOME/Android/Sdk/ndk (e.g. 29.0.14206865) +# ANDROID_NDK_HOME / ANDROID_NDK_ROOT — used when valid +# +set -euo pipefail + +_androidcast_sdk_base() { + printf '%s\n' "${ANDROIDCAST_SDK_BASE:-${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}}" +} + +# Print absolute NDK root (directory containing ndk-build). Errors on stderr. +androidcast_find_ndk_root() { + local project_root="${1:-}" + local ndk="" ndk_root ndk_ver parent pinned + + if [[ -n "$project_root" && -f "$project_root/local.properties" ]]; then + ndk="$(sed -n 's/^[[:space:]]*ndk\.dir=//p' "$project_root/local.properties" | tr -d '\r' | head -1)" + ndk="${ndk%/}" + while [[ -n "$ndk" ]]; do + if [[ -x "$ndk/ndk-build" ]]; then + realpath "$ndk" + return 0 + fi + parent="$(dirname "$ndk")" + [[ "$parent" == "$ndk" ]] && break + ndk="$parent" + done + fi + + for ndk in "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_ROOT:-}"; do + ndk="${ndk%/}" + if [[ -n "$ndk" && -x "$ndk/ndk-build" ]]; then + realpath "$ndk" + return 0 + fi + done + + ndk_root="$(_androidcast_sdk_base)/ndk" + if [[ ! -d "$ndk_root" ]]; then + echo "ERROR: No Android NDK found. Install via SDK Manager ($ndk_root) or set ANDROID_NDK_HOME." >&2 + return 1 + fi + + pinned="${ANDROIDCAST_NDK_VERSION:-}" + if [[ -n "$pinned" ]]; then + if [[ -d "$ndk_root/$pinned" ]]; then + ndk_ver="$pinned" + else + echo "ERROR: ANDROIDCAST_NDK_VERSION=$pinned not found under $ndk_root" >&2 + return 1 + fi + else + ndk_ver="$(ls -1 "$ndk_root" 2>/dev/null | sort -V | tail -n 1 || true)" + if [[ -z "$ndk_ver" ]]; then + echo "ERROR: No NDK versions under $ndk_root" >&2 + return 1 + fi + fi + + ndk="$ndk_root/$ndk_ver" + if [[ ! -x "$ndk/ndk-build" ]]; then + echo "ERROR: ndk-build missing under $ndk" >&2 + return 1 + fi + realpath "$ndk" +} + +# Print llvm prebuilt bin directory for the current host. +androidcast_ndk_host_bin() { + local ndk_root="${1:?NDK root required}" + local os arch host_tag + case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) os="$(uname -s | tr '[:upper:]' '[:lower:]')" ;; + esac + arch="$(uname -m)" + case "$arch" in + x86_64 | amd64) arch=x86_64 ;; + aarch64 | arm64) arch=aarch64 ;; + esac + host_tag="${os}-${arch}" + if [[ ! -d "$ndk_root/toolchains/llvm/prebuilt/${host_tag}/bin" ]]; then + host_tag="linux-x86_64" + fi + realpath "$ndk_root/toolchains/llvm/prebuilt/${host_tag}/bin" +} diff --git a/scripts/build-native-codecs.sh b/scripts/build-native-codecs.sh index b0ce424..f256de4 100755 --- a/scripts/build-native-codecs.sh +++ b/scripts/build-native-codecs.sh @@ -6,7 +6,7 @@ # # Prerequisites: # git submodule update --init third-party/libvpx -# Android NDK (ndk.dir in local.properties or ANDROID_NDK_HOME) +# Android NDK (auto-detected via scripts/android-ndk.sh) # # Usage: # ./scripts/build-native-codecs.sh arm64-v8a @@ -27,12 +27,12 @@ if [[ ! -d "$VPX_SRC_REAL" ]]; then exit 1 fi -# shellcheck source=android_find_latest_ndk.sh -source "$ROOT/android_find_latest_ndk.sh" -if ! NDK="$(android_find_ndk_root "$ROOT")"; then +# shellcheck source=android-ndk.sh +source "$ROOT/scripts/android-ndk.sh" +if ! NDK="$(androidcast_find_ndk_root "$ROOT")"; then exit 1 fi -NDK_HOST_BIN="$(android_ndk_host_prebuilt_bin "$NDK")" +NDK_HOST_BIN="$(androidcast_ndk_host_bin "$NDK")" if [[ ! -d "$NDK_HOST_BIN" ]]; then echo "ERROR: NDK host toolchain bin not found under $NDK" exit 1