Skip to content

Commit d8dc923

Browse files
committed
Fix stretched macOS video by syncing aspect ratio to live frames
Derive the display aspect ratio from AVPlayerItem.presentationSize (pixel aspect ratio and clean aperture applied) and re-sync it per published frame. The dimensions known at open time can be wrong or stale — HLS reports a default/early-variant size and anamorphic content has non-square pixels — so stretching the full bitmap into a Canvas sized from those dimensions distorted the image. presentationSize is cached from a KVO observer so it can be read off the main thread.
1 parent 64c5c8d commit d8dc923

4 files changed

Lines changed: 107 additions & 5 deletions

File tree

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacNativeBridge.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ internal object MacNativeBridge {
6565

6666
@JvmStatic external fun nGetFrameHeight(handle: Long): Int
6767

68+
@JvmStatic external fun nGetDisplayAspectRatio(handle: Long): Double
69+
6870
@JvmStatic external fun nSetOutputSize(
6971
handle: Long,
7072
width: Int,

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,45 @@ class MacVideoPlayerState : VideoPlayerState {
447447
macLogger.e { "Unable to retrieve valid dimensions after $maxAttempts attempts" }
448448
}
449449

450+
/**
451+
* Returns the aspect ratio the video should be displayed at.
452+
*
453+
* Prefers the display aspect ratio reported by AVFoundation ([MacNativeBridge.nGetDisplayAspectRatio],
454+
* i.e. `presentationSize`), which has the pixel aspect ratio and clean aperture applied. Anamorphic /
455+
* non-square-pixel videos have a display aspect ratio that differs from the raw decoded pixel-buffer
456+
* dimensions; drawing the raw bitmap into a Canvas sized with this display ratio rescales the pixels
457+
* back to their intended geometry. Falls back to the raw frame ratio when unavailable.
458+
*/
459+
private fun displayAspectRatio(width: Int, height: Int): Float {
460+
val ptr = playerPtr
461+
val displayAspect = if (ptr != 0L) MacNativeBridge.nGetDisplayAspectRatio(ptr) else 0.0
462+
return if (displayAspect > 0.0) {
463+
displayAspect.toFloat()
464+
} else {
465+
width.toFloat() / height.toFloat()
466+
}
467+
}
468+
469+
/**
470+
* Aligns the displayed aspect ratio (and reported dimensions) with the live frame.
471+
*
472+
* Called on every published frame. Both [_aspectRatio] and the [metadata] width/height are
473+
* snapshot-backed state, so they can be written safely from the frame-decoding thread, and the
474+
* guards keep it a no-op unless a value actually changed. This is what prevents the video from
475+
* being stretched when the dimensions known at open time (HLS reports a default/early-variant
476+
* size, anamorphic content has non-square pixels) don't match the frames being rendered.
477+
*/
478+
private fun syncAspectRatioToFrame(width: Int, height: Int) {
479+
if (width <= 0 || height <= 0) return
480+
481+
val frameAspect = displayAspectRatio(width, height)
482+
if (kotlin.math.abs(frameAspect - _aspectRatio.value) > 0.001f) {
483+
_aspectRatio.value = frameAspect
484+
}
485+
if (metadata.width != width) metadata.width = width
486+
if (metadata.height != height) metadata.height = height
487+
}
488+
450489
/** Updates the metadata from the native player. */
451490
private suspend fun updateMetadata() {
452491
macLogger.d { "updateMetadata()" }
@@ -459,13 +498,12 @@ class MacVideoPlayerState : VideoPlayerState {
459498
val duration = (MacNativeBridge.nGetVideoDuration(ptr) * 1000).toLong()
460499
val frameRate = MacNativeBridge.nGetVideoFrameRate(ptr)
461500

462-
// Calculate aspect ratio
501+
// Initial aspect ratio estimate; refined per-frame by syncAspectRatioToFrame().
502+
// Keep the previous value if the video isn't ready yet rather than forcing 16:9.
463503
val newAspectRatio =
464504
if (width > 0 && height > 0) {
465-
width.toFloat() / height.toFloat()
505+
displayAspectRatio(width, height)
466506
} else {
467-
// Instead of forcing 16f/9f, don’t change the aspect if the video is not ready yet.
468-
// For example, we can keep the previous aspect ratio:
469507
_aspectRatio.value
470508
}
471509

@@ -488,7 +526,6 @@ class MacVideoPlayerState : VideoPlayerState {
488526
metadata.audioChannels = if (audioChannels == 0) null else audioChannels
489527
metadata.audioSampleRate = if (audioSampleRate == 0) null else audioSampleRate
490528

491-
// Update the aspect ratio only if width/height are valid
492529
_aspectRatio.value = newAspectRatio
493530
}
494531

@@ -621,6 +658,15 @@ class MacVideoPlayerState : VideoPlayerState {
621658

622659
_currentFrameState.value = targetBitmap.asComposeImageBitmap()
623660
framePublished = true
661+
662+
// Keep the displayed aspect ratio in sync with the frame we actually draw.
663+
// The dimensions captured by updateMetadata() at open time can be wrong or
664+
// stale (HLS reports a default/early-variant size before the real resolution is
665+
// decoded; anamorphic content has non-square pixels). Since the Canvas is sized
666+
// with aspectRatio and the full bitmap is stretched into it, a mismatch distorts
667+
// the image (e.g. vertical stretch). Re-derive it from the live frame. Cheap:
668+
// only writes snapshot state when the value actually changes.
669+
syncAspectRatioToFrame(width, height)
624670
}
625671
} finally {
626672
MacNativeBridge.nUnlockFrame(ptr)

mediaplayer/src/jvmMain/native/macos/NativeVideoPlayer.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ class MacVideoPlayer {
7171
private var bufferEmptyObserver: NSKeyValueObservation?
7272
private var bufferLikelyToKeepUpObserver: NSKeyValueObservation?
7373
private var bufferFullObserver: NSKeyValueObservation?
74+
private var presentationSizeObserver: NSKeyValueObservation?
75+
76+
// Display aspect ratio (width / height) derived from AVPlayerItem.presentationSize.
77+
// Cached here and updated from the KVO callback so getDisplayAspectRatio() can be called
78+
// from the frame-decoding thread without touching the live AVPlayerItem off the main thread.
79+
private let aspectLock = NSLock()
80+
private var cachedDisplayAspectRatio: Double = 0.0
7481

7582
// End-of-playback flag (set by AVPlayerItemDidPlayToEndTime, consumed once by the Kotlin side)
7683
private var didPlayToEnd: Bool = false
@@ -886,6 +893,13 @@ class MacVideoPlayer {
886893
self?.handleTimeControlStatus(player.timeControlStatus)
887894
}
888895

896+
// Cache the display aspect ratio whenever presentationSize changes (e.g. HLS variant
897+
// switches). Reading presentationSize here keeps the live AVPlayerItem access on the
898+
// observation thread instead of the frame-decoding thread. .initial seeds the value now.
899+
presentationSizeObserver = item.observe(\.presentationSize, options: [.initial, .new]) { [weak self] item, _ in
900+
self?.updateCachedDisplayAspectRatio(from: item.presentationSize)
901+
}
902+
889903
// Observe end of playback for all media types
890904
playbackEndObserver = NotificationCenter.default.addObserver(
891905
forName: .AVPlayerItemDidPlayToEndTime,
@@ -1166,6 +1180,29 @@ class MacVideoPlayer {
11661180
/// Returns the height of the video frame in pixels
11671181
func getFrameHeight() -> Int { return frameHeight }
11681182

1183+
/// Recomputes the cached display aspect ratio from a presentationSize. Called on the KVO
1184+
/// observation thread; the value is read elsewhere from the frame-decoding thread.
1185+
private func updateCachedDisplayAspectRatio(from size: CGSize) {
1186+
let ratio = (size.width > 0 && size.height > 0) ? Double(size.width) / Double(size.height) : 0.0
1187+
aspectLock.lock()
1188+
cachedDisplayAspectRatio = ratio
1189+
aspectLock.unlock()
1190+
}
1191+
1192+
/// Returns the correct display aspect ratio (width / height) of the current video.
1193+
///
1194+
/// Derived from `AVPlayerItem.presentationSize`, which AVFoundation computes with the pixel
1195+
/// aspect ratio and clean aperture already applied. This is the geometry the video should be
1196+
/// drawn at — it can differ from the raw decoded pixel-buffer dimensions for anamorphic /
1197+
/// non-square pixel content, which would otherwise be stretched. The value is cached from a
1198+
/// KVO observer (see setup) so this is safe to call off the main thread; returns 0 when not
1199+
/// yet available so the caller can fall back to the raw frame dimensions.
1200+
func getDisplayAspectRatio() -> Double {
1201+
aspectLock.lock()
1202+
defer { aspectLock.unlock() }
1203+
return cachedDisplayAspectRatio
1204+
}
1205+
11691206
/// Scales the output to fit within (width, height) while preserving the native aspect ratio.
11701207
/// Never upscales beyond the native resolution. Recreates the pixel buffer output at the new size.
11711208
/// Returns true if dimensions actually changed.
@@ -1310,6 +1347,10 @@ class MacVideoPlayer {
13101347
bufferEmptyObserver?.invalidate()
13111348
bufferLikelyToKeepUpObserver?.invalidate()
13121349
bufferFullObserver?.invalidate()
1350+
presentationSizeObserver?.invalidate()
1351+
presentationSizeObserver = nil
1352+
// Drop the cached aspect ratio so a reopened player can't briefly show the old video's ratio.
1353+
updateCachedDisplayAspectRatio(from: .zero)
13131354

13141355
if let observer = playbackEndObserver {
13151356
NotificationCenter.default.removeObserver(observer)
@@ -1420,6 +1461,13 @@ public func getFrameHeight(_ context: UnsafeMutableRawPointer?) -> Int32 {
14201461
return Int32(player.getFrameHeight())
14211462
}
14221463

1464+
@_cdecl("getDisplayAspectRatio")
1465+
public func getDisplayAspectRatio(_ context: UnsafeMutableRawPointer?) -> Double {
1466+
guard let context = context else { return 0.0 }
1467+
let player = Unmanaged<MacVideoPlayer>.fromOpaque(context).takeUnretainedValue()
1468+
return player.getDisplayAspectRatio()
1469+
}
1470+
14231471
@_cdecl("setOutputSize")
14241472
public func setOutputSize(_ context: UnsafeMutableRawPointer?, _ width: Int32, _ height: Int32) -> Int32 {
14251473
guard let context = context else { return 0 }

mediaplayer/src/jvmMain/native/macos/jni_bridge.c

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ extern void* lockLatestFrame(void* ctx, int32_t* outInfo);
1919
extern void unlockLatestFrame(void* ctx);
2020
extern int32_t getFrameWidth(void* ctx);
2121
extern int32_t getFrameHeight(void* ctx);
22+
extern double getDisplayAspectRatio(void* ctx);
2223
extern int32_t setOutputSize(void* ctx, int32_t width, int32_t height);
2324
extern float getVideoFrameRate(void* ctx);
2425
extern float getScreenRefreshRate(void* ctx);
@@ -106,6 +107,10 @@ static jint JNICALL jni_GetFrameHeight(JNIEnv* env, jclass cls, jlong handle) {
106107
return handle ? (jint)getFrameHeight(toCtx(handle)) : 0;
107108
}
108109

110+
static jdouble JNICALL jni_GetDisplayAspectRatio(JNIEnv* env, jclass cls, jlong handle) {
111+
return handle ? (jdouble)getDisplayAspectRatio(toCtx(handle)) : 0.0;
112+
}
113+
109114
static jint JNICALL jni_SetOutputSize(JNIEnv* env, jclass cls, jlong handle, jint width, jint height) {
110115
return handle ? (jint)setOutputSize(toCtx(handle), (int32_t)width, (int32_t)height) : 0;
111116
}
@@ -196,6 +201,7 @@ static const JNINativeMethod g_methods[] = {
196201
{ "nWrapPointer", "(JJ)Ljava/nio/ByteBuffer;", (void*)jni_WrapPointer },
197202
{ "nGetFrameWidth", "(J)I", (void*)jni_GetFrameWidth },
198203
{ "nGetFrameHeight", "(J)I", (void*)jni_GetFrameHeight },
204+
{ "nGetDisplayAspectRatio", "(J)D", (void*)jni_GetDisplayAspectRatio },
199205
{ "nSetOutputSize", "(JII)I", (void*)jni_SetOutputSize },
200206
{ "nGetVideoFrameRate", "(J)F", (void*)jni_GetVideoFrameRate },
201207
{ "nGetScreenRefreshRate", "(J)F", (void*)jni_GetScreenRefreshRate },

0 commit comments

Comments
 (0)