Files
rdplib/plugin/rdpgfx/ffmpeg/h264_ffmpeg.go
T

3078 lines
132 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build h264
package ffmpeg
/*
#cgo pkg-config: libavcodec libavutil libswscale
#cgo nocallback avcodec_alloc_context3
#cgo nocallback avcodec_find_decoder
#cgo nocallback avcodec_flush_buffers
#cgo nocallback avcodec_free_context
#cgo nocallback avcodec_get_hw_config
#cgo nocallback avcodec_open2
#cgo nocallback avcodec_receive_frame
#cgo nocallback avcodec_send_packet
#cgo nocallback av_buffer_ref
#cgo nocallback av_buffer_unref
#cgo nocallback av_frame_alloc
#cgo nocallback av_frame_free
#cgo nocallback av_frame_unref
#cgo nocallback av_hwdevice_ctx_create
#cgo nocallback av_hwdevice_get_type_name
#cgo nocallback av_hwdevice_iterate_types
#cgo nocallback av_hwframe_transfer_data
#cgo nocallback av_packet_alloc
#cgo nocallback av_packet_free
#cgo nocallback grdp_find_v4l2m2m
#cgo nocallback grdp_hwframe_map
#cgo nocallback grdp_is_full_range_fmt
#cgo nocallback grdp_set_get_format
#cgo nocallback grdp_set_hw_pix_fmt
#cgo nocallback grdp_set_low_delay
#cgo nocallback grdp_suppress_av_log
#cgo nocallback grdp_yuvj_to_yuv
#cgo nocallback sws_freeContext
#cgo nocallback sws_getContext
#cgo nocallback grdp_sws_set_src_range
#cgo noescape avcodec_send_packet
#cgo noescape grdp_copy_yuv420p_to_i420
#cgo nocallback grdp_copy_yuv420p_to_i420
#cgo noescape grdp_copy_nv12_to_i420
#cgo nocallback grdp_copy_nv12_to_i420
#cgo noescape grdp_copy_nv12
#cgo nocallback grdp_copy_nv12
#cgo noescape grdp_yuv420p_to_bgra_regions
#cgo nocallback grdp_yuv420p_to_bgra_regions
#cgo noescape grdp_yuv420p_to_bgra_rows
#cgo nocallback grdp_yuv420p_to_bgra_rows
#cgo noescape grdp_nv12_to_bgra_regions
#cgo nocallback grdp_nv12_to_bgra_regions
#cgo noescape grdp_nv12_to_bgra_rows
#cgo nocallback grdp_nv12_to_bgra_rows
#cgo noescape grdp_frame_to_bgra
#cgo nocallback grdp_frame_to_bgra
#cgo noescape grdp_sample_nv12
#cgo nocallback grdp_sample_nv12
#cgo noescape grdp_sample_yuv
#cgo nocallback grdp_sample_yuv
#cgo noescape grdp_sample_nv12_at
#cgo nocallback grdp_sample_nv12_at
#cgo nocallback grdp_is_warmup_nv12
#cgo noescape grdp_is_low_chroma_nv12
#cgo nocallback grdp_is_low_chroma_nv12
#cgo noescape grdp_is_low_chroma_yuv420p
#cgo nocallback grdp_is_low_chroma_yuv420p
#include <libavcodec/avcodec.h>
#include <libavutil/imgutils.h>
#include <libavutil/hwcontext.h>
#include <libavutil/log.h>
#include <libswscale/swscale.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#ifdef __ARM_NEON__
#include <arm_neon.h>
#endif
#ifdef __SSE2__
#include <emmintrin.h>
#endif
// grdp_suppress_av_log sets FFmpeg's global log level to FATAL so that
// decoder-level error messages (e.g. "sps_id out of range", "no frame!")
// are not printed to stderr. Those messages are expected and harmless
// during H.264 stream recovery; grdp emits its own slog warnings instead.
static void grdp_suppress_av_log(void) {
av_log_set_level(AV_LOG_FATAL);
}
// get_format callback that prefers the hardware pixel format stored in opaque.
static enum AVPixelFormat grdp_get_hw_format(
AVCodecContext *ctx, const enum AVPixelFormat *pix_fmts) {
enum AVPixelFormat hw_fmt = (enum AVPixelFormat)(intptr_t)ctx->opaque;
if (hw_fmt == AV_PIX_FMT_NONE) return pix_fmts[0];
for (const enum AVPixelFormat *p = pix_fmts; *p != AV_PIX_FMT_NONE; p++) {
if (*p == hw_fmt) return *p;
}
return pix_fmts[0];
}
static void grdp_set_get_format(AVCodecContext *ctx) {
ctx->get_format = grdp_get_hw_format;
}
// grdp_set_low_delay enables AV_CODEC_FLAG_LOW_DELAY on the codec context
// so the decoder emits frames as soon as they are decoded, without waiting
// to reorder B-frames. RDP H.264 streams transmit in display order and do
// not use B-frame reordering, so the default reorder buffer only adds
// apparent latency and (on VideoToolbox) makes legitimate frames look like
// "null frames" to our stall detector, triggering spurious hard resets.
static void grdp_set_low_delay(AVCodecContext *ctx) {
ctx->flags |= AV_CODEC_FLAG_LOW_DELAY;
ctx->flags2 |= AV_CODEC_FLAG2_FAST;
}
static void grdp_set_hw_pix_fmt(AVCodecContext *ctx, enum AVPixelFormat fmt) {
ctx->opaque = (void*)(intptr_t)fmt;
}
// grdp_hwframe_map attempts a zero-copy CPU mapping of a hardware frame.
// On VideoToolbox (macOS), decoded frames live in IOSurface-backed memory that
// is accessible from both CPU and GPU. av_hwframe_map creates a mapped view
// without copying the pixel data, allowing NV12 extraction without the extra
// GPU→RAM copy that av_hwframe_transfer_data would perform.
// Returns 0 on success; callers must fall back to av_hwframe_transfer_data
// on negative return (hardware type does not support mapping).
static int grdp_hwframe_map(AVFrame *dst, const AVFrame *src) {
return av_hwframe_map(dst, src, AV_HWFRAME_MAP_READ);
}
// Helper: convert AVFrame to BGRA via swscale.
static int grdp_frame_to_bgra(struct SwsContext *sws,
AVFrame *src, uint8_t *dst, int dst_stride) {
uint8_t *dst_data[4] = {dst, NULL, NULL, NULL};
int dst_linesize[4] = {dst_stride, 0, 0, 0};
return sws_scale(sws,
(const uint8_t *const *)src->data, src->linesize,
0, src->height,
dst_data, dst_linesize);
}
// Map deprecated YUVJ pixel formats to their non-J equivalents.
// YUVJ formats are full-range YUV; the modern way is to use the plain YUV
// format and communicate the range via sws_setColorspaceDetails.
static enum AVPixelFormat grdp_yuvj_to_yuv(enum AVPixelFormat fmt) {
switch (fmt) {
case AV_PIX_FMT_YUVJ420P: return AV_PIX_FMT_YUV420P;
case AV_PIX_FMT_YUVJ422P: return AV_PIX_FMT_YUV422P;
case AV_PIX_FMT_YUVJ444P: return AV_PIX_FMT_YUV444P;
case AV_PIX_FMT_YUVJ440P: return AV_PIX_FMT_YUV440P;
default: return fmt;
}
}
// Return 1 if fmt is a full-range (YUVJ) format, 0 otherwise.
static int grdp_is_full_range_fmt(enum AVPixelFormat fmt) {
return (fmt == AV_PIX_FMT_YUVJ420P ||
fmt == AV_PIX_FMT_YUVJ422P ||
fmt == AV_PIX_FMT_YUVJ444P ||
fmt == AV_PIX_FMT_YUVJ440P) ? 1 : 0;
}
// grdp_bt601_pixel writes one BGRA pixel using BT.601 coefficients.
// u and v are pre-offset (i.e. raw_value - 128).
// full_range: 0 = limited (video) range [16-235 / 16-240],
// 1 = full range [0-255].
#define CLAMP8(x) ((x) < 0 ? 0 : (x) > 255 ? 255 : (uint8_t)(x))
static inline void grdp_bt601_pixel(
int y_raw, int u, int v, int full_range, uint8_t *dst)
{
int r, g, b;
if (full_range) {
int y = y_raw;
r = (256*y + 359*v + 128) >> 8;
g = (256*y - 88*u - 183*v + 128) >> 8;
b = (256*y + 454*u + 128) >> 8;
} else {
int c = y_raw - 16;
r = (298*c + 409*v + 128) >> 8;
g = (298*c - 100*u - 208*v + 128) >> 8;
b = (298*c + 516*u + 128) >> 8;
}
dst[0] = CLAMP8(b);
dst[1] = CLAMP8(g);
dst[2] = CLAMP8(r);
dst[3] = 255;
}
// grdp_yuv420p_to_bgra converts a planar YUV420P/YUVJ420P frame to packed
// BGRA using BT.601 coefficients. This bypasses swscale entirely so that
// the broken ARM64 colorspace-matrix fallback path is never taken.
#ifdef __ARM_NEON__
// grdp_yuv420p_to_bgra_neon_8 processes 8 luma pixels (4 UV pairs) per call.
// For YUV420P each UV sample covers 2 horizontal luma pixels; we load 4 U and
// 4 V bytes and duplicate each with vzip to produce 8 per-pixel U/V vectors,
// then follow the same NEON arithmetic path as grdp_nv12_to_bgra_neon_8.
static inline void grdp_yuv420p_to_bgra_neon_8(
const uint8_t *yrow, const uint8_t *urow, const uint8_t *vrow,
uint8_t *drow, int col,
int16_t ky, int16_t kr, int16_t kgu, int16_t kgv, int16_t kb,
int16_t yoff)
{
// Load 8 luma bytes, convert to int16, subtract Y offset (16 or 0).
uint8x8_t y_u8 = vld1_u8(yrow + col);
int16x8_t c16 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(y_u8)),
vdupq_n_s16(yoff));
// Load 4 U and 4 V bytes (one UV pair per 2 luma pixels).
// vzip duplicates each byte: [U0,U1,U2,U3,...] → [U0,U0,U1,U1,U2,U2,U3,U3].
// ffmpeg pads AVFrame line buffers for SIMD so loading 8 bytes is safe.
uint8x8_t u_raw = vld1_u8(urow + (col >> 1));
uint8x8_t v_raw = vld1_u8(vrow + (col >> 1));
uint8x8_t u8 = vzip_u8(u_raw, u_raw).val[0];
uint8x8_t v8 = vzip_u8(v_raw, v_raw).val[0];
int16x8_t u16 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(u8)), vdupq_n_s16(128));
int16x8_t v16 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(v8)), vdupq_n_s16(128));
// Compute R/G/B with int32 to avoid overflow. Process 4+4 pixels.
int16x4_t c_lo = vget_low_s16(c16), u_lo = vget_low_s16(u16), v_lo = vget_low_s16(v16);
int16x4_t c_hi = vget_high_s16(c16), u_hi = vget_high_s16(u16), v_hi = vget_high_s16(v16);
int32x4_t ky_lo = vmull_n_s16(c_lo, ky), ky_hi = vmull_n_s16(c_hi, ky);
int32x4_t r_lo = vaddq_s32(vaddq_s32(ky_lo, vmull_n_s16(v_lo, kr)), vdupq_n_s32(128));
int32x4_t g_lo = vaddq_s32(vsubq_s32(vsubq_s32(ky_lo, vmull_n_s16(u_lo, kgu)), vmull_n_s16(v_lo, kgv)), vdupq_n_s32(128));
int32x4_t b_lo = vaddq_s32(vaddq_s32(ky_lo, vmull_n_s16(u_lo, kb)), vdupq_n_s32(128));
int32x4_t r_hi = vaddq_s32(vaddq_s32(ky_hi, vmull_n_s16(v_hi, kr)), vdupq_n_s32(128));
int32x4_t g_hi = vaddq_s32(vsubq_s32(vsubq_s32(ky_hi, vmull_n_s16(u_hi, kgu)), vmull_n_s16(v_hi, kgv)), vdupq_n_s32(128));
int32x4_t b_hi = vaddq_s32(vaddq_s32(ky_hi, vmull_n_s16(u_hi, kb)), vdupq_n_s32(128));
// Shift >>8, saturate int32→int16→uint8, store interleaved BGRA.
uint8x8_t r = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(r_lo,8)), vqmovn_s32(vshrq_n_s32(r_hi,8))));
uint8x8_t g = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(g_lo,8)), vqmovn_s32(vshrq_n_s32(g_hi,8))));
uint8x8_t b = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(b_lo,8)), vqmovn_s32(vshrq_n_s32(b_hi,8))));
uint8x8x4_t bgra;
bgra.val[0] = b;
bgra.val[1] = g;
bgra.val[2] = r;
bgra.val[3] = vdup_n_u8(255);
vst4_u8(drow + col * 4, bgra);
}
// grdp_yuv420p_to_bgra_neon_16 processes 16 luma pixels (8 UV pairs) per call.
// One vld1_u8 covers all 8 U (and V) samples needed for 16 luma columns.
// vzip_u8 duplicates the low half for pixels 0-7 and the high half for 8-15,
// giving twice the throughput of grdp_yuv420p_to_bgra_neon_8 per iteration.
static inline void grdp_yuv420p_to_bgra_neon_16(
const uint8_t *yrow, const uint8_t *urow, const uint8_t *vrow,
uint8_t *drow, int col,
int16_t ky, int16_t kr, int16_t kgu, int16_t kgv, int16_t kb,
int16_t yoff)
{
// Load 16 luma bytes, subtract Y offset.
uint8x16_t y_u8 = vld1q_u8(yrow + col);
int16x8_t c_lo = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(vget_low_u8(y_u8))),
vdupq_n_s16(yoff));
int16x8_t c_hi = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(vget_high_u8(y_u8))),
vdupq_n_s16(yoff));
// Load 8 U and 8 V bytes; duplicate each to cover its two luma pixels.
uint8x8_t u_raw = vld1_u8(urow + (col >> 1));
uint8x8_t v_raw = vld1_u8(vrow + (col >> 1));
uint8x8x2_t u_zip = vzip_u8(u_raw, u_raw); // val[0]=[U0,U0,...,U3,U3] val[1]=[U4,U4,...,U7,U7]
uint8x8x2_t v_zip = vzip_u8(v_raw, v_raw);
// Pixels 0-7 (low half).
{
int16x8_t u8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(u_zip.val[0])), vdupq_n_s16(128));
int16x8_t v8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(v_zip.val[0])), vdupq_n_s16(128));
int16x4_t cl = vget_low_s16(c_lo), ul = vget_low_s16(u8), vl = vget_low_s16(v8);
int16x4_t ch = vget_high_s16(c_lo), uh = vget_high_s16(u8), vh = vget_high_s16(v8);
int32x4_t kyl = vmull_n_s16(cl, ky), kyh = vmull_n_s16(ch, ky);
int32x4_t rl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(vl, kr)), vdupq_n_s32(128));
int32x4_t gl = vaddq_s32(vsubq_s32(vsubq_s32(kyl, vmull_n_s16(ul, kgu)), vmull_n_s16(vl, kgv)), vdupq_n_s32(128));
int32x4_t bl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(ul, kb)), vdupq_n_s32(128));
int32x4_t rh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(vh, kr)), vdupq_n_s32(128));
int32x4_t gh = vaddq_s32(vsubq_s32(vsubq_s32(kyh, vmull_n_s16(uh, kgu)), vmull_n_s16(vh, kgv)), vdupq_n_s32(128));
int32x4_t bh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(uh, kb)), vdupq_n_s32(128));
uint8x8_t r = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(rl,8)), vqmovn_s32(vshrq_n_s32(rh,8))));
uint8x8_t g = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(gl,8)), vqmovn_s32(vshrq_n_s32(gh,8))));
uint8x8_t b = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(bl,8)), vqmovn_s32(vshrq_n_s32(bh,8))));
uint8x8x4_t bgra; bgra.val[0]=b; bgra.val[1]=g; bgra.val[2]=r; bgra.val[3]=vdup_n_u8(255);
vst4_u8(drow + col * 4, bgra);
}
// Pixels 8-15 (high half).
{
int16x8_t u8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(u_zip.val[1])), vdupq_n_s16(128));
int16x8_t v8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(v_zip.val[1])), vdupq_n_s16(128));
int16x4_t cl = vget_low_s16(c_hi), ul = vget_low_s16(u8), vl = vget_low_s16(v8);
int16x4_t ch = vget_high_s16(c_hi), uh = vget_high_s16(u8), vh = vget_high_s16(v8);
int32x4_t kyl = vmull_n_s16(cl, ky), kyh = vmull_n_s16(ch, ky);
int32x4_t rl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(vl, kr)), vdupq_n_s32(128));
int32x4_t gl = vaddq_s32(vsubq_s32(vsubq_s32(kyl, vmull_n_s16(ul, kgu)), vmull_n_s16(vl, kgv)), vdupq_n_s32(128));
int32x4_t bl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(ul, kb)), vdupq_n_s32(128));
int32x4_t rh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(vh, kr)), vdupq_n_s32(128));
int32x4_t gh = vaddq_s32(vsubq_s32(vsubq_s32(kyh, vmull_n_s16(uh, kgu)), vmull_n_s16(vh, kgv)), vdupq_n_s32(128));
int32x4_t bh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(uh, kb)), vdupq_n_s32(128));
uint8x8_t r = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(rl,8)), vqmovn_s32(vshrq_n_s32(rh,8))));
uint8x8_t g = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(gl,8)), vqmovn_s32(vshrq_n_s32(gh,8))));
uint8x8_t b = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(bl,8)), vqmovn_s32(vshrq_n_s32(bh,8))));
uint8x8x4_t bgra; bgra.val[0]=b; bgra.val[1]=g; bgra.val[2]=r; bgra.val[3]=vdup_n_u8(255);
vst4_u8(drow + (col + 8) * 4, bgra);
}
}
#endif // __ARM_NEON__
// ----------------------------------------------------------------------------
// SSE2 YUV→BGRA inline helpers (x86_64)
// Each function converts 8 luma pixels per call using 128-bit SIMD.
// Coefficients and arithmetic match the NEON paths above (BT.601, fixed-point
// with 8 fractional bits): R = (ky*Y + kr*V + 128) >> 8, etc.
// _mm_madd_epi16 is used for the R and B channels because kb=516 overflows
// int16 multiplication (516×127 = 65532 > 32767); madd widens to int32.
// ----------------------------------------------------------------------------
#ifdef __SSE2__
// Shared BT.601 arithmetic core. Receives pre-biased int16 vectors
// y16 (Y − yoff), u16 (U − 128), v16 (V − 128) and stores 8 BGRA pixels.
static inline void grdp_yuv_to_bgra_sse2_core(
__m128i y16, __m128i u16, __m128i v16,
uint8_t *drow, int col,
int16_t ky, int16_t kr, int16_t kgu, int16_t kgv, int16_t kb)
{
const __m128i add128 = _mm_set1_epi32(128);
const __m128i alpha = _mm_set1_epi8((char)255);
// Interleaved coefficient pairs for _mm_madd_epi16: elem0=ky, elem1=k?
const __m128i coeff_r = _mm_set_epi16(kr, ky, kr, ky,
kr, ky, kr, ky);
const __m128i coeff_b = _mm_set_epi16(kb, ky, kb, ky,
kb, ky, kb, ky);
// Green uses ky*Y − kgv*V (via madd) then adds −kgu*U (sign-extended).
const __m128i coeff_yv = _mm_set_epi16((int16_t)-kgv, ky, (int16_t)-kgv, ky,
(int16_t)-kgv, ky, (int16_t)-kgv, ky);
const __m128i neg_kgu = _mm_set1_epi16((int16_t)-kgu);
__m128i yv_lo = _mm_unpacklo_epi16(y16, v16);
__m128i yu_lo = _mm_unpacklo_epi16(y16, u16);
__m128i r32_lo = _mm_add_epi32(_mm_madd_epi16(yv_lo, coeff_r), add128);
__m128i b32_lo = _mm_add_epi32(_mm_madd_epi16(yu_lo, coeff_b), add128);
__m128i g32_lo = _mm_madd_epi16(yv_lo, coeff_yv);
{
__m128i ngu = _mm_mullo_epi16(u16, neg_kgu);
// Sign-extend int16 ngu to int32 using srai-15 trick, then unpack.
g32_lo = _mm_add_epi32(g32_lo,
_mm_add_epi32(_mm_unpacklo_epi16(ngu, _mm_srai_epi16(ngu, 15)), add128));
}
__m128i yv_hi = _mm_unpackhi_epi16(y16, v16);
__m128i yu_hi = _mm_unpackhi_epi16(y16, u16);
__m128i r32_hi = _mm_add_epi32(_mm_madd_epi16(yv_hi, coeff_r), add128);
__m128i b32_hi = _mm_add_epi32(_mm_madd_epi16(yu_hi, coeff_b), add128);
__m128i g32_hi = _mm_madd_epi16(yv_hi, coeff_yv);
{
__m128i ngu = _mm_mullo_epi16(u16, neg_kgu);
g32_hi = _mm_add_epi32(g32_hi,
_mm_add_epi32(_mm_unpackhi_epi16(ngu, _mm_srai_epi16(ngu, 15)), add128));
}
// Shift right 8 (un-scale), pack int32→int16→uint8 (auto-saturating clamp).
__m128i r16 = _mm_packs_epi32(_mm_srai_epi32(r32_lo, 8), _mm_srai_epi32(r32_hi, 8));
__m128i g16 = _mm_packs_epi32(_mm_srai_epi32(g32_lo, 8), _mm_srai_epi32(g32_hi, 8));
__m128i b16 = _mm_packs_epi32(_mm_srai_epi32(b32_lo, 8), _mm_srai_epi32(b32_hi, 8));
__m128i r8 = _mm_packus_epi16(r16, r16);
__m128i g8 = _mm_packus_epi16(g16, g16);
__m128i b8 = _mm_packus_epi16(b16, b16);
// Interleave B,G,R,A into two 16-byte stores covering 8 BGRA pixels.
__m128i bg = _mm_unpacklo_epi8(b8, g8);
__m128i ra = _mm_unpacklo_epi8(r8, alpha);
_mm_storeu_si128((__m128i *)(drow + col * 4), _mm_unpacklo_epi16(bg, ra));
_mm_storeu_si128((__m128i *)(drow + col * 4 + 16), _mm_unpackhi_epi16(bg, ra));
}
// 8-pixel NV12 (semi-planar Y + interleaved UV) → BGRA.
static inline void grdp_nv12_to_bgra_sse2_8(
const uint8_t *yrow, const uint8_t *uvrow, uint8_t *drow, int col,
int16_t ky, int16_t kr, int16_t kgu, int16_t kgv, int16_t kb, int16_t yoff)
{
const __m128i zero = _mm_setzero_si128();
__m128i y8 = _mm_loadl_epi64((const __m128i *)(yrow + col));
__m128i y16 = _mm_sub_epi16(_mm_unpacklo_epi8(y8, zero), _mm_set1_epi16(yoff));
// Load 8 interleaved UV bytes [U0,V0,U1,V1,...,U3,V3].
__m128i uv8 = _mm_loadl_epi64((const __m128i *)(uvrow + col));
// Isolate U (even bytes) and V (odd bytes), pack to low 8 bytes.
__m128i u_pack = _mm_packus_epi16(_mm_and_si128(uv8, _mm_set1_epi16((int16_t)0x00FF)), zero);
__m128i v_pack = _mm_packus_epi16(_mm_srli_epi16(uv8, 8), zero);
// Duplicate each sample to cover its two luma pixels, then extend to int16.
__m128i u16 = _mm_sub_epi16(
_mm_unpacklo_epi8(_mm_unpacklo_epi8(u_pack, u_pack), zero), _mm_set1_epi16(128));
__m128i v16 = _mm_sub_epi16(
_mm_unpacklo_epi8(_mm_unpacklo_epi8(v_pack, v_pack), zero), _mm_set1_epi16(128));
grdp_yuv_to_bgra_sse2_core(y16, u16, v16, drow, col, ky, kr, kgu, kgv, kb);
}
// 8-pixel planar YUV420P → BGRA.
// FFmpeg guarantees at least 64-byte padding at end of each line buffer, so
// loading 8 bytes when only 4 U/V bytes are needed per 8 luma pixels is safe.
static inline void grdp_yuv420p_to_bgra_sse2_8(
const uint8_t *yrow, const uint8_t *urow, const uint8_t *vrow,
uint8_t *drow, int col,
int16_t ky, int16_t kr, int16_t kgu, int16_t kgv, int16_t kb, int16_t yoff)
{
const __m128i zero = _mm_setzero_si128();
__m128i y8 = _mm_loadl_epi64((const __m128i *)(yrow + col));
__m128i y16 = _mm_sub_epi16(_mm_unpacklo_epi8(y8, zero), _mm_set1_epi16(yoff));
// Load 4 U and 4 V bytes (8-byte load; only low 4 used after duplication).
__m128i u4 = _mm_loadl_epi64((const __m128i *)(urow + (col >> 1)));
__m128i u16 = _mm_sub_epi16(
_mm_unpacklo_epi8(_mm_unpacklo_epi8(u4, u4), zero), _mm_set1_epi16(128));
__m128i v4 = _mm_loadl_epi64((const __m128i *)(vrow + (col >> 1)));
__m128i v16 = _mm_sub_epi16(
_mm_unpacklo_epi8(_mm_unpacklo_epi8(v4, v4), zero), _mm_set1_epi16(128));
grdp_yuv_to_bgra_sse2_core(y16, u16, v16, drow, col, ky, kr, kgu, kgv, kb);
}
#endif // __SSE2__
static void grdp_yuv420p_to_bgra(
const AVFrame *src, uint8_t *dst, int dst_stride, int full_range)
{
int width = src->width;
int height = src->height;
#ifdef __ARM_NEON__
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = 0; row < height; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *urow = src->data[1] + (row >> 1) * src->linesize[1];
const uint8_t *vrow = src->data[2] + (row >> 1) * src->linesize[2];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 15 < width; col += 16)
grdp_yuv420p_to_bgra_neon_16(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
for (; col + 7 < width; col += 8)
grdp_yuv420p_to_bgra_neon_8(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
// Scalar tail for widths not a multiple of 8.
for (; col < width; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#elif defined(__SSE2__)
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = 0; row < height; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *urow = src->data[1] + (row >> 1) * src->linesize[1];
const uint8_t *vrow = src->data[2] + (row >> 1) * src->linesize[2];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 7 < width; col += 8)
grdp_yuv420p_to_bgra_sse2_8(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
for (; col < width; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#else
for (int row = 0; row < height; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *urow = src->data[1] + (row >> 1) * src->linesize[1];
const uint8_t *vrow = src->data[2] + (row >> 1) * src->linesize[2];
uint8_t *drow = dst + row * dst_stride;
for (int col = 0; col < width; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#endif
}
// grdp_yuv420p_to_bgra_regions is the region-aware variant of
// grdp_yuv420p_to_bgra. Only pixels within the n_rects dirty rectangles
// (flat array of [left,top,right,bottom] uint16 tuples) are written to dst;
// all other pixels are left untouched, saving work proportional to the
// fraction of the frame that did not change.
static void grdp_yuv420p_to_bgra_regions(
const AVFrame *src, uint8_t *dst, int dst_stride, int full_range,
const uint16_t *rects, int n_rects)
{
int width = src->width;
int height = src->height;
#ifdef __ARM_NEON__
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
#elif defined(__SSE2__)
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
#endif
for (int i = 0; i < n_rects; i++) {
int left = (int)rects[i*4+0];
int top = (int)rects[i*4+1];
int right = (int)rects[i*4+2];
int bottom = (int)rects[i*4+3];
if (left < 0) left = 0;
if (top < 0) top = 0;
if (right > width) right = width;
if (bottom > height) bottom = height;
if (left >= right || top >= bottom) continue;
for (int row = top; row < bottom; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *urow = src->data[1] + (row >> 1) * src->linesize[1];
const uint8_t *vrow = src->data[2] + (row >> 1) * src->linesize[2];
uint8_t *drow = dst + row * dst_stride;
int col = left;
#ifdef __ARM_NEON__
// Advance scalar to the next multiple-of-8 boundary before the
// NEON loop. The only requirement for correct UV subsampling is
// that col is even; 8-alignment satisfies that and reduces the
// scalar pre-loop to at most 7 pixels (vs. 15 for 16-alignment).
int neon_start = (col + 7) & ~7;
for (; col < neon_start && col < right; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
for (; col + 15 < right; col += 16)
grdp_yuv420p_to_bgra_neon_16(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
for (; col + 7 < right; col += 8)
grdp_yuv420p_to_bgra_neon_8(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
#elif defined(__SSE2__)
// Advance to 8-aligned column so UV subsampling is always correct.
int sse_start = (col + 7) & ~7;
for (; col < sse_start && col < right; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
for (; col + 7 < right; col += 8)
grdp_yuv420p_to_bgra_sse2_8(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
#endif
for (; col < right; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
}
}
// grdp_nv12_to_bgra converts a semi-planar NV12 frame (Y plane + interleaved
// UV plane) to packed BGRA using BT.601 coefficients. This bypasses swscale
// for the same reason as grdp_yuv420p_to_bgra: on ARM64 swscale's
// non-accelerated NV12→BGRA fallback ignores sws_setColorspaceDetails.
// VideoToolbox (macOS HW decoder) always outputs NV12.
//
// On ARM64 the inner loop is NEON-accelerated (8 pixels per iteration) to
// reduce per-frame CPU cost and decode-loop jitter.
#ifdef __ARM_NEON__
// grdp_nv12_to_bgra_neon_8 processes 8 luma pixels (4 UV pairs) per call.
// All int32x4_t intermediates prevent overflow of e.g. 298*239 = 71 222.
static inline void grdp_nv12_to_bgra_neon_8(
const uint8_t *yrow, const uint8_t *uvrow, uint8_t *drow,
int col, int16_t ky, int16_t kr, int16_t kgu, int16_t kgv, int16_t kb,
int16_t yoff)
{
// Load 8 luma bytes, convert to int16, subtract Y offset (16 or 0).
uint8x8_t y_u8 = vld1_u8(yrow + col);
int16x8_t c16 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(y_u8)),
vdupq_n_s16(yoff));
// Load 8 UV bytes: [U0,V0,U1,V1,U2,V2,U3,V3].
// vtrn_u8(a,a) transposes pairs: val[0]=[a[0],a[0],a[2],a[2],…] val[1]=[a[1],a[1],a[3],a[3],…].
// Applied to interleaved NV12 this deinterleaves AND duplicates U and V in one instruction.
uint8x8_t uv_u8 = vld1_u8(uvrow + col);
uint8x8x2_t uv_dup = vtrn_u8(uv_u8, uv_u8);
uint8x8_t u8 = uv_dup.val[0]; // [U0,U0,U1,U1,U2,U2,U3,U3]
uint8x8_t v8 = uv_dup.val[1]; // [V0,V0,V1,V1,V2,V2,V3,V3]
int16x8_t u16 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(u8)), vdupq_n_s16(128));
int16x8_t v16 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(v8)), vdupq_n_s16(128));
// Compute R/G/B with int32 to avoid overflow. Process 4+4 pixels.
int16x4_t c_lo = vget_low_s16(c16), u_lo = vget_low_s16(u16), v_lo = vget_low_s16(v16);
int16x4_t c_hi = vget_high_s16(c16), u_hi = vget_high_s16(u16), v_hi = vget_high_s16(v16);
int32x4_t ky_lo = vmull_n_s16(c_lo, ky), ky_hi = vmull_n_s16(c_hi, ky);
int32x4_t r_lo = vaddq_s32(vaddq_s32(ky_lo, vmull_n_s16(v_lo, kr)), vdupq_n_s32(128));
int32x4_t g_lo = vaddq_s32(vsubq_s32(vsubq_s32(ky_lo, vmull_n_s16(u_lo, kgu)), vmull_n_s16(v_lo, kgv)), vdupq_n_s32(128));
int32x4_t b_lo = vaddq_s32(vaddq_s32(ky_lo, vmull_n_s16(u_lo, kb)), vdupq_n_s32(128));
int32x4_t r_hi = vaddq_s32(vaddq_s32(ky_hi, vmull_n_s16(v_hi, kr)), vdupq_n_s32(128));
int32x4_t g_hi = vaddq_s32(vsubq_s32(vsubq_s32(ky_hi, vmull_n_s16(u_hi, kgu)), vmull_n_s16(v_hi, kgv)), vdupq_n_s32(128));
int32x4_t b_hi = vaddq_s32(vaddq_s32(ky_hi, vmull_n_s16(u_hi, kb)), vdupq_n_s32(128));
// Shift >>8, saturate int32→int16→uint8, then store interleaved BGRA.
uint8x8_t r = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(r_lo,8)), vqmovn_s32(vshrq_n_s32(r_hi,8))));
uint8x8_t g = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(g_lo,8)), vqmovn_s32(vshrq_n_s32(g_hi,8))));
uint8x8_t b = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(b_lo,8)), vqmovn_s32(vshrq_n_s32(b_hi,8))));
uint8x8x4_t bgra;
bgra.val[0] = b;
bgra.val[1] = g;
bgra.val[2] = r;
bgra.val[3] = vdup_n_u8(255);
vst4_u8(drow + col * 4, bgra);
}
// grdp_nv12_to_bgra_neon_16 processes 16 luma pixels (8 UV pairs) per call.
// vld2_u8 deinterleaves U and V in one instruction; vzip_u8 then duplicates
// each chroma sample for the two luma pixels it serves, giving twice the
// throughput of grdp_nv12_to_bgra_neon_8 per loop iteration.
static inline void grdp_nv12_to_bgra_neon_16(
const uint8_t *yrow, const uint8_t *uvrow, uint8_t *drow,
int col, int16_t ky, int16_t kr, int16_t kgu, int16_t kgv, int16_t kb,
int16_t yoff)
{
// Load 16 luma bytes, subtract Y offset.
uint8x16_t y_u8 = vld1q_u8(yrow + col);
int16x8_t c_lo = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(vget_low_u8(y_u8))),
vdupq_n_s16(yoff));
int16x8_t c_hi = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(vget_high_u8(y_u8))),
vdupq_n_s16(yoff));
// Load 8 UV pairs (16 bytes) with automatic deinterleave.
// val[0]=[U0..U7], val[1]=[V0..V7]; each serves 2 luma pixels.
uint8x8x2_t uv = vld2_u8(uvrow + col);
uint8x8x2_t u_zip = vzip_u8(uv.val[0], uv.val[0]); // val[0]=[U0,U0,...,U3,U3] val[1]=[U4,U4,...,U7,U7]
uint8x8x2_t v_zip = vzip_u8(uv.val[1], uv.val[1]);
// Pixels 0-7 (low half).
{
int16x8_t u8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(u_zip.val[0])), vdupq_n_s16(128));
int16x8_t v8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(v_zip.val[0])), vdupq_n_s16(128));
int16x4_t cl = vget_low_s16(c_lo), ul = vget_low_s16(u8), vl = vget_low_s16(v8);
int16x4_t ch = vget_high_s16(c_lo), uh = vget_high_s16(u8), vh = vget_high_s16(v8);
int32x4_t kyl = vmull_n_s16(cl, ky), kyh = vmull_n_s16(ch, ky);
int32x4_t rl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(vl, kr)), vdupq_n_s32(128));
int32x4_t gl = vaddq_s32(vsubq_s32(vsubq_s32(kyl, vmull_n_s16(ul, kgu)), vmull_n_s16(vl, kgv)), vdupq_n_s32(128));
int32x4_t bl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(ul, kb)), vdupq_n_s32(128));
int32x4_t rh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(vh, kr)), vdupq_n_s32(128));
int32x4_t gh = vaddq_s32(vsubq_s32(vsubq_s32(kyh, vmull_n_s16(uh, kgu)), vmull_n_s16(vh, kgv)), vdupq_n_s32(128));
int32x4_t bh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(uh, kb)), vdupq_n_s32(128));
uint8x8_t r = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(rl,8)), vqmovn_s32(vshrq_n_s32(rh,8))));
uint8x8_t g = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(gl,8)), vqmovn_s32(vshrq_n_s32(gh,8))));
uint8x8_t b = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(bl,8)), vqmovn_s32(vshrq_n_s32(bh,8))));
uint8x8x4_t bgra; bgra.val[0]=b; bgra.val[1]=g; bgra.val[2]=r; bgra.val[3]=vdup_n_u8(255);
vst4_u8(drow + col * 4, bgra);
}
// Pixels 8-15 (high half).
{
int16x8_t u8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(u_zip.val[1])), vdupq_n_s16(128));
int16x8_t v8 = vsubq_s16(vreinterpretq_s16_u16(vmovl_u8(v_zip.val[1])), vdupq_n_s16(128));
int16x4_t cl = vget_low_s16(c_hi), ul = vget_low_s16(u8), vl = vget_low_s16(v8);
int16x4_t ch = vget_high_s16(c_hi), uh = vget_high_s16(u8), vh = vget_high_s16(v8);
int32x4_t kyl = vmull_n_s16(cl, ky), kyh = vmull_n_s16(ch, ky);
int32x4_t rl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(vl, kr)), vdupq_n_s32(128));
int32x4_t gl = vaddq_s32(vsubq_s32(vsubq_s32(kyl, vmull_n_s16(ul, kgu)), vmull_n_s16(vl, kgv)), vdupq_n_s32(128));
int32x4_t bl = vaddq_s32(vaddq_s32(kyl, vmull_n_s16(ul, kb)), vdupq_n_s32(128));
int32x4_t rh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(vh, kr)), vdupq_n_s32(128));
int32x4_t gh = vaddq_s32(vsubq_s32(vsubq_s32(kyh, vmull_n_s16(uh, kgu)), vmull_n_s16(vh, kgv)), vdupq_n_s32(128));
int32x4_t bh = vaddq_s32(vaddq_s32(kyh, vmull_n_s16(uh, kb)), vdupq_n_s32(128));
uint8x8_t r = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(rl,8)), vqmovn_s32(vshrq_n_s32(rh,8))));
uint8x8_t g = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(gl,8)), vqmovn_s32(vshrq_n_s32(gh,8))));
uint8x8_t b = vqmovun_s16(vcombine_s16(vqmovn_s32(vshrq_n_s32(bl,8)), vqmovn_s32(vshrq_n_s32(bh,8))));
uint8x8x4_t bgra; bgra.val[0]=b; bgra.val[1]=g; bgra.val[2]=r; bgra.val[3]=vdup_n_u8(255);
vst4_u8(drow + (col + 8) * 4, bgra);
}
}
#endif // __ARM_NEON__
static void grdp_nv12_to_bgra(
const AVFrame *src, uint8_t *dst, int dst_stride, int full_range)
{
int width = src->width;
int height = src->height;
#ifdef __ARM_NEON__
// NEON fast path: 8 pixels per inner iteration on ARM64.
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = 0; row < height; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *uvrow = src->data[1] + (row >> 1) * src->linesize[1];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 15 < width; col += 16)
grdp_nv12_to_bgra_neon_16(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
for (; col + 7 < width; col += 8)
grdp_nv12_to_bgra_neon_8(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
// Scalar tail for widths not a multiple of 8.
for (; col < width; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#elif defined(__SSE2__)
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = 0; row < height; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *uvrow = src->data[1] + (row >> 1) * src->linesize[1];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 7 < width; col += 8)
grdp_nv12_to_bgra_sse2_8(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
for (; col < width; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#else
for (int row = 0; row < height; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *uvrow = src->data[1] + (row >> 1) * src->linesize[1];
uint8_t *drow = dst + row * dst_stride;
for (int col = 0; col < width; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#endif
}
// grdp_nv12_to_bgra_regions is the region-aware variant of grdp_nv12_to_bgra.
// Only pixels within the n_rects dirty rectangles (flat [left,top,right,bottom]
// uint16 tuples) are written; all other pixels in dst are left untouched.
static void grdp_nv12_to_bgra_regions(
const AVFrame *src, uint8_t *dst, int dst_stride, int full_range,
const uint16_t *rects, int n_rects)
{
int width = src->width;
int height = src->height;
#ifdef __ARM_NEON__
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
#elif defined(__SSE2__)
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
#endif
for (int i = 0; i < n_rects; i++) {
int left = (int)rects[i*4+0];
int top = (int)rects[i*4+1];
int right = (int)rects[i*4+2];
int bottom = (int)rects[i*4+3];
if (left < 0) left = 0;
if (top < 0) top = 0;
if (right > width) right = width;
if (bottom > height) bottom = height;
if (left >= right || top >= bottom) continue;
for (int row = top; row < bottom; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *uvrow = src->data[1] + (row >> 1) * src->linesize[1];
uint8_t *drow = dst + row * dst_stride;
int col = left;
#ifdef __ARM_NEON__
int neon_start = (col + 7) & ~7;
for (; col < neon_start && col < right; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
for (; col + 15 < right; col += 16)
grdp_nv12_to_bgra_neon_16(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
for (; col + 7 < right; col += 8)
grdp_nv12_to_bgra_neon_8(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
#elif defined(__SSE2__)
int sse_start = (col + 7) & ~7;
for (; col < sse_start && col < right; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
for (; col + 7 < right; col += 8)
grdp_nv12_to_bgra_sse2_8(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
#endif
for (; col < right; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
}
}
// grdp_sample_yuv samples the centre pixel of a planar YUV frame for
// diagnostics. Returns raw byte values (not offset-adjusted).
static void grdp_sample_yuv(const AVFrame *f,
uint8_t *y_out, uint8_t *u_out, uint8_t *v_out)
{
int cx = f->width / 2;
int cy = f->height / 2;
*y_out = f->data[0][ cy * f->linesize[0] + cx ];
*u_out = f->data[1][(cy / 2) * f->linesize[1] + (cx / 2)];
*v_out = f->data[2][(cy / 2) * f->linesize[2] + (cx / 2)];
}
// grdp_sample_nv12 samples the centre pixel of a semi-planar NV12 frame
// (Y plane + interleaved UV plane) for diagnostics.
static void grdp_sample_nv12(const AVFrame *f,
uint8_t *y_out, uint8_t *u_out, uint8_t *v_out)
{
int cx = f->width / 2;
int cy = f->height / 2;
*y_out = f->data[0][cy * f->linesize[0] + cx];
int uvx = (cx / 2) * 2; // NV12: interleaved, U at even index, V at odd
int uvy = cy / 2;
*u_out = f->data[1][uvy * f->linesize[1] + uvx];
*v_out = f->data[1][uvy * f->linesize[1] + uvx + 1];
}
// grdp_sample_nv12_at samples a specific (x,y) pixel from an NV12 frame.
static void grdp_sample_nv12_at(const AVFrame *f, int x, int y,
uint8_t *y_out, uint8_t *u_out, uint8_t *v_out)
{
if (x < 0 || x >= f->width || y < 0 || y >= f->height) {
*y_out = *u_out = *v_out = 0;
return;
}
*y_out = f->data[0][y * f->linesize[0] + x];
int uvx = (x >> 1) * 2;
int uvy = y >> 1;
*u_out = f->data[1][uvy * f->linesize[1] + uvx];
*v_out = f->data[1][uvy * f->linesize[1] + uvx + 1];
}
// grdp_is_warmup_nv12 checks whether an NV12 frame looks like an uninitialised
// VideoToolbox IOSurface where the Y plane is zeroed but UV is at neutral 128.
// It samples Y at a 3×3 grid spread across the frame (at 25%, 50%, 75% of
// width and height). Returns 1 if every sampled luma value is <= threshold.
// A threshold of 4 catches Y=0 warm-up frames while tolerating H.264 rounding
// (luma=1 is possible). Real video content — even a mostly-black desktop — is
// statistically unlikely to have all 9 spread-out luma samples at or below 4.
static int grdp_is_warmup_nv12(const AVFrame *f, int threshold) {
const uint8_t *yp = f->data[0];
int stride = f->linesize[0];
int w = f->width, h = f->height;
if (!yp || w <= 0 || h <= 0) return 0;
int xs[3] = { w / 4, w / 2, 3 * w / 4 };
int ys[3] = { h / 4, h / 2, 3 * h / 4 };
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
if (yp[ys[i] * stride + xs[j]] > (uint8_t)threshold)
return 0;
return 1;
}
// grdp_debug_sample_y_grid fills out[0..8] with the same 3x3 luma samples
// grdp_is_warmup_nv12 checks (25%/50%/75% of width and height), for
// diagnostic logging only — it does not affect the warm-up decision itself.
static void grdp_debug_sample_y_grid(const AVFrame *f, uint8_t *out) {
const uint8_t *yp = f->data[0];
int stride = f->linesize[0];
int w = f->width, h = f->height;
if (!yp || w <= 0 || h <= 0) {
for (int k = 0; k < 9; k++) out[k] = 0;
return;
}
int xs[3] = { w / 4, w / 2, 3 * w / 4 };
int ys[3] = { h / 4, h / 2, 3 * h / 4 };
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
out[i * 3 + j] = yp[ys[i] * stride + xs[j]];
}
// grdp_is_low_chroma_nv12 returns 1 if at least minLow of the sampled UV pairs
// in an NV12 frame are abnormally low (both U and V below threshold). Valid
// YUV content always has chroma centered near 128; corruption from stale IDR
// priming or uninitialised buffers collapses chroma to ~0, which renders as a
// green-monochrome frame. A 3x3 grid spread across the frame catches corruption
// that only affects the centre pixel.
static int grdp_is_low_chroma_nv12(const AVFrame *f, int threshold, int minLow) {
const uint8_t *uvp = f->data[1];
int stride = f->linesize[1];
int w = f->width, h = f->height;
if (!uvp || w <= 0 || h <= 0) return 0;
int ph = (h + 1) / 2;
int pw = (w + 1) / 2;
if (pw <= 0 || ph <= 0) return 0;
int xs[3] = { pw / 4, pw / 2, 3 * pw / 4 };
int ys[3] = { ph / 4, ph / 2, 3 * ph / 4 };
int low = 0;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
int x = xs[j] * 2; // NV12: interleaved U at even byte, V at odd byte
int y = ys[i];
int u = (int)uvp[y * stride + x];
int v = (int)uvp[y * stride + x + 1];
if (u < threshold && v < threshold) low++;
}
}
return low >= minLow ? 1 : 0;
}
// grdp_is_low_chroma_yuv420p is the planar YUV420P equivalent of
// grdp_is_low_chroma_nv12.
static int grdp_is_low_chroma_yuv420p(const AVFrame *f, int threshold, int minLow) {
const uint8_t *up = f->data[1];
const uint8_t *vp = f->data[2];
int uStride = f->linesize[1];
int vStride = f->linesize[2];
int w = f->width, h = f->height;
if (!up || !vp || w <= 0 || h <= 0) return 0;
int ph = (h + 1) / 2;
int pw = (w + 1) / 2;
if (pw <= 0 || ph <= 0) return 0;
int xs[3] = { pw / 4, pw / 2, 3 * pw / 4 };
int ys[3] = { ph / 4, ph / 2, 3 * ph / 4 };
int low = 0;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
int x = xs[j];
int y = ys[i];
int u = (int)up[y * uStride + x];
int v = (int)vp[y * vStride + x];
if (u < threshold && v < threshold) low++;
}
}
return low >= minLow ? 1 : 0;
}
static void grdp_sws_set_src_range(struct SwsContext *sws, int full_range) {
const int *inv_table, *table;
int src_range, dst_range, brightness, contrast, saturation;
if (sws_getColorspaceDetails(sws,
(int **)&inv_table, &src_range,
(int **)&table, &dst_range,
&brightness, &contrast, &saturation) >= 0) {
sws_setColorspaceDetails(sws,
inv_table, full_range,
table, dst_range,
brightness, contrast, saturation);
}
}
// grdp_copy_yuv420p_to_i420 copies an AVFrame in YUV420P or YUVJ420P format
// to tightly-packed I420 planes (stride = width for Y, stride = (width+1)/2 for U/V).
// ydst, udst, vdst must be pre-allocated by the caller.
// When the source frame is tight-packed (linesize == stride), a single bulk
// memcpy is used per plane instead of per-row copies.
static void grdp_copy_yuv420p_to_i420(
const AVFrame *f,
uint8_t *ydst, uint8_t *udst, uint8_t *vdst,
int w, int h)
{
int pw = (w + 1) / 2;
int ph = (h + 1) / 2;
if (f->linesize[0] == w)
memcpy(ydst, f->data[0], (size_t)w * h);
else
for (int y = 0; y < h; y++)
memcpy(ydst + y * w, f->data[0] + y * f->linesize[0], w);
if (f->linesize[1] == pw)
memcpy(udst, f->data[1], (size_t)pw * ph);
else
for (int y = 0; y < ph; y++)
memcpy(udst + y * pw, f->data[1] + y * f->linesize[1], pw);
if (f->linesize[2] == pw)
memcpy(vdst, f->data[2], (size_t)pw * ph);
else
for (int y = 0; y < ph; y++)
memcpy(vdst + y * pw, f->data[2] + y * f->linesize[2], pw);
}
// grdp_copy_nv12_to_i420 copies an AVFrame in NV12 format (Y plane + interleaved UV)
// to tightly-packed I420 planes.
// The Y plane is bulk-copied when tight-packed.
// On ARM64 the UV deinterleave loop uses NEON vld2q_u8 to process 16 chroma
// pairs per iteration, roughly halving the cost of the chroma plane copy.
static void grdp_copy_nv12_to_i420(
const AVFrame *f,
uint8_t *ydst, uint8_t *udst, uint8_t *vdst,
int w, int h)
{
int pw = (w + 1) / 2;
int ph = (h + 1) / 2;
if (f->linesize[0] == w)
memcpy(ydst, f->data[0], (size_t)w * h);
else
for (int y = 0; y < h; y++)
memcpy(ydst + y * w, f->data[0] + y * f->linesize[0], w);
for (int y = 0; y < ph; y++) {
const uint8_t *row = f->data[1] + y * f->linesize[1];
uint8_t *ud = udst + y * pw;
uint8_t *vd = vdst + y * pw;
int x = 0;
#ifdef __ARM_NEON__
for (; x + 15 < pw; x += 16) {
uint8x16x2_t uv = vld2q_u8(row + x * 2);
vst1q_u8(ud + x, uv.val[0]);
vst1q_u8(vd + x, uv.val[1]);
}
#elif defined(__SSE2__)
// SSE2: deinterleave 8 UV pairs (16 bytes) per iteration.
// _mm_and_si128 extracts even bytes (U) and _mm_srli_epi16 shifts odd bytes (V).
// _mm_packus_epi16 packs 8 × 16-bit values to 8 bytes in the low half.
for (; x + 7 < pw; x += 8) {
__m128i uv128 = _mm_loadu_si128((const __m128i *)(row + x * 2));
__m128i u_vec = _mm_and_si128(uv128, _mm_set1_epi16(0x00FF));
__m128i v_vec = _mm_srli_epi16(uv128, 8);
_mm_storel_epi64((__m128i *)(ud + x), _mm_packus_epi16(u_vec, u_vec));
_mm_storel_epi64((__m128i *)(vd + x), _mm_packus_epi16(v_vec, v_vec));
}
#endif
for (; x < pw; x++) {
ud[x] = row[x * 2];
vd[x] = row[x * 2 + 1];
}
}
}
// grdp_copy_nv12 copies an AVFrame in NV12 format to tightly-packed NV12
// planes. Unlike grdp_copy_nv12_to_i420, this keeps the interleaved UV plane
// intact so SDL2 can upload it via SDL_UpdateNVTexture without CPU-side
// deinterleaving.
// When the source frame is tight-packed (linesize == stride), a single bulk
// memcpy is used per plane instead of per-row copies.
static void grdp_copy_nv12(
const AVFrame *f,
uint8_t *ydst, uint8_t *uvdst,
int w, int h)
{
int uv_bytes = ((w + 1) / 2) * 2;
int ph = (h + 1) / 2;
if (f->linesize[0] == w)
memcpy(ydst, f->data[0], (size_t)w * h);
else
for (int y = 0; y < h; y++)
memcpy(ydst + y * w, f->data[0] + y * f->linesize[0], w);
if (f->linesize[1] == uv_bytes)
memcpy(uvdst, f->data[1], (size_t)uv_bytes * ph);
else
for (int y = 0; y < ph; y++)
memcpy(uvdst + y * uv_bytes, f->data[1] + y * f->linesize[1], uv_bytes);
}
// grdp_nv12_to_bgra_rows is the row-range variant of grdp_nv12_to_bgra.
// Only rows [start_row, end_row) are written; the rest of dst is untouched.
// This allows the caller to parallelise the conversion across goroutines.
static void grdp_nv12_to_bgra_rows(
const AVFrame *src, uint8_t *dst, int dst_stride, int full_range,
int start_row, int end_row)
{
int width = src->width;
if (start_row < 0) start_row = 0;
if (end_row > src->height) end_row = src->height;
#ifdef __ARM_NEON__
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = start_row; row < end_row; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *uvrow = src->data[1] + (row >> 1) * src->linesize[1];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 15 < width; col += 16)
grdp_nv12_to_bgra_neon_16(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
for (; col + 7 < width; col += 8)
grdp_nv12_to_bgra_neon_8(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
for (; col < width; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#elif defined(__SSE2__)
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = start_row; row < end_row; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *uvrow = src->data[1] + (row >> 1) * src->linesize[1];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 7 < width; col += 8)
grdp_nv12_to_bgra_sse2_8(yrow, uvrow, drow, col, ky, kr, kgu, kgv, kb, yoff);
for (; col < width; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#else
for (int row = start_row; row < end_row; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *uvrow = src->data[1] + (row >> 1) * src->linesize[1];
uint8_t *drow = dst + row * dst_stride;
for (int col = 0; col < width; col++) {
int u = (int)uvrow[(col >> 1) * 2 ] - 128;
int v = (int)uvrow[(col >> 1) * 2 + 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#endif
}
// grdp_yuv420p_to_bgra_rows is the row-range variant of grdp_yuv420p_to_bgra.
static void grdp_yuv420p_to_bgra_rows(
const AVFrame *src, uint8_t *dst, int dst_stride, int full_range,
int start_row, int end_row)
{
int width = src->width;
if (start_row < 0) start_row = 0;
if (end_row > src->height) end_row = src->height;
#ifdef __ARM_NEON__
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = start_row; row < end_row; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *urow = src->data[1] + (row >> 1) * src->linesize[1];
const uint8_t *vrow = src->data[2] + (row >> 1) * src->linesize[2];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 15 < width; col += 16)
grdp_yuv420p_to_bgra_neon_16(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
for (; col + 7 < width; col += 8)
grdp_yuv420p_to_bgra_neon_8(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
for (; col < width; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#elif defined(__SSE2__)
int16_t ky = full_range ? 256 : 298;
int16_t kr = full_range ? 359 : 409;
int16_t kgu = full_range ? 88 : 100;
int16_t kgv = full_range ? 183 : 208;
int16_t kb = full_range ? 454 : 516;
int16_t yoff = full_range ? 0 : 16;
for (int row = start_row; row < end_row; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *urow = src->data[1] + (row >> 1) * src->linesize[1];
const uint8_t *vrow = src->data[2] + (row >> 1) * src->linesize[2];
uint8_t *drow = dst + row * dst_stride;
int col = 0;
for (; col + 7 < width; col += 8)
grdp_yuv420p_to_bgra_sse2_8(yrow, urow, vrow, drow, col,
ky, kr, kgu, kgv, kb, yoff);
for (; col < width; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#else
for (int row = start_row; row < end_row; row++) {
const uint8_t *yrow = src->data[0] + row * src->linesize[0];
const uint8_t *urow = src->data[1] + (row >> 1) * src->linesize[1];
const uint8_t *vrow = src->data[2] + (row >> 1) * src->linesize[2];
uint8_t *drow = dst + row * dst_stride;
for (int col = 0; col < width; col++) {
int u = (int)urow[col >> 1] - 128;
int v = (int)vrow[col >> 1] - 128;
grdp_bt601_pixel((int)yrow[col], u, v, full_range, drow + col*4);
}
}
#endif
}
// grdp_find_v4l2m2m returns the h264_v4l2m2m decoder if FFmpeg was compiled
// with V4L2 M2M support (common on Linux/Raspberry Pi). Returns NULL otherwise.
static const AVCodec *grdp_find_v4l2m2m(void) {
return avcodec_find_decoder_by_name("h264_v4l2m2m");
}
*/
import "C"
import (
"fmt"
"log/slog"
"runtime"
"sync"
"sync/atomic"
"time"
"unsafe"
"git.zeroonesoft.cn/golib/rdplib/plugin/rdpgfx"
)
// useSwscale controls whether YUV420P/YUVJ420P and NV12 frames are converted
// to BGRA via swscale (SIMD-accelerated on x86_64) or via hand-written C loops.
// On ARM64, swscale's non-accelerated paths ignore sws_setColorspaceDetails,
// producing a strong green cast; the hand-written BT.601 loops are used instead.
// On x86_64, swscale is both correct and significantly faster (SSSE3/AVX2).
var useSwscale = runtime.GOARCH != "arm64"
// convertWorkers is the number of goroutines used to parallelise the YUV→BGRA
// conversion. Capped at 4 so we don't thrash the cache with too many threads
// writing into the same output buffer.
var convertWorkers = min(runtime.GOMAXPROCS(0), 4)
// convertParallelMinH is the minimum frame height at which row-parallel
// conversion is enabled. For small frames the goroutine overhead outweighs
// the gain from parallelism.
const convertParallelMinH = 480
// rowConvertJob is the work item dispatched to convertWorkerPool goroutines.
type rowConvertJob struct {
fn func(s, e C.int)
s, e C.int
}
// convertWorkerPool maintains N persistent goroutines for row-parallel
// YUV→BGRA conversion, eliminating the per-frame goroutine allocation cost.
// At 30fps with 4 workers the old code created ~120 goroutines/second; this
// pool amortises that overhead down to zero after the first frame.
type convertWorkerPool struct {
jobs chan rowConvertJob
done chan struct{}
n int
}
func newConvertWorkerPool(n int) *convertWorkerPool {
p := &convertWorkerPool{
jobs: make(chan rowConvertJob, n),
done: make(chan struct{}, n),
n: n,
}
for range n {
go func() {
for job := range p.jobs {
job.fn(job.s, job.e)
p.done <- struct{}{}
}
}()
}
return p
}
// dispatch partitions [0, h) into p.n equal bands and executes fn
// concurrently across the pool's persistent goroutines.
func (p *convertWorkerPool) dispatch(h int, fn func(s, e C.int)) {
step := (h + p.n - 1) / p.n
count := 0
for i := 0; i < p.n; i++ {
s := i * step
e := s + step
if e > h {
e = h
}
if s >= h {
break
}
p.jobs <- rowConvertJob{fn: fn, s: C.int(s), e: C.int(e)}
count++
}
for range count {
<-p.done
}
}
var (
globalConvertPool *convertWorkerPool
globalConvertPoolOnce sync.Once
)
// parallelConvertRows partitions [0, h) into convertWorkers equal bands and
// calls fn(startRow, endRow) concurrently via a persistent worker pool. For
// small frames (h < convertParallelMinH) or when convertWorkers <= 1 the
// function is called serially to avoid scheduling overhead.
func parallelConvertRows(h int, fn func(s, e C.int)) {
n := convertWorkers
if n <= 1 || h < convertParallelMinH {
fn(0, C.int(h))
return
}
globalConvertPoolOnce.Do(func() {
globalConvertPool = newConvertWorkerPool(n)
})
globalConvertPool.dispatch(h, fn)
}
// avLogOnce ensures grdp_suppress_av_log is called only once per process.
var avLogOnce sync.Once
// avcFreezeThreshold is the duration of no decoded output from the HW decoder
// lowChromaThreshold is the UV value below which a chroma sample is considered
// abnormally low. Valid YUV content centres chroma near 128; corruption from
// stale IDR priming or uninitialised buffers collapses chroma toward 0. The
// observed SW-fallback corruption produces U≈60, V≈42, so 72 catches those
// green-monochrome frames while staying well below the U≈101–122, V≈88–122
// range seen in healthy desktop content.
const lowChromaThreshold = 72
// after which it is marked broken. The application-level watchdog then
// reconnects the RDP session. VideoToolbox (macOS) can stall for 2-3 seconds
// while processing a new IDR/SPS frame; 6 seconds gives it enough headroom to
// recover naturally before we declare it broken. FreeRDP takes a similar
// passive approach: it drops failed frames without hard resets or IDR requests
// and waits for the server to resume naturally.
// This threshold applies to the initial-stall case (hwReady=false).
const avcFreezeThreshold = 6 * time.Second
// avcHWReadyFreezeThreshold is the point at which a stalled HW decoder stops
// accepting new packets. VideoToolbox can legitimately pause for several
// seconds when flushing its internal reference pipeline at an IDR/GOP
// boundary; 5 s is chosen because the CGo call (avcodec_send_packet) itself
// permanently blocks after ~5.75 s of stall on macOS VideoToolbox. The
// pre-flight guard in Decode() bails out *before* the CGo call to prevent the
// decodeLoop goroutine from hanging inside CGo.
//
// Crossing this threshold does NOT immediately mark the decoder broken.
// Instead Decode() enters a recovery-probe window (avcHWRecoveryWindow) and
// keeps probing avcodec_receive_frame without sending new packets. If
// VideoToolbox produces a frame during that window the stall clock is reset
// and normal decoding resumes; only if the window is exhausted is the decoder
// marked broken.
const avcHWReadyFreezeThreshold = 5 * time.Second
// avcHWEarlyFreezeThreshold is a shorter stall threshold applied during the
// first avcHWEarlyFrameLimit packets sent to the HW decoder after each
// decoder initialisation or flush. VideoToolbox exhibits a characteristic
// stall pattern at RDP session start (and after a forced flush): it processes
// a small burst of frames, then freezes for 8+ seconds without recovering.
// The normal 7 s threshold is designed for mid-session IDR stalls that
// self-resolve in 2-3 s; in the early phase a genuine VT stall is
// distinguishable because it persists well beyond 5 s. Using a shorter
// threshold here reduces the visible freeze by ~2 s while avoiding false
// positives from transient 3-4 s null-frame bursts at IDR/GOP boundaries.
const avcHWEarlyFreezeThreshold = 5 * time.Second
// avcHWEarlyFrameLimit is the number of packets sent to the HW decoder
// (hwSentCount) below which avcHWEarlyFreezeThreshold is used instead of
// avcHWReadyFreezeThreshold. hwSentCount resets to zero on every
// avcodec_send_packet failure (decoder flush), so this threshold also covers
// the early window after an in-session flush. 50 packets at 30 fps ≈ 1.7 s,
// comfortably covering the unstable session-start window without interfering
// with normal mid-session IDR stalls.
const avcHWEarlyFrameLimit = 50
// avcHWRecoveryWindow is how long Decode() probes for pending output after
// a stall is detected (either from avcHWReadyFreezeThreshold or from the
// early null-frame count detector). After this window, if VT has not
// produced a real frame, the decoder is declared broken and a soft reset
// is triggered. 300 ms is sufficient: any frame VT had buffered will have
// surfaced well within 300 ms, and YouTube / gnome-remote-desktop delivers a
// fresh IDR within ~2 s of the soft reset anyway.
const avcHWRecoveryWindow = 300 * time.Millisecond
// avcHWNullFrameStallLimit is the number of consecutive null (blank) frames
// the HW decoder may produce during the early session window (hwSentCount <
// avcHWEarlyFrameLimit) before triggering a stall-probe. VideoToolbox
// legitimately outputs a handful of null frames at IDR boundaries during
// session start (observed: 5–25 frames / up to ~1 s at 30 fps).
const avcHWNullFrameStallLimit = 25
// avcHWEarlyStallMinElapsed is the minimum wall-clock time since the first
// packet was sent (hwFirstSendTime) before the early-window null-frame stall
// probe is allowed to fire. VideoToolbox needs roughly 1 s to initialise
// its pipeline regardless of how fast packets arrive, so at high packet rates
// (e.g. a burst at session start) the 25-frame count threshold is hit in
// milliseconds — far too quickly to distinguish a genuine stall from normal
// initialisation. By requiring at least 2 s of elapsed time we avoid
// false-positive probes while still detecting real stalls well before the
// 7-second safety valve.
const avcHWEarlyStallMinElapsed = 2 * time.Second
// avcHWMidSessionNullFrameLimit is the null-frame count threshold used for
// mid-session stall detection (hwSentCount >= avcHWEarlyFrameLimit).
// Normal GOP / mid-session IDR boundaries can produce up to ~25 null frames
// (~1 s at 30 fps); a genuine VideoToolbox stall produces hundreds. 15
// frames ≈ 0.5 s at 30 fps — this is more aggressive than the previous 20
// and may occasionally trigger a SW fallback at a noisy GOP boundary, but
// it recovers from genuine VT stalls noticeably faster. Observed logs show
// zero mid-session null frames outside of the fatal VT stall, so the trade-off
// is acceptable for the target use case.
const avcHWMidSessionNullFrameLimit = 15
// avcHWConsecDroppedFrameLimit is the number of consecutive HW-decoded
// frames that may be classified as warm-up/zero-fill/low-chroma junk (see
// the hwNeedsZeroCheck and low-chroma guards in convertFrame) before the
// decoder is declared broken, even though avcodec_receive_frame keeps
// returning frames (so hwConsecNullFrames / lastSuccessTime never reflect a
// stall). Under normal operation hwNeedsZeroCheck disarms itself after the
// first non-dropped frame, so this streak should only ever be a handful of
// frames long; a genuine VideoToolbox malfunction can instead keep emitting
// black/zero content indefinitely, leaving the on-screen video frozen on a
// black frame while the existing stall detectors — which only watch for the
// *absence* of frames — stay quiet. 150 frames is ~5 s at 30 fps.
const avcHWConsecDroppedFrameLimit = 150
// avcHWDroppedFrameStallThreshold is the elapsed-time companion to
// avcHWConsecDroppedFrameLimit: even at a low or irregular frame rate, a
// dropped-frame streak lasting this long is not a normal warm-up burst.
const avcHWDroppedFrameStallThreshold = 5 * time.Second
// avcHWConsecDroppedFrameMinCount is the minimum streak length required
// before avcHWDroppedFrameStallThreshold is honoured, so a single slow
// frame arriving 5 s after the previous one cannot trip the time-based leg
// on its own.
const avcHWConsecDroppedFrameMinCount = 10
// keyframeWaitLimit is the maximum number of non-IDR packets we drop while
// waiting for a keyframe after a decoder reset or flush. gnome-remote-desktop
// and similar servers send an IDR approximately every 15-25 seconds; using 900
// frames (~30s at 30 fps) ensures we catch the next natural IDR even under
// variable server GOP intervals. After this limit the SW decoder attempts
// error-concealment decode; the HW decoder marks itself broken instead (HW
// codecs like VideoToolbox cannot recover without a proper IDR).
const keyframeWaitLimit = 900
// keyframeWaitTimeout is the maximum wall-clock time an HW decoder waits for
// an IDR after entering needsKeyFrame=true. ForceRefresh is sent every 2 s,
// so the server should respond within a few seconds. If no IDR arrives within
// this window the HW decoder marks itself broken so the soft-reset / reconnect
// chain escalates quickly rather than waiting the full keyframeWaitLimit
// (~30 s) of dropped packets. The HW path cannot do error-concealment, so a
// longer window gives the server more chances to deliver a natural IDR.
const keyframeWaitTimeout = 15 * time.Second
// keyframeWaitTimeoutSW is the keyframe-wait wall-clock limit for detached SW
// decoders (aux decoder h264dec2, no watchdog channel). These decoders do not
// have an external timer to terminate the wait, so we attempt error-concealment
// sooner and let the caller tear down and recreate the decoder on the next IDR.
const keyframeWaitTimeoutSW = 5 * time.Second
// keyframeWaitTimeoutSWFallback is the keyframe-wait timer for the main SW
// fallback decoder (created after a VideoToolbox stall). Set to 3 s to give
// Windows Server enough time to respond to the ForceRefresh (SuppressOutput
// toggle) with a fresh IDR before escalating to a full reconnect. Some
// Windows Server versions respond within 1–2 s; others never respond in
// AVC444 mode, in which case the reconnect happens after 3 s regardless.
// The 3 s window avoids spurious reconnects when the server responds slowly.
const keyframeWaitTimeoutSWFallback = 3 * time.Second
// profileWindow is the number of HW frames over which Decode aggregates
// timing measurements before logging an INFO summary. At 30 fps this is
// roughly one log line every ~10 s.
const profileWindow = 300
type ffmpegDecoder struct {
codecCtx *C.AVCodecContext
packet *C.AVPacket
frame *C.AVFrame
swFrame *C.AVFrame
mapFrame *C.AVFrame // reusable frame for av_hwframe_map zero-copy transfers
hwMapSupported int8 // 0=unknown, 1=supported, -1=unsupported
swsCtx *C.struct_SwsContext
useHW bool
hwPixFmt C.enum_AVPixelFormat
lastW C.int
lastH C.int
lastFmt C.enum_AVPixelFormat
lastFullRange C.int // tracks fullRange used when swsCtx was last configured
lastSuccessTime time.Time // wall-clock time of the last successfully decoded frame
lastSendTime time.Time // wall-clock time of the last avcodec_send_packet call
lastReceiveTime time.Time // wall-clock time of the last Decode() call (updated on every call)
hwFirstSendTime time.Time // wall-clock time of the first packet sent to the HW decoder
needsKeyFrame bool // drop packets until an IDR/SPS is received
keyframeWaitCount int // P-frames dropped so far while needsKeyFrame=true
keyframeWaitStart time.Time // wall-clock time of the first dropped P-frame while waiting for IDR
hwReady bool // HW decoder has produced at least one frame
hwSentCount int // packets sent to HW decoder (for diagnostics)
swFrameCount int // frames decoded by SW decoder (for diagnostics)
hwFrameCount int // frames decoded by HW decoder (for diagnostics)
broken bool // decoder is unrecoverable; stop producing frames so the app reconnects
brokenReason rdpgfx.H264BrokenReason
timerBroken atomic.Bool // set by background timers when probe/IDR timeouts expire
timerBrokenReason atomic.Int32
proceededWithoutKeyframe bool // "proceed without keyframe" path was taken; AVERROR here means broken
stallProbeStart time.Time // wall-clock time we entered the stall recovery-probe window
stallTimer *time.Timer // fires after avcHWRecoveryWindow to mark broken independently of frame rate
hwConsecNullFrames int // consecutive HW null frames since last real frame; for early stall detection
hwConsecDroppedFrames int // consecutive HW frames classified as warm-up/zero-fill/low-chroma and dropped
hwFirstDroppedTime time.Time // wall-clock time the current dropped-frame streak began
kfWaitTimer *time.Timer // fires after kfWaitTimeoutVal to mark broken independently of frame rate
kfWaitTimeoutVal time.Duration // per-decoder IDR wait limit (varies by decoder type)
watchdogCh chan<- struct{} // signals GfxHandler.decodeLoop to call maybeNotifyDecoderBroken
// Profiling: aggregated timing stats over the last profileWindow frames
// for the HW path. Helps determine whether convertFrame
// (av_hwframe_transfer_data + colour conversion) is the bottleneck that
// causes VideoToolbox to stall by holding GPU frames too long.
profFrames int
profSendNs int64 // total ns in avcodec_send_packet
profRecvNs int64 // total ns in avcodec_receive_frame loop (excluding convert)
profConvertNs int64 // total ns in convertFrame (transfer + colour conversion)
profTransferNs int64 // total ns in av_hwframe_transfer_data only
profMaxConvNs int64 // worst-case convertFrame duration in window
profMaxSendNs int64 // worst-case avcodec_send_packet duration in window
profMaxRecvNs int64 // worst-case avcodec_receive_frame duration in window
// outRing holds two recyclable BGRA destination buffers. convertFrame
// rotates between them so each Decode() avoids allocating a fresh
// width*height*4 buffer (≈8MB at 1920×1080 → ≈240MB/s of GC garbage at
// 30fps). Two slots is sufficient because emitBitmap is called
// synchronously from the rdpgfx PDU loop and always finishes (the
// caller has copied the data into its backing image) before the next
// Decode runs. outRingIdx selects the slot to use *next*.
outRing [2][]byte
outRingIdx int
// outI420Ring holds two recyclable I420 frame slots for GPU-accelerated
// rendering via SDL2 IYUV textures. Same ring/lifecycle pattern as outRing.
// outI420Enabled gates I420 extraction (set by DecodeWithI420); outNV12Enabled
// also triggers I420 extraction for non-NV12 sources (e.g. software YUV420P)
// so the caller can use LastI420() to update the AVC444 Y cache. lastI420
// is the result from the most recent convertFrame call.
outI420Ring [2]rdpgfx.H264FrameI420
outI420RingIdx int
outI420Enabled bool
lastI420 *rdpgfx.H264FrameI420
// outNV12Ring holds native NV12 frames for SDL2 NV12 texture upload.
// This path is especially useful for VideoToolbox, whose transferred
// software frames are usually NV12.
outNV12Ring [2]rdpgfx.H264FrameNV12
outNV12RingIdx int
outNV12Enabled bool
lastNV12 *rdpgfx.H264FrameNV12
// regionHint carries dirty-rect hints for region-aware YUV→BGRA conversion.
// setRegionHint populates these fields; Decode() captures them into local
// variables at entry (clearing nRegionHints) so stale hints can never
// carry over to a subsequent unrelated frame.
regionHint []C.uint16_t // flat [left,top,right,bottom,...] per rect
nRegionHints C.int // number of valid rects in regionHint
// hwNeedsZeroCheck is set to true on decoder creation and after each
// avcodec_flush_buffers call. When set, convertFrame checks the first
// NV12 output frame for a zero-filled chroma plane (U=0, V=0).
//
// VideoToolbox sometimes returns a zero-initialised IOSurface for the
// first decoded frame after init or a pipeline flush. The BT.601
// limited-range conversion of (Y=0, U=0, V=0) produces BGRA(0,135,0,255)
// — a full-screen dark-green frame that manifests as a brief "green
// curtain" in the UI. Valid NV12 chroma always centres on 128
// (limited-range [16,240], full-range centred on 128), so U=0 and V=0
// occurring simultaneously at the centre pixel unambiguously signals an
// uninitialised buffer rather than real video content.
hwNeedsZeroCheck bool
// swNeedsZeroCheck mirrors hwNeedsZeroCheck for the software (YUV420P)
// decode path. The FFmpeg SW decoder similarly outputs all-zero frames
// on the first packet after creation or avcodec_flush_buffers, especially
// when primed with a stale cached IDR. Cleared after the first valid
// (non-zero-fill) YUV420P frame is seen.
swNeedsZeroCheck bool
// swConsecDroppedFrames/swFirstDroppedTime are diagnostic-only counters
// mirroring hwConsecDroppedFrames/hwFirstDroppedTime for the SW warm-up
// check above: they let the log line report how long the current
// black-frame streak has lasted, without changing drop behaviour.
swConsecDroppedFrames int
swFirstDroppedTime time.Time
}
// extractI420fromSrc extracts I420 planar data from srcFrame into the ring
// buffer and stores a pointer in d.lastI420. Called from convertFrame() when
// outI420Enabled is true, before av_frame_unref(d.swFrame).
// Sets d.lastI420 = nil when the pixel format is not directly supported.
func (d *ffmpegDecoder) extractI420fromSrc(srcFrame *C.AVFrame) {
srcFmt := C.enum_AVPixelFormat(srcFrame.format)
if srcFmt != C.AV_PIX_FMT_YUV420P && srcFmt != C.AV_PIX_FMT_YUVJ420P &&
srcFmt != C.AV_PIX_FMT_NV12 {
d.lastI420 = nil
return
}
w := int(srcFrame.width)
h := int(srcFrame.height)
pw := (w + 1) / 2
ph := (h + 1) / 2
ySize := w * h
uvSize := pw * ph
slot := &d.outI420Ring[d.outI420RingIdx]
d.outI420RingIdx ^= 1
if cap(slot.Y) < ySize {
slot.Y = make([]byte, ySize)
} else {
slot.Y = slot.Y[:ySize]
}
if cap(slot.U) < uvSize {
slot.U = make([]byte, uvSize)
} else {
slot.U = slot.U[:uvSize]
}
if cap(slot.V) < uvSize {
slot.V = make([]byte, uvSize)
} else {
slot.V = slot.V[:uvSize]
}
slot.YStride = w
slot.UStride = pw
slot.VStride = pw
slot.Width = w
slot.Height = h
slot.FullRange = srcFmt == C.AV_PIX_FMT_YUVJ420P || srcFrame.color_range == 2
if srcFmt == C.AV_PIX_FMT_YUV420P || srcFmt == C.AV_PIX_FMT_YUVJ420P {
C.grdp_copy_yuv420p_to_i420(srcFrame,
(*C.uint8_t)(unsafe.Pointer(&slot.Y[0])),
(*C.uint8_t)(unsafe.Pointer(&slot.U[0])),
(*C.uint8_t)(unsafe.Pointer(&slot.V[0])),
C.int(w), C.int(h))
} else {
C.grdp_copy_nv12_to_i420(srcFrame,
(*C.uint8_t)(unsafe.Pointer(&slot.Y[0])),
(*C.uint8_t)(unsafe.Pointer(&slot.U[0])),
(*C.uint8_t)(unsafe.Pointer(&slot.V[0])),
C.int(w), C.int(h))
}
d.lastI420 = slot
}
// extractNV12fromSrc copies native NV12 planes from srcFrame into the ring
// buffer and stores a pointer in d.lastNV12. It intentionally does not
// deinterleave chroma, so SDL2 NV12 texture uploads avoid the I420 conversion
// work required by extractI420fromSrc.
func (d *ffmpegDecoder) extractNV12fromSrc(srcFrame *C.AVFrame) {
if C.enum_AVPixelFormat(srcFrame.format) != C.AV_PIX_FMT_NV12 {
d.lastNV12 = nil
return
}
w := int(srcFrame.width)
h := int(srcFrame.height)
uvStride := ((w + 1) / 2) * 2
ph := (h + 1) / 2
ySize := w * h
uvSize := uvStride * ph
slot := &d.outNV12Ring[d.outNV12RingIdx]
d.outNV12RingIdx ^= 1
if cap(slot.Y) < ySize {
slot.Y = make([]byte, ySize)
} else {
slot.Y = slot.Y[:ySize]
}
if cap(slot.UV) < uvSize {
slot.UV = make([]byte, uvSize)
} else {
slot.UV = slot.UV[:uvSize]
}
slot.YStride = w
slot.UVStride = uvStride
slot.Width = w
slot.Height = h
slot.FullRange = srcFrame.color_range == 2
C.grdp_copy_nv12(srcFrame,
(*C.uint8_t)(unsafe.Pointer(&slot.Y[0])),
(*C.uint8_t)(unsafe.Pointer(&slot.UV[0])),
C.int(w), C.int(h))
d.lastNV12 = slot
}
func newH264DecoderInternal(watchdogCh chan<- struct{}, forceSW bool, kfWaitTimeout time.Duration) rdpgfx.H264Decoder {
// Suppress FFmpeg stderr output (e.g. "[h264 @ ...] sps_id out of range").
// grdp emits its own slog messages for H.264 recovery events.
avLogOnce.Do(func() { C.grdp_suppress_av_log() })
codec := C.avcodec_find_decoder(C.AV_CODEC_ID_H264)
if codec == nil {
slog.Warn("H.264: codec not found in FFmpeg")
return nil
}
codecCtx := C.avcodec_alloc_context3(codec)
if codecCtx == nil {
return nil
}
// alreadyOpened is set when a codec-specific path (e.g. V4L2 M2M) opens
// its own AVCodecContext before the shared avcodec_open2 call below.
alreadyOpened := false
d := &ffmpegDecoder{
codecCtx: codecCtx,
hwPixFmt: C.AV_PIX_FMT_NONE,
lastFmt: C.AV_PIX_FMT_NONE,
needsKeyFrame: true, // always wait for a clean IDR before feeding packets
hwNeedsZeroCheck: true, // check first NV12 output for zero-filled IOSurface
swNeedsZeroCheck: true, // check first YUV420P output for zero-fill warm-up
watchdogCh: watchdogCh,
kfWaitTimeoutVal: kfWaitTimeout,
}
// Always enable LOW_DELAY: RDP H.264 streams are transmitted in display
// order with no B-frame reordering, so the default reorder buffer adds
// no value and (especially on VideoToolbox) makes the decoder appear
// stalled between IDRs.
C.grdp_set_low_delay(codecCtx)
if !forceSW {
// Probe available hardware acceleration backends.
hwType := C.av_hwdevice_iterate_types(C.AV_HWDEVICE_TYPE_NONE)
for hwType != C.AV_HWDEVICE_TYPE_NONE {
var devCtx *C.AVBufferRef
if C.av_hwdevice_ctx_create(&devCtx, hwType, nil, nil, 0) == 0 {
// Find the HW pixel format for this device type.
hwPixFmt := C.enum_AVPixelFormat(C.AV_PIX_FMT_NONE)
for i := C.int(0); ; i++ {
cfg := C.avcodec_get_hw_config(codec, i)
if cfg == nil {
break
}
if cfg.device_type == hwType &&
(cfg.methods&C.AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) != 0 {
hwPixFmt = cfg.pix_fmt
break
}
}
if hwPixFmt != C.AV_PIX_FMT_NONE {
codecCtx.hw_device_ctx = C.av_buffer_ref(devCtx)
C.grdp_set_hw_pix_fmt(codecCtx, hwPixFmt)
C.grdp_set_get_format(codecCtx)
d.useHW = true
d.hwPixFmt = hwPixFmt
name := C.av_hwdevice_get_type_name(hwType)
slog.Debug("H.264: hardware acceleration enabled", "type", C.GoString(name))
}
C.av_buffer_unref(&devCtx)
if d.useHW {
break
}
}
hwType = C.av_hwdevice_iterate_types(hwType)
}
// If no hwdevice backend was found, try the V4L2 M2M codec (h264_v4l2m2m).
// This is a standalone FFmpeg codec that directly outputs NV12 frames in
// CPU-accessible memory and is commonly available on Linux SoCs such as
// Raspberry Pi 4/5. It is not exposed via av_hwdevice_iterate_types and
// must be probed explicitly. On macOS or when FFmpeg is built without V4L2
// support, avcodec_find_decoder_by_name returns nil and the probe is a no-op.
if !d.useHW {
v4l2Codec := C.grdp_find_v4l2m2m()
if v4l2Codec != nil {
v4l2Ctx := C.avcodec_alloc_context3(v4l2Codec)
if v4l2Ctx != nil {
C.grdp_set_low_delay(v4l2Ctx)
if C.avcodec_open2(v4l2Ctx, v4l2Codec, nil) >= 0 {
// Replace the standard h264 context with the V4L2 M2M one.
// avcodec_free_context sets d.codecCtx to nil via its **ctx arg.
C.avcodec_free_context(&d.codecCtx)
d.codecCtx = v4l2Ctx
codecCtx = v4l2Ctx
d.useHW = true
d.hwNeedsZeroCheck = false // no zero-filled IOSurface on V4L2
alreadyOpened = true
slog.Debug("H.264: V4L2 M2M hardware acceleration enabled")
} else {
C.avcodec_free_context(&v4l2Ctx)
}
}
}
}
}
if !d.useHW {
if d.watchdogCh != nil {
// Main decoder switching from VideoToolbox to FFmpeg after a stall.
slog.Debug("H.264: using software decoding (SW fallback)")
} else {
// Aux decoder (h264dec2) or initial SW-only decoder — always pure SW.
slog.Debug("H.264: using software decoding")
}
// Limit the decoded picture buffer to 1 reference frame so each frame
// is emitted immediately rather than waiting for up to
// max_dec_frame_buffering (often 8) frames to accumulate. RDP H.264
// streams use sequential P-frames that only reference the immediately
// preceding frame, so this is safe. VideoToolbox (HW path) has its
// own zero-latency output mechanism and does not need this.
codecCtx.refs = 1
// Use slice-level threading only. Frame-level threading (the FFmpeg
// default) introduces a one-frame reorder delay that conflicts with
// AV_CODEC_FLAG_LOW_DELAY and causes each decoded frame to arrive one
// frame late — effectively doubling input latency. Slice threading
// parallelises within a single frame with no added latency, which is
// beneficial when the server encodes multiple slices per frame.
codecCtx.thread_type = C.FF_THREAD_SLICE
}
if !alreadyOpened {
if C.avcodec_open2(codecCtx, codec, nil) < 0 {
C.avcodec_free_context(&d.codecCtx)
return nil
}
}
d.packet = C.av_packet_alloc()
d.frame = C.av_frame_alloc()
d.swFrame = C.av_frame_alloc()
d.mapFrame = C.av_frame_alloc()
if d.packet == nil || d.frame == nil || d.swFrame == nil || d.mapFrame == nil {
d.Close()
return nil
}
// Arm the keyframe-wait timer immediately so recovery is triggered even
// when the server sends no frames after a soft reset (e.g. static screen
// or ForceRefresh ignored by the server). If an IDR arrives first,
// Decode() cancels the timer. Decoders without a watchdog channel
// (e.g. h264dec2) are not armed here — they are managed separately.
if watchdogCh != nil {
d.kfWaitTimer = time.AfterFunc(kfWaitTimeout, func() {
d.timerBrokenReason.Store(int32(rdpgfx.H264BrokenReasonNoIDR))
d.timerBroken.Store(true)
d.signalWatchdog()
})
}
runtime.SetFinalizer(d, func(dec *ffmpegDecoder) { dec.Close() })
return d
}
func (d *ffmpegDecoder) NeedsKeyframe() bool {
return d.needsKeyFrame
}
func (d *ffmpegDecoder) NeedsIDR() bool {
return d.needsKeyFrame
}
func (d *ffmpegDecoder) IsBroken() bool {
return d.broken || d.timerBroken.Load()
}
func (d *ffmpegDecoder) BrokenReason() rdpgfx.H264BrokenReason {
if d.brokenReason != rdpgfx.H264BrokenReasonNone {
return d.brokenReason
}
return rdpgfx.H264BrokenReason(d.timerBrokenReason.Load())
}
func (d *ffmpegDecoder) ForceBroken(reason rdpgfx.H264BrokenReason) {
d.markBroken(reason)
}
// markBroken sets d.broken and stops any pending background timers.
// Called from inside Decode() (decodeLoop goroutine) when a timeout fires.
func (d *ffmpegDecoder) markBroken(reason rdpgfx.H264BrokenReason) {
d.broken = true
if reason != rdpgfx.H264BrokenReasonNone {
d.brokenReason = reason
d.timerBrokenReason.Store(int32(reason))
}
d.stopTimers()
}
// stopTimers cancels the stall-probe and IDR-wait background timers.
func (d *ffmpegDecoder) stopTimers() {
if d.stallTimer != nil {
d.stallTimer.Stop()
d.stallTimer = nil
}
if d.kfWaitTimer != nil {
d.kfWaitTimer.Stop()
d.kfWaitTimer = nil
}
}
// signalWatchdog sends a non-blocking signal to the GfxHandler decodeLoop so
// it calls maybeNotifyDecoderBroken even when no server frames are arriving.
func (d *ffmpegDecoder) signalWatchdog() {
if d.watchdogCh == nil {
return
}
select {
case d.watchdogCh <- struct{}{}:
default:
}
}
// HardResetCount always returns 0 — hard resets have been removed.
// The method is kept to satisfy the rdpgfx.H264Decoder interface used by GfxHandler.
func (d *ffmpegDecoder) HardResetCount() int {
return 0
}
func (d *ffmpegDecoder) LastReceiveTime() time.Time {
return d.lastReceiveTime
}
// setRegionHint specifies dirty rectangles for the next Decode call. When
// set, convertFrame will use region-aware YUV→BGRA conversion and only write
// pixels within the provided rectangles, skipping unchanged areas of the frame.
// Must be called immediately before Decode; Decode clears the hint at entry so
// it cannot accidentally apply to a later unrelated frame.
func (d *ffmpegDecoder) SetRegionHint(rects [][4]uint16) {
n := len(rects)
need := n * 4
if cap(d.regionHint) < need {
d.regionHint = make([]C.uint16_t, need)
} else {
d.regionHint = d.regionHint[:need]
}
for i, r := range rects {
d.regionHint[i*4+0] = C.uint16_t(r[0])
d.regionHint[i*4+1] = C.uint16_t(r[1])
d.regionHint[i*4+2] = C.uint16_t(r[2])
d.regionHint[i*4+3] = C.uint16_t(r[3])
}
d.nRegionHints = C.int(n)
}
func (d *ffmpegDecoder) Decode(h264Data []byte) (*rdpgfx.H264Frame, error) {
// Capture and clear the pending region hint immediately so that any early
// return (broken, keyframe wait, etc.) cannot leave stale hints that would
// incorrectly apply to a subsequent unrelated frame.
regHint := d.regionHint
nReg := d.nRegionHints
d.nRegionHints = 0
if len(h264Data) == 0 {
return nil, nil
}
if !d.outI420Enabled {
d.lastI420 = nil
}
if !d.outNV12Enabled {
d.lastNV12 = nil
}
if d.broken {
// HW decoder is unrecoverable. Stop feeding packets so no frames
// are produced; the application-level watchdog will reconnect.
return nil, nil
}
// A background timer may have fired and set timerBroken while Decode()
// was not being called (static screen → server sends no frames).
// Propagate it to broken so all downstream checks see a consistent state.
if d.timerBroken.Load() {
d.markBroken(rdpgfx.H264BrokenReason(d.timerBrokenReason.Load()))
return nil, nil
}
// Track every call, including those that return early (probe mode, keyframe
// wait, etc.). Keep the previous receive time for idle detection before we
// overwrite it with the current call timestamp.
now := time.Now()
prevReceiveTime := d.lastReceiveTime
d.lastReceiveTime = now
// After a decoder reset we must resync with a fresh IDR from the server.
// After a SW decoder flush, wait for an IDR before resuming decoding.
// If the server never sends one within keyframeWaitLimit packets,
// attempt error-concealment decode anyway.
// FFmpeg's "[h264 @ ...] sps_id out of range" errors are suppressed at
// the av_log level (AV_LOG_FATAL) set in newH264Decoder; grdp emits its
// own slog warning instead.
// Single pass over the Annex B stream: detect IDR/SPS NAL presence.
scan := rdpgfx.ScanH264Packet(h264Data)
if d.needsKeyFrame {
if !scan.HasKeyFrame {
d.keyframeWaitCount++
if d.keyframeWaitCount == 1 {
d.keyframeWaitStart = time.Now()
if d.useHW {
slog.Debug("H.264: HW decoder waiting for IDR")
// kfWaitTimer was armed at decoder creation; only start a new
// one here if the decoder was created without a watchdog channel
// (no timer was armed at creation time).
if d.kfWaitTimer == nil {
d.kfWaitTimer = time.AfterFunc(d.kfWaitTimeoutVal, func() {
d.timerBrokenReason.Store(int32(rdpgfx.H264BrokenReasonNoIDR))
d.timerBroken.Store(true)
d.signalWatchdog()
})
}
}
} else if d.keyframeWaitCount%30 == 0 {
slog.Debug("H.264: still waiting for IDR",
"waited", d.keyframeWaitCount,
"waitedFor", time.Since(d.keyframeWaitStart).Round(time.Millisecond))
}
kfTimeout := d.kfWaitTimeoutVal // use per-decoder limit (HW: 15 s, SW fallback: 8 s)
if !d.useHW && d.watchdogCh == nil {
// Detached aux SW decoder (h264dec2): shorter wait so it is
// torn down and recreated quickly on the next stream2 IDR.
kfTimeout = keyframeWaitTimeoutSW // 5 s
}
waitedTooLong := !d.keyframeWaitStart.IsZero() &&
time.Since(d.keyframeWaitStart) >= kfTimeout
if d.keyframeWaitCount >= keyframeWaitLimit || waitedTooLong {
if d.useHW || d.watchdogCh != nil {
// HW decoders (e.g. VideoToolbox) and watchdog-armed SW
// decoders (main decoder SW fallback) cannot recover
// without a proper IDR. Mark broken so the recovery
// chain can escalate. For the SW fallback case, error-
// concealment on P-frames without reference frames always
// fails (avcodec_send_packet returns EINVAL) and would
// only produce a spurious WARN and an immediate reconnect.
slog.Debug("H.264: no IDR received, marking broken",
"hw", d.useHW,
"waited", d.keyframeWaitCount,
"waitedFor", time.Since(d.keyframeWaitStart).Round(time.Millisecond))
d.markBroken(rdpgfx.H264BrokenReasonNoIDR)
return nil, nil
}
slog.Debug("H.264: aux SW decoder: no IDR received, attempting error-concealment",
"waited", d.keyframeWaitCount,
"waitedFor", time.Since(d.keyframeWaitStart).Round(time.Millisecond))
d.needsKeyFrame = false
d.keyframeWaitCount = 0
d.keyframeWaitStart = time.Time{}
d.proceededWithoutKeyframe = true
// fall through and attempt SW error-concealment decode
} else {
return nil, nil // drop P-frames while waiting
}
} else {
waitedFor := time.Duration(0)
if !d.keyframeWaitStart.IsZero() {
waitedFor = time.Since(d.keyframeWaitStart).Round(time.Millisecond)
}
slog.Debug("H.264: IDR received, resuming decode",
"hw", d.useHW, "waitedFor", waitedFor)
d.needsKeyFrame = false
d.keyframeWaitCount = 0
d.keyframeWaitStart = time.Time{}
// IDR received — cancel the background wait timer.
if d.kfWaitTimer != nil {
d.kfWaitTimer.Stop()
d.kfWaitTimer = nil
}
}
}
// If we previously proceeded without a keyframe (error-concealment path)
// and the server has now sent a proper IDR, the decoder is back to a clean
// state — clear the flag so a future send failure is not misattributed to
// the (long-past) keyframe wait exhaustion.
if d.proceededWithoutKeyframe && scan.HasKeyFrame {
d.proceededWithoutKeyframe = false
}
// VideoToolbox sometimes returns a zero-filled IOSurface on the first
// frame after any IDR — not only after decoder creation or flush — because
// the hardware pipeline must drain and reset its reference frames before it
// can produce the new intra frame. Re-arm the zero-check whenever we
// receive an IDR so that convertFrame discards any spurious green frame that
// VideoToolbox outputs during that transition.
if d.useHW && scan.HasKeyFrame {
d.hwNeedsZeroCheck = true
}
// Time-based stall detection for the HW decoder.
//
// hwReady=false: decoder has never produced a frame. If it keeps receiving
// packets without ever outputting anything, the VideoToolbox session failed
// to initialise — mark broken so the soft-reset/reconnect path fires.
//
// hwReady=true: decoder was working. VideoToolbox legitimately stalls for
// several seconds when processing an IDR / scene-change keyframe (it must
// flush its internal reference pipeline before it can resume output).
// Firing broken on these stalls causes unnecessary soft-reset loops. We
// apply avcHWReadyFreezeThreshold here as a pre-flight guard: if the
// decoder has been silent for longer than the threshold we mark it broken
// and return *without* calling avcodec_send_packet. This is critical
// because on macOS VideoToolbox the CGo call itself permanently blocks
// after ~5.75 s of stall, permanently hanging the decodeLoop goroutine.
//
// False-positive guard: if the RDP server itself was idle (no packets sent
// for at least avcHWReadyFreezeThreshold), the elapsed time since
// lastSuccessTime reflects server silence, not a VideoToolbox deadlock.
// In that case we reset the stall clock so the threshold applies only to
// periods where packets were actually flowing into the decoder.
if d.useHW && !d.hwReady && !d.hwFirstSendTime.IsZero() {
if stalledFor := time.Since(d.hwFirstSendTime); stalledFor >= avcFreezeThreshold {
slog.Warn("H.264: HW decoder failed to produce first frame, marking broken",
"stalledFor", stalledFor, "hwSentCount", d.hwSentCount)
d.markBroken(rdpgfx.H264BrokenReasonInitFailure)
return nil, nil
}
}
if d.useHW && d.hwReady && !d.lastSuccessTime.IsZero() {
// Early probe: the null-frame count detector may have set stallProbeStart
// before stalledFor reached readyThreshold. Handle it here so we skip
// avcodec_send_packet during the probe window even while stalledFor is
// still below the 7-second CGo-safe threshold.
if !d.stallProbeStart.IsZero() {
readyThreshold := avcHWReadyFreezeThreshold
if d.hwSentCount < avcHWEarlyFrameLimit {
readyThreshold = avcHWEarlyFreezeThreshold
}
if stalledFor := time.Since(d.lastSuccessTime); stalledFor < readyThreshold {
// Probe active but main threshold not yet crossed. Try to drain
// a frame that VT may have buffered; if found the stall was
// transient and we resume normally.
if C.avcodec_receive_frame(d.codecCtx, d.frame) >= 0 {
C.av_frame_unref(d.frame)
d.lastSuccessTime = time.Now()
d.hwConsecNullFrames = 0
d.stallProbeStart = time.Time{}
if d.stallTimer != nil {
d.stallTimer.Stop()
d.stallTimer = nil
}
slog.Debug("H.264: HW decoder recovered during early probe (drain found frame)",
"hwSentCount", d.hwSentCount)
// Fall through to send the current packet normally.
} else if probedFor := time.Since(d.stallProbeStart); probedFor >= avcHWRecoveryWindow {
slog.Debug("H.264: HW decoder early-probe timed out, marking broken",
"probedFor", probedFor.Round(time.Second),
"frozenFor", stalledFor.Round(time.Second),
"hwSentCount", d.hwSentCount)
d.markBroken(rdpgfx.H264BrokenReasonHWStall)
return nil, nil
} else {
// Still inside probe window: skip send_packet to avoid
// feeding the stalled VT pipeline.
return nil, nil
}
}
// else: stalledFor >= readyThreshold — fall through to the
// threshold-based block below which also handles the probe.
}
}
if d.useHW && d.hwReady && !d.lastSuccessTime.IsZero() {
readyThreshold := avcHWReadyFreezeThreshold
if d.hwSentCount < avcHWEarlyFrameLimit {
readyThreshold = avcHWEarlyFreezeThreshold
}
if stalledFor := time.Since(d.lastSuccessTime); stalledFor >= readyThreshold {
// If no packet had arrived since the previous Decode() call during
// the apparent stall, the server was simply idle (e.g. screen was
// static). Reset the stall clock so we don't misfire on the first
// packet after a server-side pause.
if prevReceiveTime.IsZero() || now.Sub(prevReceiveTime) >= readyThreshold {
slog.Debug("H.264: HW decoder stall clock reset (server was idle)",
"idleFor", stalledFor, "hwSentCount", d.hwSentCount)
d.lastSuccessTime = now
d.stallProbeStart = time.Time{}
if d.stallTimer != nil {
d.stallTimer.Stop()
d.stallTimer = nil
}
} else {
// Probe for pending output that VideoToolbox may be about to
// produce. VT legitimately stalls for several seconds at a
// GOP/IDR boundary while it flushes its reference pipeline;
// immediately marking broken would cause an unnecessary
// soft-reset loop followed by a ForceRefresh that the server
// may not honour with a timely IDR.
//
// avcodec_receive_frame is non-blocking and safe to call
// without a preceding send_packet. If a frame emerges VT was
// just slow but is still healthy — reset the stall clock and
// let the current packet be sent normally below.
if C.avcodec_receive_frame(d.codecCtx, d.frame) >= 0 {
C.av_frame_unref(d.frame)
d.lastSuccessTime = time.Now()
d.stallProbeStart = time.Time{}
// Stall resolved — cancel the background probe timer.
if d.stallTimer != nil {
d.stallTimer.Stop()
d.stallTimer = nil
}
slog.Debug("H.264: HW decoder stall clock reset (drain found pending frame)",
"hadBeenSilentFor", stalledFor, "hwSentCount", d.hwSentCount)
// Fall through to send the current packet normally.
} else {
// No output yet. Enter / stay in recovery-probe window.
if d.stallProbeStart.IsZero() {
d.stallProbeStart = now
slog.Debug("H.264: HW decoder stall detected, probing for recovery",
"frozenFor", stalledFor.Round(time.Millisecond),
"hwSentCount", d.hwSentCount)
// Start a background timer so the probe window expires
// even when the server sends no more frames.
d.stallTimer = time.AfterFunc(avcHWRecoveryWindow, func() {
d.timerBrokenReason.Store(int32(rdpgfx.H264BrokenReasonHWStall))
d.timerBroken.Store(true)
d.signalWatchdog()
})
} else if probedFor := time.Since(d.stallProbeStart); probedFor >= avcHWRecoveryWindow {
slog.Debug("H.264: HW decoder recovery probe timed out, marking broken",
"totalFrozen", stalledFor.Round(time.Second),
"probedFor", probedFor.Round(time.Second),
"hwSentCount", d.hwSentCount)
d.markBroken(rdpgfx.H264BrokenReasonHWStall)
return nil, nil
}
// Still in recovery window: skip send_packet to avoid the
// ~5.75 s CGo deadlock and wait for VT to resume.
return nil, nil
}
}
} else if !d.stallProbeStart.IsZero() {
// Stall resolved (lastSuccessTime updated by normal frame output).
slog.Debug("H.264: HW decoder recovered from stall",
"probedFor", time.Since(d.stallProbeStart).Round(time.Millisecond))
d.stallProbeStart = time.Time{}
// Cancel the background probe timer — VT recovered on its own.
if d.stallTimer != nil {
d.stallTimer.Stop()
d.stallTimer = nil
}
}
}
// Pass the Go slice's backing array directly to avcodec_send_packet
// instead of allocating + copying via C.CBytes for every packet.
// FFmpeg copies the buffer internally for non-refcounted packets, so the
// memory only needs to remain valid for the duration of the C call —
// runtime.KeepAlive guarantees this.
d.packet.data = (*C.uint8_t)(unsafe.Pointer(&h264Data[0]))
d.packet.size = C.int(len(h264Data))
// Count packets sent to HW decoder (for init timeout tracking).
if d.useHW {
d.hwSentCount++
hwNow := time.Now()
if d.hwSentCount == 1 {
d.hwFirstSendTime = hwNow
}
d.lastSendTime = hwNow
}
sendStart := time.Now()
ret := C.avcodec_send_packet(d.codecCtx, d.packet)
sendNs := time.Since(sendStart).Nanoseconds()
// Make sure the Go-managed h264Data backing array is not collected or
// moved while FFmpeg is reading from it inside the C call above.
runtime.KeepAlive(h264Data)
// Drop the Go pointer from the AVPacket immediately so a subsequent
// avcodec_* call can't dereference stale memory.
d.packet.data = nil
d.packet.size = 0
if ret < 0 {
// Both HW and SW: flush the decoder pipeline and wait for a fresh IDR.
// Reset the HW stall-timer so it starts fresh after the IDR arrives,
// not from before this failed send attempt.
slog.Debug("H.264: avcodec_send_packet failed, flushing decoder to recover",
"hw", d.useHW, "err", int(ret))
C.avcodec_flush_buffers(d.codecCtx)
prev := d.proceededWithoutKeyframe
d.needsKeyFrame = true
d.keyframeWaitCount = 0
d.keyframeWaitStart = time.Time{}
d.proceededWithoutKeyframe = false
if d.useHW {
d.hwFirstSendTime = time.Time{} // restart stall clock after IDR
d.hwSentCount = 0
d.hwConsecNullFrames = 0
d.hwConsecDroppedFrames = 0
d.hwFirstDroppedTime = time.Time{}
d.hwNeedsZeroCheck = true // re-check for zero-filled IOSurface after flush
if !d.hwReady && prev {
// We gave up waiting for an IDR and tried a P-frame anyway, and
// VideoToolbox rejected it. There is no further recovery possible
// for this decoder context — mark broken so the soft-reset /
// reconnect chain can proceed.
slog.Warn("H.264: HW decoder rejected packet after keyframe wait exhaustion, marking broken",
"err", int(ret))
d.markBroken(rdpgfx.H264BrokenReasonNoIDR)
}
} else {
// Re-arm the SW zero-check: libavcodec similarly outputs zero frames
// on the first packet after a flush, especially when primed with a
// stale cached IDR.
d.swNeedsZeroCheck = true
if prev {
// SW decoder: error-concealment attempt (proceededWithoutKeyframe)
// failed — avcodec_send_packet rejected the P-frame. Without
// marking broken the decoder would loop: wait 900 frames → try →
// fail → reset → wait 900 frames → ... Mark broken so the
// soft-reset / reconnect chain can escalate instead.
slog.Warn("H.264: SW decoder rejected packet after keyframe wait exhaustion, marking broken",
"err", int(ret))
d.markBroken(rdpgfx.H264BrokenReasonNoIDR)
}
}
return nil, nil
}
// Receive decoded frame(s); keep the last one.
var result *rdpgfx.H264Frame
var recvNs, convertNs, transferNs, maxConvNs int64
for {
recvStart := time.Now()
ret = C.avcodec_receive_frame(d.codecCtx, d.frame)
recvNs += time.Since(recvStart).Nanoseconds()
if ret < 0 {
break // EAGAIN (need more input) or EOF
}
convStart := time.Now()
f, tNs, err := d.convertFrame(regHint, nReg)
dur := time.Since(convStart).Nanoseconds()
convertNs += dur
transferNs += tNs
if dur > maxConvNs {
maxConvNs = dur
}
C.av_frame_unref(d.frame)
if err != nil {
return nil, err
}
result = f
}
// I420/NV12 fast paths return nil for the BGRA frame but still represent a
// successfully decoded frame. Count them as success for health tracking.
gotFrame := result != nil || d.lastI420 != nil || d.lastNV12 != nil
if gotFrame {
d.lastSuccessTime = time.Now()
d.hwConsecNullFrames = 0
if d.useHW {
// A dropped frame (warm-up/zero-fill/low-chroma junk — see
// convertFrame) still counts as "gotFrame" above, which keeps
// resetting the null-frame/lastSuccessTime stall detectors. If
// VideoToolbox never produces anything but junk, those detectors
// would otherwise never fire and the app would freeze on a black
// frame forever. Track dropped-frame streaks independently and
// escalate to broken/HWStall once the streak is clearly abnormal.
if result != nil && result.Dropped {
if d.hwConsecDroppedFrames == 0 {
d.hwFirstDroppedTime = time.Now()
}
d.hwConsecDroppedFrames++
droppedFor := time.Since(d.hwFirstDroppedTime)
if d.hwConsecDroppedFrames >= avcHWConsecDroppedFrameLimit ||
(d.hwConsecDroppedFrames >= avcHWConsecDroppedFrameMinCount &&
droppedFor >= avcHWDroppedFrameStallThreshold) {
slog.Warn("H.264: HW decoder producing only warm-up/junk frames, marking broken",
"consecDroppedFrames", d.hwConsecDroppedFrames,
"droppedFor", droppedFor.Round(time.Millisecond),
"hwSentCount", d.hwSentCount)
d.markBroken(rdpgfx.H264BrokenReasonHWStall)
return nil, nil
}
} else {
d.hwConsecDroppedFrames = 0
d.hwFirstDroppedTime = time.Time{}
}
if !d.hwReady {
slog.Debug("H.264: HW decoder produced first frame",
"hwSentCount", d.hwSentCount)
}
d.hwReady = true
// Aggregate per-frame timing for the HW path.
d.profFrames++
d.profSendNs += sendNs
d.profRecvNs += recvNs
d.profConvertNs += convertNs
d.profTransferNs += transferNs
if maxConvNs > d.profMaxConvNs {
d.profMaxConvNs = maxConvNs
}
if sendNs > d.profMaxSendNs {
d.profMaxSendNs = sendNs
}
if recvNs > d.profMaxRecvNs {
d.profMaxRecvNs = recvNs
}
if d.profFrames >= profileWindow {
n := int64(d.profFrames)
slog.Debug("H.264: HW decode timing",
"frames", d.profFrames,
"avgSendUs", d.profSendNs/n/1000,
"avgRecvUs", d.profRecvNs/n/1000,
"avgConvertUs", d.profConvertNs/n/1000,
"avgTransferUs", d.profTransferNs/n/1000,
"maxSendUs", d.profMaxSendNs/1000,
"maxRecvUs", d.profMaxRecvNs/1000,
"maxConvertUs", d.profMaxConvNs/1000)
d.profFrames = 0
d.profSendNs = 0
d.profRecvNs = 0
d.profConvertNs = 0
d.profTransferNs = 0
d.profMaxConvNs = 0
d.profMaxSendNs = 0
d.profMaxRecvNs = 0
}
}
} else { // !gotFrame
if d.useHW && d.hwReady {
stalledFor := time.Since(d.lastSuccessTime)
d.hwConsecNullFrames++
slog.Debug("H.264: HW null frame", "frozenFor", stalledFor,
"hwSentCount", d.hwSentCount)
// Stall probe: trigger a probe if we accumulate many consecutive
// null frames before the 7-second CGo-safe threshold. Two tiers:
// • Early window (hwSentCount < avcHWEarlyFrameLimit): use
// avcHWNullFrameStallLimit (25). Reduces visible freeze from
// ~10 s to ~4 s for a genuine VT stall at session start.
// • Mid-session (hwSentCount >= avcHWEarlyFrameLimit): use
// avcHWMidSessionNullFrameLimit (30 ≈ 1 s at 30 fps).
// Normal GOP boundaries produce ≤25 null frames so there is
// minimal headroom; genuine stalls persist for hundreds of
// null frames. Reduces visible freeze from ~2.5 s to ~1 s.
//
// IDR-flush suppression: when VideoToolbox just received a new IDR
// (hwNeedsZeroCheck=true), null frames are part of its normal pipeline
// flush — it must drain its internal reference frames before outputting
// the new intra picture. Triggering a stall probe on these IDR-induced
// null frames causes premature SW fallback and an unnecessary reconnect:
// observed logs show VT recovering naturally within ~1-2 s of the IDR,
// and the post-reconnect VT session exhibits the same null-frame burst
// (which resolves on its own). Suppress the count-based probe while
// hwNeedsZeroCheck is true; the 7-second safety valve below remains
// the backstop for genuine stalls that do not self-resolve.
earlyStall := d.hwSentCount < avcHWEarlyFrameLimit &&
d.hwConsecNullFrames >= avcHWNullFrameStallLimit &&
!d.hwFirstSendTime.IsZero() &&
time.Since(d.hwFirstSendTime) >= avcHWEarlyStallMinElapsed
midStall := d.hwSentCount >= avcHWEarlyFrameLimit &&
d.hwConsecNullFrames >= avcHWMidSessionNullFrameLimit &&
!d.hwNeedsZeroCheck // IDR-flush null frames: let VT recover naturally
if (earlyStall || midStall) && d.stallProbeStart.IsZero() {
slog.Debug("H.264: HW decoder stall detected (null frame count), entering probe",
"consecNullFrames", d.hwConsecNullFrames,
"frozenFor", stalledFor.Round(time.Millisecond),
"hwSentCount", d.hwSentCount)
d.stallProbeStart = time.Now()
d.stallTimer = time.AfterFunc(avcHWRecoveryWindow, func() {
d.timerBrokenReason.Store(int32(rdpgfx.H264BrokenReasonHWStall))
d.timerBroken.Store(true)
d.signalWatchdog()
})
}
// Safety valve: if the pre-flight probe window is NOT active and
// the decoder has been silent past the threshold, VideoToolbox is
// genuinely stuck. In probe mode the pre-flight block (above) is
// responsible for declaring the decoder broken — the safety valve
// must not interfere with the probe window countdown.
if d.stallProbeStart.IsZero() && stalledFor >= avcHWReadyFreezeThreshold {
slog.Warn("H.264: HW decoder stall timeout (safety valve), marking broken",
"frozenFor", stalledFor, "hwSentCount", d.hwSentCount)
d.markBroken(rdpgfx.H264BrokenReasonHWStall)
}
}
}
return result, nil
}
// DecodeWithI420 implements the rdpgfx.I420Decoder interface. It decodes H.264 NAL
// data and returns both a BGRA frame (for the surface backing store) and an
// optional I420 frame for GPU-accelerated rendering via SDL2 IYUV textures.
// The I420 frame is nil when the decoder's pixel format is not directly
// supported (e.g. swscale paths that have already consumed the source frame
// before we could extract planar data, or hardware-decoded frames whose
// transfer format is not YUV420P or NV12). Callers must fall back to BGRA
// rendering when I420 is nil.
func (d *ffmpegDecoder) DecodeWithI420(h264Data []byte) (*rdpgfx.H264Frame, *rdpgfx.H264FrameI420, error) {
d.outI420Enabled = true
d.lastI420 = nil
frame, err := d.Decode(h264Data)
d.outI420Enabled = false
return frame, d.lastI420, err
}
// LastI420 returns the I420 frame produced during the most recent Decode,
// DecodeWithI420, or DecodeWithNV12 call. For DecodeWithNV12, this is
// non-nil when the source format was YUV420P/YUVJ420P (software decoder)
// rather than NV12, allowing callers to refresh the AVC444 Y cache even
// when no native NV12 planes were available. Must be called from the same
// goroutine as Decode; the returned pointer is valid until the next call.
func (d *ffmpegDecoder) LastI420() *rdpgfx.H264FrameI420 {
return d.lastI420
}
// DecodeWithNV12 implements the rdpgfx.NV12Decoder interface. It decodes H.264 NAL
// data and returns native NV12 output when FFmpeg produces NV12, avoiding the
// extra NV12->I420 deinterleave used by DecodeWithI420.
func (d *ffmpegDecoder) DecodeWithNV12(h264Data []byte) (*rdpgfx.H264Frame, *rdpgfx.H264FrameNV12, error) {
d.outNV12Enabled = true
d.lastNV12 = nil
frame, err := d.Decode(h264Data)
d.outNV12Enabled = false
return frame, d.lastNV12, err
}
func (d *ffmpegDecoder) convertFrame(regionHint []C.uint16_t, nRegions C.int) (*rdpgfx.H264Frame, int64, error) {
srcFrame := d.frame
var transferNs int64
usedMapFrame := false
// Transfer from GPU to CPU memory if using hardware acceleration.
if d.useHW && d.frame.format == C.int(d.hwPixFmt) {
tStart := time.Now()
// Prefer zero-copy CPU mapping (av_hwframe_map) over a copy
// (av_hwframe_transfer_data). VideoToolbox on macOS stores decoded
// frames in IOSurface-backed shared memory, so mapping is supported
// and avoids a full GPU→RAM copy of the pixel data.
// hwMapSupported: 0=unknown (first frame), 1=ok, -1=unsupported.
if d.hwMapSupported >= 0 {
C.av_frame_unref(d.mapFrame)
if ret := C.grdp_hwframe_map(d.mapFrame, d.frame); ret >= 0 {
d.hwMapSupported = 1
srcFrame = d.mapFrame
usedMapFrame = true
} else if d.hwMapSupported == 0 {
// First attempt failed; mark unsupported and fall through.
d.hwMapSupported = -1
}
}
if !usedMapFrame {
ret := C.av_hwframe_transfer_data(d.swFrame, d.frame, 0)
transferNs = time.Since(tStart).Nanoseconds()
if ret < 0 {
return nil, transferNs, fmt.Errorf("av_hwframe_transfer_data: error %d", int(ret))
}
srcFrame = d.swFrame
} else {
transferNs = time.Since(tStart).Nanoseconds()
}
}
w := srcFrame.width
h := srcFrame.height
srcFmt := C.enum_AVPixelFormat(srcFrame.format)
// Fast path for SDL2 NV12 texture upload. VideoToolbox usually transfers
// hardware-decoded H.264 frames as NV12, so keeping the interleaved UV plane
// intact avoids the chroma deinterleave required by I420.
if d.outNV12Enabled && srcFmt == C.AV_PIX_FMT_NV12 {
// Apply the same zero-filled IOSurface check as the BGRA NV12 path
// (see comment near hwNeedsZeroCheck below). The NV12 fast path
// previously bypassed this check, allowing zero UV (→ green) frames to
// reach the NV12 callback.
if d.useHW && d.hwNeedsZeroCheck {
var sy, su, sv C.uint8_t
C.grdp_sample_nv12(srcFrame, &sy, &su, &sv)
drop := false
if su == 0 && sv == 0 {
drop = true
slog.Debug("H.264: dropping zero-UV HW frame in NV12 path (IOSurface not ready)",
"Y", int(sy))
} else if sy == 0 && su >= 124 && su <= 132 && sv >= 124 && sv <= 132 &&
C.grdp_is_warmup_nv12(srcFrame, 4) != 0 {
drop = true
// Diagnostic-only fields: streak duration/count come from the
// caller's tracking (set on the previous drop, since this call
// happens before the caller increments the counters for THIS
// frame), and the 3x3 Y grid confirms whether the whole
// sampled area is really black or just the single centre
// pixel checked above. Logged on every Nth drop (not every
// frame) to avoid flooding, since a real bug reproduction may
// run for many seconds at high frame rate.
streakElapsed := time.Duration(0)
if !d.hwFirstDroppedTime.IsZero() {
streakElapsed = time.Since(d.hwFirstDroppedTime)
}
if d.hwConsecDroppedFrames%10 == 0 {
var grid [9]C.uint8_t
C.grdp_debug_sample_y_grid(srcFrame, &grid[0])
ys := make([]int, 9)
for i, v := range grid {
ys[i] = int(v)
}
slog.Debug("H.264: dropping black warm-up HW frame in NV12 path",
"Y", int(sy), "U", int(su), "V", int(sv),
"streakCount", d.hwConsecDroppedFrames,
"streakElapsed", streakElapsed.Round(time.Millisecond),
"yGrid", ys)
} else {
slog.Debug("H.264: dropping black warm-up HW frame in NV12 path",
"Y", int(sy), "U", int(su), "V", int(sv),
"streakCount", d.hwConsecDroppedFrames,
"streakElapsed", streakElapsed.Round(time.Millisecond))
}
}
if drop {
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
d.hwNeedsZeroCheck = false
}
// Permanent guard: the single-pixel check above only runs while
// hwNeedsZeroCheck is armed, but stale IDR priming in the SW fallback
// (and rare HW corruption) can produce green-monochrome frames later in
// the session. Sample a 3x3 grid and drop frames whose chroma has
// collapsed to ~0 across most of the frame.
if C.grdp_is_low_chroma_nv12(srcFrame, lowChromaThreshold, 6) != 0 {
slog.Debug("H.264: dropping low-chroma NV12 frame (green-monochrome corruption)")
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
d.extractNV12fromSrc(srcFrame)
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
return nil, transferNs, nil
}
// Fast path: when I420 output is requested and the source pixel format is
// directly convertible to I420 (YUV420P, YUVJ420P, NV12), skip the
// YUV→BGRA conversion entirely. The SDL2 IYUV texture render path does
// not need BGRA; eliminating the conversion saves roughly w*h*4 bytes of
// CPU writes per frame (≈8 MB at 1920×1080).
// Trade-off: blitToSurface will not be called for this frame, so the
// RDPGFX surface backing store will not reflect the H.264 content.
// SurfaceToSurface reads from this surface will see stale data, but in
// practice H.264-decoded surfaces are destination-only in normal sessions.
if d.outI420Enabled {
if srcFmt == C.AV_PIX_FMT_YUV420P || srcFmt == C.AV_PIX_FMT_YUVJ420P ||
srcFmt == C.AV_PIX_FMT_NV12 {
// Apply the zero-filled IOSurface check before extracting I420.
// The I420 fast path previously bypassed hwNeedsZeroCheck entirely:
// VT could output Y≠0, UV=0 (partially initialised IOSurface) which
// isNullYUVFrame would not catch (only Y=0 && UV=0 triggers it),
// causing a bright-green frame. Additionally, even an all-zero
// frame (Y=0, UV=0) would poison the AVC444 Y cache via
// updateAVC444YCache(), producing green LC=2 combine artifacts for
// the next 500 ms window. Check UV here and drop the frame if zero,
// matching the BGRA NV12 path behaviour.
if srcFmt == C.AV_PIX_FMT_NV12 && d.useHW && d.hwNeedsZeroCheck {
var sy, su, sv C.uint8_t
C.grdp_sample_nv12(srcFrame, &sy, &su, &sv)
drop := false
if su == 0 && sv == 0 {
drop = true
slog.Debug("H.264: dropping zero-UV HW frame in I420 path (IOSurface not ready)",
"Y", int(sy))
} else if sy == 0 && su >= 124 && su <= 132 && sv >= 124 && sv <= 132 &&
C.grdp_is_warmup_nv12(srcFrame, 4) != 0 {
drop = true
slog.Debug("H.264: dropping black warm-up HW frame in I420 path",
"Y", int(sy), "U", int(su), "V", int(sv))
}
if drop {
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
// Return Dropped so Decode() counts this as success (health
// tracking stays correct) while signalling grdp to skip the
// I420 callback and Y-cache update.
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
d.hwNeedsZeroCheck = false
}
// Permanent low-chroma guard for the I420 fast path, matching the
// NV12 fast path above. This protects the SDL2 IYUV texture and the
// AVC444 Y cache from green-monochrome corruption.
if srcFmt == C.AV_PIX_FMT_NV12 {
if C.grdp_is_low_chroma_nv12(srcFrame, lowChromaThreshold, 6) != 0 {
slog.Debug("H.264: dropping low-chroma NV12 frame in I420 path (green-monochrome corruption)")
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
} else if srcFmt == C.AV_PIX_FMT_YUV420P || srcFmt == C.AV_PIX_FMT_YUVJ420P {
if C.grdp_is_low_chroma_yuv420p(srcFrame, lowChromaThreshold, 6) != 0 {
slog.Debug("H.264: dropping low-chroma YUV420P frame in I420 path (green-monochrome corruption)")
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
}
d.extractI420fromSrc(srcFrame)
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
return nil, transferNs, nil
}
}
outSize := int(w) * int(h) * 4
// Borrow the next ring buffer instead of allocating fresh. At 1920×1080
// this avoids an 8MB allocation every frame.
out := d.outRing[d.outRingIdx]
if cap(out) < outSize {
out = make([]byte, outSize)
} else {
out = out[:outSize]
}
d.outRing[d.outRingIdx] = out
d.outRingIdx ^= 1
// For planar YUV420P (both limited- and full-range variants), use our own
// BT.601 conversion instead of swscale on ARM64. swscale has no
// accelerated colorspace-conversion path for yuv420p→bgra on ARM64 and
// its non-accelerated fallback ignores sws_setColorspaceDetails,
// producing a strong green cast. On x86_64 swscale is both correct and
// significantly faster (SIMD-accelerated), so we route through swscale
// there and only fall back to the hand-written loop on ARM64.
//
// For NV12 (VideoToolbox HW transfer output) on ARM64, bypass swscale for
// the same reason: the non-accelerated ARM64 path ignores
// sws_setColorspaceDetails and produces a green cast on zero-filled frames.
//
// Exception: when dirty-region hints are provided, always use the
// hand-written region-aware functions even on x86_64. swscale has no
// partial-frame API, so it would convert the full frame unconditionally.
// For typical RDP partial-screen updates (cursors, small windows) the
// scalar BT.601 loop over only the dirty pixels is significantly faster
// than running swscale over the entire frame.
haveRegions := nRegions > 0 && len(regionHint) > 0
var convErr error
switch {
case (srcFmt == C.AV_PIX_FMT_YUV420P || srcFmt == C.AV_PIX_FMT_YUVJ420P) && (!useSwscale || haveRegions):
fullRange := C.int(0)
if srcFmt == C.AV_PIX_FMT_YUVJ420P || srcFrame.color_range == 2 {
fullRange = 1
}
// Sample the centre pixel for diagnostic logging and SW zero-frame checks.
// For SW decoder, always sample: zero-UV is checked on every frame.
needSample := d.hwFrameCount < 3 || !d.useHW
var sy, su, sv C.uint8_t
if needSample {
C.grdp_sample_yuv(srcFrame, &sy, &su, &sv)
}
// Log the centre-pixel YUV values for the first few frames so we
// can distinguish H.264 decode corruption from colour-conversion bugs.
if d.hwFrameCount < 3 || (!d.useHW && d.swFrameCount < 3) {
slog.Debug("H.264: frame sample (yuv420p)",
"hw", d.useHW,
"frame", d.hwFrameCount,
"fmt", int(srcFmt),
"colorRange", int(srcFrame.color_range),
"fullRange", int(fullRange),
"Y", int(sy), "U", int(su), "V", int(sv),
"w", int(w), "h", int(h))
if d.useHW {
d.hwFrameCount++
} else {
d.swFrameCount++
}
}
// Drop corrupted/uninitialised frames from the SW decoder.
//
// Zero-UV check (permanent): U=0 and V=0 simultaneously never occurs in
// real BT.601/BT.709 content — valid chroma always centres on 128. This
// pattern indicates a reference-frame mismatch (stale-IDR priming with
// live P-frames that reference a different state) or an uninitialised
// buffer. BT.601 conversion of (Y=0, U=0, V=0) → BGRA(0,135,0,255)
// is a full-screen bright-green frame. Apply permanently; cost is one
// pixel sample per frame which is negligible.
//
// Near-zero UV check: stale-IDR priming sometimes produces chroma that
// is not exactly 0/0 but still extremely low (e.g. U=0, V=2). Valid
// content, even dark scenes, keeps chroma centred near 128; both planes
// collapsing to ~0 is a reliable sign of decoder corruption and renders
// as a full-screen green frame (BT.601 of Cb≈0,Cr≈0 → BGRA(0,~135,0)).
// Drop when U and V are both abnormally low regardless of luma: corrupt
// SW-fallback frames have been observed at Y≈40, U≈60, V≈42, which is
// well below the healthy desktop-content range (U≈101–122, V≈88–122)
// but above the old threshold of 24. lowChromaThreshold (72) catches
// these green-monochrome frames without affecting valid content.
//
// Warm-up black check (gated by swNeedsZeroCheck): Y=0, U≈128, V≈128
// with all luma near zero is libavcodec's initial black output before
// the pipeline is ready. Only checked around decoder creation/flush.
if !d.useHW {
if su == 0 && sv == 0 {
slog.Debug("H.264: dropping zero-UV SW frame (reference mismatch or uninitialised)",
"Y", int(sy))
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
if su < lowChromaThreshold && sv < lowChromaThreshold {
slog.Debug("H.264: dropping near-zero-UV SW frame (stale IDR prime?)",
"Y", int(sy), "U", int(su), "V", int(sv))
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
// The centre-pixel check above can miss corruption that leaves the
// centre valid while the rest of the frame collapses to near-zero
// chroma. Sample a 3x3 grid and drop if most samples are abnormally
// low — this catches the green-monochrome frames produced by stale
// IDR priming in the SW fallback decoder.
if C.grdp_is_low_chroma_yuv420p(srcFrame, lowChromaThreshold, 6) != 0 {
slog.Debug("H.264: dropping low-chroma YUV420P frame in BGRA path (green-monochrome corruption)")
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
if d.swNeedsZeroCheck {
if sy == 0 && su >= 124 && su <= 132 && sv >= 124 && sv <= 132 &&
C.grdp_is_warmup_nv12(srcFrame, 4) != 0 {
if d.swConsecDroppedFrames == 0 {
d.swFirstDroppedTime = time.Now()
}
d.swConsecDroppedFrames++
streakElapsed := time.Since(d.swFirstDroppedTime)
if d.swConsecDroppedFrames%10 == 1 {
var grid [9]C.uint8_t
C.grdp_debug_sample_y_grid(srcFrame, &grid[0])
ys := make([]int, 9)
for i, v := range grid {
ys[i] = int(v)
}
slog.Debug("H.264: dropping black warm-up SW frame",
"Y", int(sy), "U", int(su), "V", int(sv),
"streakCount", d.swConsecDroppedFrames,
"streakElapsed", streakElapsed.Round(time.Millisecond),
"yGrid", ys)
} else {
slog.Debug("H.264: dropping black warm-up SW frame",
"Y", int(sy), "U", int(su), "V", int(sv),
"streakCount", d.swConsecDroppedFrames,
"streakElapsed", streakElapsed.Round(time.Millisecond))
}
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
d.swConsecDroppedFrames = 0
d.swNeedsZeroCheck = false
}
}
if haveRegions {
C.grdp_yuv420p_to_bgra_regions(srcFrame,
(*C.uint8_t)(unsafe.Pointer(&out[0])), C.int(w*4), fullRange,
(*C.uint16_t)(unsafe.Pointer(&regionHint[0])), nRegions)
} else {
dstPtr := (*C.uint8_t)(unsafe.Pointer(&out[0]))
parallelConvertRows(int(h), func(s, e C.int) {
C.grdp_yuv420p_to_bgra_rows(srcFrame, dstPtr, C.int(w*4), fullRange, s, e)
})
}
case srcFmt == C.AV_PIX_FMT_NV12 && (!useSwscale || haveRegions):
fullRange := C.int(0)
if srcFrame.color_range == 2 { // AVCOL_RANGE_JPEG
fullRange = 1
}
frameIdx := d.hwFrameCount
logThis := d.hwFrameCount < 3
needZeroCheck := d.useHW && d.hwNeedsZeroCheck
var sy, su, sv C.uint8_t
if d.hwFrameCount < 3 || needZeroCheck {
C.grdp_sample_nv12(srcFrame, &sy, &su, &sv)
if d.hwFrameCount < 3 {
slog.Debug("H.264: frame sample (nv12)",
"hw", d.useHW,
"frame", d.hwFrameCount,
"fmt", int(srcFmt),
"colorRange", int(srcFrame.color_range),
"fullRange", int(fullRange),
"Y", int(sy), "U", int(su), "V", int(sv),
"w", int(w), "h", int(h))
d.hwFrameCount++
}
}
// Zero-filled IOSurface detection: VideoToolbox sometimes returns an
// uninitialised (all-zero) IOSurface on the first decoded frame after
// decoder init or avcodec_flush_buffers. BT.601 limited-range
// conversion of (Y=0, U=0, V=0) yields BGRA(0,135,0,255), a
// full-screen dark-green frame. Valid NV12 chroma always centres on
// 128, so U=0 and V=0 simultaneously at the centre pixel is an
// unambiguous indicator of an uninitialised buffer. Drop the frame
// and keep hwNeedsZeroCheck set so we continue checking until a frame
// with valid chroma arrives.
//
// A second pattern is Y=0, U≈128, V≈128: VideoToolbox occasionally
// outputs a fully-black frame (Y plane zeroed, UV neutral) for the
// first 1-2 frames after an IDR flush while the pipeline warms up.
// This is detected by sampling Y at a 3×3 grid; if all 9 samples are
// near zero the frame is an uninitialised buffer and is dropped.
if needZeroCheck {
drop := false
if su == 0 && sv == 0 {
drop = true
slog.Debug("H.264: dropping zero-UV HW frame (IOSurface not ready)",
"Y", int(sy))
} else if sy == 0 && su >= 124 && su <= 132 && sv >= 124 && sv <= 132 &&
C.grdp_is_warmup_nv12(srcFrame, 4) != 0 {
drop = true
slog.Debug("H.264: dropping black warm-up HW frame",
"Y", int(sy), "U", int(su), "V", int(sv))
}
if drop {
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
// Return a non-nil Dropped frame so Decode() counts this as a
// successful VideoToolbox output (health tracking stays correct)
// and callers skip the keyframe-request / decoder-broken path.
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
// Valid frame seen — IOSurface is properly populated.
d.hwNeedsZeroCheck = false
}
// Permanent low-chroma guard for the BGRA NV12 path. The single-pixel
// zero-UV check above is only armed around init/flush/IDR; stale IDR
// priming and other decoder corruption can produce green-monochrome
// frames later in the session.
if C.grdp_is_low_chroma_nv12(srcFrame, lowChromaThreshold, 6) != 0 {
slog.Debug("H.264: dropping low-chroma NV12 frame in BGRA path (green-monochrome corruption)")
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
return &rdpgfx.H264Frame{Dropped: true, Width: int(w), Height: int(h)}, transferNs, nil
}
if haveRegions {
C.grdp_nv12_to_bgra_regions(srcFrame,
(*C.uint8_t)(unsafe.Pointer(&out[0])), C.int(w*4), fullRange,
(*C.uint16_t)(unsafe.Pointer(&regionHint[0])), nRegions)
} else {
dstPtr := (*C.uint8_t)(unsafe.Pointer(&out[0]))
parallelConvertRows(int(h), func(s, e C.int) {
C.grdp_nv12_to_bgra_rows(srcFrame, dstPtr, C.int(w*4), fullRange, s, e)
})
}
if logThis {
// Sample NV12 input and BGRA output at multiple positions for
// the first three frames to diagnose colour conversion.
for _, p := range [][2]int{{100, 50}, {500, 50}, {960, 50}, {1400, 50}, {960, 200}} {
px, py := p[0], p[1]
if px >= int(w) || py >= int(h) {
continue
}
var sy, su, sv C.uint8_t
C.grdp_sample_nv12_at(srcFrame, C.int(px), C.int(py), &sy, &su, &sv)
off := (py*int(w) + px) * 4
slog.Debug("H.264: pixel sample (nv12→bgra)",
"frame", frameIdx,
"hw", d.useHW,
"x", px, "y", py,
"Y", int(sy), "U", int(su), "V", int(sv),
"B", out[off], "G", out[off+1], "R", out[off+2])
}
}
default:
// For other formats, use swscale.
swsFmt := C.grdp_yuvj_to_yuv(srcFmt)
fullRange := C.grdp_is_full_range_fmt(srcFmt)
if fullRange == 0 && srcFrame.color_range == 2 { // AVCOL_RANGE_JPEG
fullRange = 1
}
if d.hwFrameCount < 3 {
slog.Debug("H.264: frame sample (swscale)",
"hw", d.useHW,
"frame", d.hwFrameCount,
"fmt", int(srcFmt),
"colorRange", int(srcFrame.color_range),
"fullRange", int(fullRange),
"w", int(w), "h", int(h))
d.hwFrameCount++
}
if w != d.lastW || h != d.lastH || srcFmt != d.lastFmt || fullRange != d.lastFullRange {
if d.swsCtx != nil {
C.sws_freeContext(d.swsCtx)
}
d.swsCtx = C.sws_getContext(
w, h, swsFmt,
w, h, C.AV_PIX_FMT_BGRA,
C.SWS_FAST_BILINEAR, nil, nil, nil,
)
if d.swsCtx == nil {
convErr = fmt.Errorf("sws_getContext failed for %dx%d fmt=%d", w, h, srcFmt)
break
}
C.grdp_sws_set_src_range(d.swsCtx, fullRange)
d.lastW = w
d.lastH = h
d.lastFmt = srcFmt
d.lastFullRange = fullRange
}
C.grdp_frame_to_bgra(d.swsCtx, srcFrame,
(*C.uint8_t)(unsafe.Pointer(&out[0])), C.int(w*4))
}
// Extract I420 when explicitly requested (DecodeWithI420) or when in NV12
// mode but the source is not NV12 (e.g. software decoder producing YUV420P).
// The latter lets callers use LastI420() to refresh the AVC444 Y cache even
// when DecodeWithNV12 returns a BGRA frame instead of native NV12 planes.
if convErr == nil && (d.outI420Enabled || d.outNV12Enabled) {
d.extractI420fromSrc(srcFrame)
}
if usedMapFrame {
C.av_frame_unref(d.mapFrame)
} else if srcFrame == d.swFrame {
C.av_frame_unref(d.swFrame)
}
if convErr != nil {
return nil, transferNs, convErr
}
return &rdpgfx.H264Frame{Data: out, Width: int(w), Height: int(h)}, transferNs, nil
}
func (d *ffmpegDecoder) Close() {
// Stop any background timers so their callbacks don't fire after Close.
d.stopTimers()
if d.swsCtx != nil {
C.sws_freeContext(d.swsCtx)
d.swsCtx = nil
}
if d.frame != nil {
C.av_frame_free(&d.frame)
}
if d.swFrame != nil {
C.av_frame_free(&d.swFrame)
}
if d.mapFrame != nil {
C.av_frame_free(&d.mapFrame)
}
if d.packet != nil {
C.av_packet_free(&d.packet)
}
if d.codecCtx != nil {
C.avcodec_free_context(&d.codecCtx)
}
}
func init() {
rdpgfx.SetH264Backend(&rdpgfx.H264DecoderBackend{
NewHW: func(ch chan<- struct{}) rdpgfx.H264Decoder {
return newH264DecoderInternal(ch, false, keyframeWaitTimeout)
},
NewSW: func() rdpgfx.H264Decoder {
return newH264DecoderInternal(nil, true, keyframeWaitTimeoutSW)
},
NewSWFallback: func(ch chan<- struct{}) rdpgfx.H264Decoder {
return newH264DecoderInternal(ch, true, keyframeWaitTimeoutSWFallback)
},
})
}