//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 #include #include #include #include #include #include #include #ifdef __ARM_NEON__ #include #endif #ifdef __SSE2__ #include #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(®ionHint[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(®ionHint[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) }, }) }