Some videos display a black picture while their audio plays, or freeze after a few frames. 8-bit H.264 videos play normally. The affected files use codecs or pixel formats that the Photos app does not identify. Telegram for macOS exhibits the same behavior, apparently due to sharing the same player code.
I traced the issue in the iOS source (master f1dd7a2d) and identified three decoding bugs. In all three cases, the decoder produces no frames and reports no error to the user.
Incorrect hardware pixel format selection (FFMpegAVCodecContext.m, getPreferredPixelFormat): get_format always returns VideoToolbox. FFmpeg does not offer VideoToolbox for HEVC 12-bit or H.264 9/12-bit, and removes it from the available formats if hardware acceleration initialization fails. Returning it anyway results in Invalid return from get_format(): videotoolbox_vld not in possible list, preventing decoding.
Unsupported high-bit-depth software decoder output (FFMpegMediaVideoFrameDecoder.swift): 10-bit and 12-bit frames produced by software decoders are discarded. This affects dav1d (AV1 without hardware decoding, such as on iPhones before the 15 Pro and M1/M2 Macs) and the H.264/HEVC software decoders. These decoders can output formats such as yuv420p10le, yuv422p10le, and yuv420p12le. FFMpegAVFrame.pixelFormat reports these formats as unsupported, causing convertVideoFrame to drop every frame. Streams that switch from 8-bit to 10-bit consequently freeze.
Invalid MPEG-4 Part 2 format description (createFormatDescriptionFromMpeg4CodecData): FFmpeg's mp4v extradata contains only the DecoderSpecificInfo, but it is passed as the complete esds atom. VideoToolbox rejects the format description with error -12909.
A tested fix is described in the comments:
Fall back to the software pixel format when VideoToolbox is not offered.
Reduce 9–16-bit planar YUV to 8-bit before the existing 4:2:0 conversion.
Wrap the DecoderSpecificInfo in a complete ES_Descriptor.
Using the app's own FFmpeg, dav1d, and libvpx libraries in the iOS simulator, five new decoding tests fail on the current master branch and pass with the fix. In addition, 21 of 73 real-world sample/mode combinations that previously produced zero frames decode fully with the fix, with no regressions observed.
Fix, part 1/4: get_format (FFMpegBinding/Sources/FFMpegAVCodecContext.m)
FFmpeg requires get_format to return a format from the list it offers. When VideoToolbox is not offered (HEVC 12-bit, H.264 9/12-bit) or its init failed, returning the software format lets the compiled-in h264/hevc software decoder take over.
Fix, part 2/4: reducing 10/12-bit frames to 8-bit (FFMpegBinding/Sources/FFMpegAVFrame.m, needs libavutil/pixdesc.h)
static enum AVPixelFormat eightBitFormatForHighBitDepthFormat(enum AVPixelFormat format) { const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(format); if (!desc || (desc->flags & (AV_PIX_FMT_FLAG_HWACCEL | AV_PIX_FMT_FLAG_RGB | AV_PIX_FMT_FLAG_BE | AV_PIX_FMT_FLAG_BITSTREAM | AV_PIX_FMT_FLAG_PAL))) return AV_PIX_FMT_NONE; if (!(desc->flags & AV_PIX_FMT_FLAG_PLANAR) || desc->nb_components < 3) return AV_PIX_FMT_NONE; for (int i = 0; i < desc->nb_components; i++) { if (desc->comp[i].plane != i || desc->comp[i].step != 2 || desc->comp[i].shift != 0 || desc->comp[i].depth <= 8 || desc->comp[i].depth > 16) return AV_PIX_FMT_NONE; } bool a = (desc->flags & AV_PIX_FMT_FLAG_ALPHA) != 0; if (desc->log2_chroma_w == 1 && desc->log2_chroma_h == 1) return a ? AV_PIX_FMT_YUVA420P : AV_PIX_FMT_YUV420P; if (desc->log2_chroma_w == 1 && desc->log2_chroma_h == 0) return a ? AV_PIX_FMT_YUVA422P : AV_PIX_FMT_YUV422P; if (desc->log2_chroma_w == 0 && desc->log2_chroma_h == 0) return a ? AV_PIX_FMT_YUVA444P : AV_PIX_FMT_YUV444P; return AV_PIX_FMT_NONE; }
New FFMpegAVFrame API: - isHighBitDepthPlanarYUV: eightBitFormatForHighBitDepthFormat(format) != NONE - initWithEightBitLayoutOfFrame: allocates a frame of that 8-bit format and size (av_frame_get_buffer) - reduceBitDepthIntoFrame: for each plane (chroma sized with AV_CEIL_RSHIFT by log2_chroma_w/h), shift = depth - 8, dst = min(255, (src + (1 << (shift - 1))) >> shift); then copies color_range.
Fix, part 3/4: using it in MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swift
private var reducedBitDepthFrame: FFMpegAVFrame?
private func eightBitFrame(_ frame: FFMpegAVFrame) -> FFMpegAVFrame? { guard frame.isHighBitDepthPlanarYUV else { return frame } if let existing = self.reducedBitDepthFrame, frame.reduceBitDepth(into: existing) { return existing } guard let created = FFMpegAVFrame(eightBitLayoutOf: frame), frame.reduceBitDepth(into: created) else { return nil } self.reducedBitDepthFrame = created return created }
At both call sites (convertVideoFrameToImage and convertVideoFrame):
- guard let frame = self.planarFrameFor420Conversion(frame) else { + guard let frame = self.eightBitFrame(frame).flatMap(self.planarFrameFor420Conversion) else {
The existing 4:2:2/4:4:4 -> 4:2:0 fold then handles the 8-bit frame. There is no tone mapping, so HDR10 shows with SDR contrast instead of black. Converting a 4K frame takes about 4 ms at -Os.
Fix, part 4/4: MPEG-4 Part 2 esds (MediaPlayer/Sources/FFMpegMediaFrameSourceContextHelpers.swift)
static func mpeg4EsdsAtom(_ extradata: Data) -> Data { if extradata.count > 5 && extradata.prefix(4) == Data([0, 0, 0, 0]) && extradata[extradata.startIndex + 4] == 0x03 { return extradata } func descriptor(_ tag: UInt8, _ payload: Data) -> Data { let size = payload.count var result = Data([tag]) result.append(contentsOf: [UInt8(0x80 | ((size >> 21) & 0x7f)), UInt8(0x80 | ((size >> 14) & 0x7f)), UInt8(0x80 | ((size >> 7) & 0x7f)), UInt8(size & 0x7f)]) result.append(payload) return result } var decoderConfig = Data([0x20, 0x11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) decoderConfig.append(descriptor(0x05, extradata)) var esDescriptor = Data([0, 0, 0]) esDescriptor.append(descriptor(0x04, decoderConfig)) esDescriptor.append(descriptor(0x06, Data([0x02]))) var atom = Data([0, 0, 0, 0]) atom.append(descriptor(0x03, esDescriptor)) return atom }
In createFormatDescriptionFromMpeg4CodecData: - atoms.setObject(extradata as NSData, forKey: "esds" as NSString) + atoms.setObject(mpeg4EsdsAtom(extradata) as NSData, forKey: "esds" as NSString)
I added FFMpegVideoDecodingTests to //submodules/MediaPlayer:UniversalMediaPlayerTests. It reads 12-frame 192x108 fixtures through FFMpegFileReader. On master: h264_8bit with hw=true where VideoToolbox can't initialise gives 0 frames; h264_high10, hevc_main10, hevc_main12 and av1_10bit in software give 0 frames each; hevc_main12 with hw=true gives 0; 8->10-bit gives 4 frames and stops at 0.1 s; for mpeg4_part2, VTDecompressionSessionCreate returns -12909. With the fix all of them decode. I'm happy to share the full diff, test file and fixtures if useful.
Not covered: - VP9 profile 2 (10/12-bit): libvpx is built with --disable-vp9-highbitdepth (third-party/libvpx/build-libvpx-bazel.sh), so it rejects these streams before decoding. - RGB (gbrp) and monochrome (gray) frames. - AV1 when the VideoToolbox AV1 decoder rejects a stream (e.g. 4:4:4): the native av1 decoder has no software fallback, so dav1d would be needed. - MPEG-4 Part 2 in AVI.
Yes. The affected videos play normally after saving them to the Photos app, and only Telegram's in-app player shows the black or frozen picture. That matches the root cause: VideoToolbox can decode these files, but Telegram's FFmpeg-based player path drops the decoded frames (details and fix in the comments above).
Each post is one sample file, captioned with what it is and what happens: - h264_8bit.mp4: control, plays fine - h264_high10.mp4: H.264 High 10, black - hevc_main12.mp4: HEVC Main 12, black - av1_10bit.mp4: AV1 10-bit, black on devices without AV1 hardware (iPhone before 15 Pro, M1/M2 Macs) - mpeg4_part2.mp4 and resolution.mp4 (real-world file): MPEG-4 Part 2, black - h264_8bit_then_10bit.mp4: freezes when the stream switches to 10-bit
The two MPEG-4 files were sent as files, because the web client could not preview them.
FFmpeg requires get_format to return a format from the list it offers. When VideoToolbox is not offered (HEVC 12-bit, H.264 9/12-bit) or its init failed, returning the software format lets the compiled-in h264/hevc software decoder take over.
#import "libavutil/pixdesc.h"
static enum AVPixelFormat getPreferredPixelFormat(__unused AVCodecContext *ctx, const enum AVPixelFormat *pix_fmts) {
enum AVPixelFormat fallback = AV_PIX_FMT_NONE;
for (const enum AVPixelFormat *p = pix_fmts; *p != AV_PIX_FMT_NONE; p++) {
if (*p == AV_PIX_FMT_VIDEOTOOLBOX) {
return AV_PIX_FMT_VIDEOTOOLBOX;
}
const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(*p);
if (desc && !(desc->flags & AV_PIX_FMT_FLAG_HWACCEL)) {
fallback = *p;
}
}
return fallback;
}
- (void)setupHardwareAccelerationIfPossible {
if (av_hwdevice_ctx_create(&_impl->hw_device_ctx, AV_HWDEVICE_TYPE_VIDEOTOOLBOX, nil, nil, 0) < 0) {
return;
}
_impl->get_format = getPreferredPixelFormat;
}
static enum AVPixelFormat eightBitFormatForHighBitDepthFormat(enum AVPixelFormat format) {
const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(format);
if (!desc || (desc->flags & (AV_PIX_FMT_FLAG_HWACCEL | AV_PIX_FMT_FLAG_RGB | AV_PIX_FMT_FLAG_BE | AV_PIX_FMT_FLAG_BITSTREAM | AV_PIX_FMT_FLAG_PAL))) return AV_PIX_FMT_NONE;
if (!(desc->flags & AV_PIX_FMT_FLAG_PLANAR) || desc->nb_components < 3) return AV_PIX_FMT_NONE;
for (int i = 0; i < desc->nb_components; i++) {
if (desc->comp[i].plane != i || desc->comp[i].step != 2 || desc->comp[i].shift != 0 || desc->comp[i].depth <= 8 || desc->comp[i].depth > 16) return AV_PIX_FMT_NONE;
}
bool a = (desc->flags & AV_PIX_FMT_FLAG_ALPHA) != 0;
if (desc->log2_chroma_w == 1 && desc->log2_chroma_h == 1) return a ? AV_PIX_FMT_YUVA420P : AV_PIX_FMT_YUV420P;
if (desc->log2_chroma_w == 1 && desc->log2_chroma_h == 0) return a ? AV_PIX_FMT_YUVA422P : AV_PIX_FMT_YUV422P;
if (desc->log2_chroma_w == 0 && desc->log2_chroma_h == 0) return a ? AV_PIX_FMT_YUVA444P : AV_PIX_FMT_YUV444P;
return AV_PIX_FMT_NONE;
}
New FFMpegAVFrame API:
- isHighBitDepthPlanarYUV: eightBitFormatForHighBitDepthFormat(format) != NONE
- initWithEightBitLayoutOfFrame: allocates a frame of that 8-bit format and size (av_frame_get_buffer)
- reduceBitDepthIntoFrame: for each plane (chroma sized with AV_CEIL_RSHIFT by log2_chroma_w/h), shift = depth - 8, dst = min(255, (src + (1 << (shift - 1))) >> shift); then copies color_range.
private var reducedBitDepthFrame: FFMpegAVFrame?
private func eightBitFrame(_ frame: FFMpegAVFrame) -> FFMpegAVFrame? {
guard frame.isHighBitDepthPlanarYUV else {
return frame
}
if let existing = self.reducedBitDepthFrame, frame.reduceBitDepth(into: existing) {
return existing
}
guard let created = FFMpegAVFrame(eightBitLayoutOf: frame), frame.reduceBitDepth(into: created) else {
return nil
}
self.reducedBitDepthFrame = created
return created
}
At both call sites (convertVideoFrameToImage and convertVideoFrame):
- guard let frame = self.planarFrameFor420Conversion(frame) else {
+ guard let frame = self.eightBitFrame(frame).flatMap(self.planarFrameFor420Conversion) else {
The existing 4:2:2/4:4:4 -> 4:2:0 fold then handles the 8-bit frame. There is no tone mapping, so HDR10 shows with SDR contrast instead of black. Converting a 4K frame takes about 4 ms at -Os.
static func mpeg4EsdsAtom(_ extradata: Data) -> Data {
if extradata.count > 5 && extradata.prefix(4) == Data([0, 0, 0, 0]) && extradata[extradata.startIndex + 4] == 0x03 {
return extradata
}
func descriptor(_ tag: UInt8, _ payload: Data) -> Data {
let size = payload.count
var result = Data([tag])
result.append(contentsOf: [UInt8(0x80 | ((size >> 21) & 0x7f)), UInt8(0x80 | ((size >> 14) & 0x7f)), UInt8(0x80 | ((size >> 7) & 0x7f)), UInt8(size & 0x7f)])
result.append(payload)
return result
}
var decoderConfig = Data([0x20, 0x11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
decoderConfig.append(descriptor(0x05, extradata))
var esDescriptor = Data([0, 0, 0])
esDescriptor.append(descriptor(0x04, decoderConfig))
esDescriptor.append(descriptor(0x06, Data([0x02])))
var atom = Data([0, 0, 0, 0])
atom.append(descriptor(0x03, esDescriptor))
return atom
}
In createFormatDescriptionFromMpeg4CodecData:
- atoms.setObject(extradata as NSData, forKey: "esds" as NSString)
+ atoms.setObject(mpeg4EsdsAtom(extradata) as NSData, forKey: "esds" as NSString)
I added FFMpegVideoDecodingTests to //submodules/MediaPlayer:UniversalMediaPlayerTests. It reads 12-frame 192x108 fixtures through FFMpegFileReader. On master: h264_8bit with hw=true where VideoToolbox can't initialise gives 0 frames; h264_high10, hevc_main10, hevc_main12 and av1_10bit in software give 0 frames each; hevc_main12 with hw=true gives 0; 8->10-bit gives 4 frames and stops at 0.1 s; for mpeg4_part2, VTDecompressionSessionCreate returns -12909. With the fix all of them decode. I'm happy to share the full diff, test file and fixtures if useful.
Not covered:
- VP9 profile 2 (10/12-bit): libvpx is built with --disable-vp9-highbitdepth (third-party/libvpx/build-libvpx-bazel.sh), so it rejects these streams before decoding.
- RGB (gbrp) and monochrome (gray) frames.
- AV1 when the VideoToolbox AV1 decoder rejects a stream (e.g. 4:4:4): the native av1 decoder has no software fallback, so dav1d would be needed.
- MPEG-4 Part 2 in AVI.
Each post is one sample file, captioned with what it is and what happens:
- h264_8bit.mp4: control, plays fine
- h264_high10.mp4: H.264 High 10, black
- hevc_main12.mp4: HEVC Main 12, black
- av1_10bit.mp4: AV1 10-bit, black on devices without AV1 hardware (iPhone before 15 Pro, M1/M2 Macs)
- mpeg4_part2.mp4 and resolution.mp4 (real-world file): MPEG-4 Part 2, black
- h264_8bit_then_10bit.mp4: freezes when the stream switches to 10-bit
The two MPEG-4 files were sent as files, because the web client could not preview them.