feat(clients): codec preference on Windows/Apple/Android clients (Phase 2b)
apple / swift (push) Failing after 52s
windows-drivers / probe-and-proto (push) Successful in 50s
windows-drivers / driver-build (push) Successful in 1m20s
android / android (push) Failing after 2m55s
ci / web (push) Successful in 1m5s
release / apple (push) Successful in 3m38s
apple / screenshots (push) Has been skipped
ci / rust (push) Successful in 4m47s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 2m49s
decky / build-publish (push) Successful in 21s
windows-host / package (push) Successful in 7m35s
ci / bench (push) Successful in 5m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
flatpak / build-publish (push) Successful in 4m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m58s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m45s
apple / swift (push) Failing after 52s
windows-drivers / probe-and-proto (push) Successful in 50s
windows-drivers / driver-build (push) Successful in 1m20s
android / android (push) Failing after 2m55s
ci / web (push) Successful in 1m5s
release / apple (push) Successful in 3m38s
apple / screenshots (push) Has been skipped
ci / rust (push) Successful in 4m47s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 2m49s
decky / build-publish (push) Successful in 21s
windows-host / package (push) Successful in 7m35s
ci / bench (push) Successful in 5m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
flatpak / build-publish (push) Successful in 4m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m58s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m45s
Rounds out codec negotiation across the last three clients — each advertises what it can decode, builds its decoder from the resolved Welcome.codec, and exposes a "Video codec" preference picker. **Windows** (Rust, mirrors Linux): `decodable_codecs()` + `ffmpeg_codec_id()`; the D3D11VA and software FFmpeg decoders (and the mid-session D3D11VA→software demotion) open the negotiated codec instead of hardcoding HEVC; settings gain a `codec` field + reactor ComboBox; `--codec` CLI flag. **Apple** (Swift/C-ABI): AnnexB is now codec-aware — a `VideoCodec` enum drives H.264 vs HEVC NAL parsing / parameter-set extraction (`CMVideoFormatDescriptionCreateFromH264ParameterSets` for H.264, no VPS) and AVCC repacking; `PunktfunkConnection` advertises H264|HEVC via `punktfunk_connect_ex7`, reads `resolvedCodec` (`punktfunk_connection_codec`), and threads `videoCodec` into the stage-1/2 pipelines + `VideoDecoder`; SettingsView "Video codec" Picker (auto/HEVC/H.264). AV1 is left out (hosts don't emit it on the native path, and it's not an AnnexB codec). Test call sites updated. **Android** (Kotlin + Rust JNI): the JNI `nativeConnect` gains `preferredCodec`; the native decode loop picks the AMediaCodec MIME (`video/hevc`|`video/avc`) from `connector.codec` and advertises H264|HEVC; Settings `codec` field + Compose dropdown. Core/host/probe/Linux clippy + tests green (unchanged from 2a). Windows/Apple/Android compile on their platform CI (this Linux box can't build them — Windows toolchain / Xcode / the Android NDK's opus-cmake toolchain). All follow the Linux client's validated pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -187,7 +187,8 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
targetHost, targetPort, w, h, hz,
|
targetHost, targetPort, w, h, hz,
|
||||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||||
hdrEnabled, settings.audioChannels, CONNECT_TIMEOUT_MS,
|
hdrEnabled, settings.audioChannels, settings.preferredCodec(),
|
||||||
|
CONNECT_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
connecting = false
|
connecting = false
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ data class Settings(
|
|||||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||||
val audioChannels: Int = 2,
|
val audioChannels: Int = 2,
|
||||||
|
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference — the
|
||||||
|
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
||||||
|
val codec: String = "auto",
|
||||||
val micEnabled: Boolean = false,
|
val micEnabled: Boolean = false,
|
||||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||||
val statsHudEnabled: Boolean = true,
|
val statsHudEnabled: Boolean = true,
|
||||||
@@ -51,6 +54,7 @@ class SettingsStore(context: Context) {
|
|||||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||||
|
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||||
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
trackpadMode = prefs.getBoolean(K_TRACKPAD, true),
|
||||||
@@ -66,6 +70,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putInt(K_COMPOSITOR, s.compositor)
|
.putInt(K_COMPOSITOR, s.compositor)
|
||||||
.putInt(K_GAMEPAD, s.gamepad)
|
.putInt(K_GAMEPAD, s.gamepad)
|
||||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||||
|
.putString(K_CODEC, s.codec)
|
||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||||
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
.putBoolean(K_TRACKPAD, s.trackpadMode)
|
||||||
@@ -81,6 +86,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_COMPOSITOR = "compositor"
|
const val K_COMPOSITOR = "compositor"
|
||||||
const val K_GAMEPAD = "gamepad"
|
const val K_GAMEPAD = "gamepad"
|
||||||
const val K_AUDIO_CH = "audio_channels"
|
const val K_AUDIO_CH = "audio_channels"
|
||||||
|
const val K_CODEC = "codec"
|
||||||
const val K_MIC = "mic_enabled"
|
const val K_MIC = "mic_enabled"
|
||||||
const val K_HUD = "stats_hud_enabled"
|
const val K_HUD = "stats_hud_enabled"
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
@@ -156,6 +162,21 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
|
|||||||
8 to "7.1 Surround",
|
8 to "7.1 Surround",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. */
|
||||||
|
val CODEC_OPTIONS = listOf(
|
||||||
|
"auto" to "Automatic",
|
||||||
|
"hevc" to "HEVC (H.265)",
|
||||||
|
"h264" to "H.264 (AVC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2. */
|
||||||
|
fun Settings.preferredCodec(): Int = when (codec) {
|
||||||
|
"h264" -> 1
|
||||||
|
"hevc" -> 2
|
||||||
|
"av1" -> 4
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
/** (kbps, label). `0` = host default. */
|
/** (kbps, label). `0` = host default. */
|
||||||
val BITRATE_OPTIONS = listOf(
|
val BITRATE_OPTIONS = listOf(
|
||||||
0 to "Automatic",
|
0 to "Automatic",
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
|||||||
selected = s.bitrateKbps,
|
selected = s.bitrateKbps,
|
||||||
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
|
||||||
|
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Video codec",
|
||||||
|
options = CODEC_OPTIONS,
|
||||||
|
selected = s.codec,
|
||||||
|
) { c -> update(s.copy(codec = c)) }
|
||||||
|
|
||||||
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
|
||||||
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
|
||||||
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ object NativeBridge {
|
|||||||
gamepadPref: Int,
|
gamepadPref: Int,
|
||||||
hdrEnabled: Boolean,
|
hdrEnabled: Boolean,
|
||||||
audioChannels: Int,
|
audioChannels: Int,
|
||||||
|
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
||||||
|
preferredCodec: Int,
|
||||||
timeoutMs: Int,
|
timeoutMs: Int,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,23 @@ pub fn run(
|
|||||||
) {
|
) {
|
||||||
boost_thread_priority();
|
boost_thread_priority();
|
||||||
let mode = client.mode();
|
let mode = client.mode();
|
||||||
let codec = match MediaCodec::from_decoder_type("video/hevc") {
|
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`): HEVC or H.264. AMediaCodec
|
||||||
|
// needs no out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
|
||||||
|
let mime = match client.codec {
|
||||||
|
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
||||||
|
_ => "video/hevc",
|
||||||
|
};
|
||||||
|
let codec = match MediaCodec::from_decoder_type(mime) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
log::error!("decode: no HEVC decoder on this device");
|
log::error!("decode: no {mime} decoder on this device");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
log::info!("decode: codec mime = {mime}");
|
||||||
|
|
||||||
let mut format = MediaFormat::new();
|
let mut format = MediaFormat::new();
|
||||||
format.set_str("mime", "video/hevc");
|
format.set_str("mime", mime);
|
||||||
format.set_i32("width", mode.width as i32);
|
format.set_i32("width", mode.width as i32);
|
||||||
format.set_i32("height", mode.height as i32);
|
format.set_i32("height", mode.height as i32);
|
||||||
// Generous input buffer so a large keyframe AU is never truncated.
|
// Generous input buffer so a large keyframe AU is never truncated.
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
gamepad_pref: jint,
|
gamepad_pref: jint,
|
||||||
hdr_enabled: jboolean,
|
hdr_enabled: jboolean,
|
||||||
audio_channels: jint,
|
audio_channels: jint,
|
||||||
|
preferred_codec: jint,
|
||||||
timeout_ms: jint,
|
timeout_ms: jint,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
@@ -224,6 +225,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
|
||||||
// normalizes to stereo here.
|
// normalizes to stereo here.
|
||||||
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
|
||||||
|
// Codecs this device can decode — AMediaCodec decodes both HEVC and H.264 (AV1 isn't wired;
|
||||||
|
// hosts don't emit it on the native path yet). The host resolves the emitted codec from these
|
||||||
|
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
|
||||||
|
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
||||||
|
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
||||||
None, // launch: default app
|
None, // launch: default app
|
||||||
pin, // Some → Crypto on host-fp mismatch
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
|
|||||||
@@ -28,10 +28,20 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||||
|
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
|
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
||||||
|
private var preferredCodecByte: UInt8 {
|
||||||
|
switch codec {
|
||||||
|
case "h264": return PunktfunkConnection.codecH264
|
||||||
|
case "hevc": return PunktfunkConnection.codecHEVC
|
||||||
|
case "av1": return PunktfunkConnection.codecAV1
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||||
@@ -378,6 +388,7 @@ struct ContentView: View {
|
|||||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||||
audioChannels: UInt8(clamping: audioChannels),
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
hdrEnabled: hdrEnabled,
|
hdrEnabled: hdrEnabled,
|
||||||
|
preferredCodec: preferredCodecByte,
|
||||||
launchID: launchID,
|
launchID: launchID,
|
||||||
allowTofu: allowTofu,
|
allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess)
|
requestAccess: requestAccess)
|
||||||
@@ -521,6 +532,7 @@ struct ContentView: View {
|
|||||||
bitrateKbps: bitrate,
|
bitrateKbps: bitrate,
|
||||||
audioChannels: UInt8(clamping: audioChannels),
|
audioChannels: UInt8(clamping: audioChannels),
|
||||||
hdrEnabled: hdrEnabled,
|
hdrEnabled: hdrEnabled,
|
||||||
|
preferredCodec: preferredCodecByte,
|
||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ final class SessionModel: ObservableObject {
|
|||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
audioChannels: UInt8 = 2,
|
audioChannels: UInt8 = 2,
|
||||||
hdrEnabled: Bool = true,
|
hdrEnabled: Bool = true,
|
||||||
|
preferredCodec: UInt8 = 0,
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false,
|
autoTrust: Bool = false,
|
||||||
@@ -155,12 +156,17 @@ final class SessionModel: ObservableObject {
|
|||||||
if want444, canDecode444 {
|
if want444, canDecode444 {
|
||||||
videoCaps |= PunktfunkConnection.videoCap444
|
videoCaps |= PunktfunkConnection.videoCap444
|
||||||
}
|
}
|
||||||
|
// This client's VideoToolbox path decodes H.264 and HEVC (AV1 isn't wired — hosts don't
|
||||||
|
// emit it on the native path yet). The host resolves the emitted codec from these + the
|
||||||
|
// soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||||
|
let videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||||
audioChannels: audioChannels, launchID: launchID,
|
audioChannels: audioChannels,
|
||||||
|
videoCodecs: videoCodecs, preferredCodec: preferredCodec, launchID: launchID,
|
||||||
// Delegated approval: the host holds this connect open until the operator approves
|
// Delegated approval: the host holds this connect open until the operator approves
|
||||||
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||||
// connects keep the snappy default.
|
// connects keep the snappy default.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||||
|
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
@@ -671,16 +672,22 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
@ViewBuilder private var hdrSection: some View {
|
@ViewBuilder private var hdrSection: some View {
|
||||||
Section {
|
Section {
|
||||||
|
Picker("Video codec", selection: $codec) {
|
||||||
|
Text("Automatic").tag("auto")
|
||||||
|
Text("HEVC (H.265)").tag("hevc")
|
||||||
|
Text("H.264 (AVC)").tag("h264")
|
||||||
|
}
|
||||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Video quality")
|
Text("Video quality")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("HDR requests a 10-bit BT.2020 PQ (HDR10) stream — it only engages when the host is "
|
Text("Codec is a preference — the host falls back if it can't encode the one you pick "
|
||||||
+ "sending HDR content AND this display supports HDR. 4:4:4 requests full chroma "
|
+ "(and 10-bit/4:4:4 are HEVC-only). HDR requests a 10-bit BT.2020 PQ (HDR10) stream — "
|
||||||
+ "(sharper text/UI, more bandwidth) — it only engages when this device can "
|
+ "it only engages when the host is sending HDR content AND this display supports HDR. "
|
||||||
+ "hardware-decode it AND the host opted in. Otherwise the stream stays 8-bit "
|
+ "4:4:4 requests full chroma (sharper text/UI, more bandwidth) — it only engages when "
|
||||||
+ "4:2:0 SDR. Applies from the next session.")
|
+ "this device can hardware-decode it AND the host opted in. Otherwise the stream stays "
|
||||||
|
+ "8-bit 4:2:0 SDR. Applies from the next session.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,20 @@
|
|||||||
import CoreMedia
|
import CoreMedia
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// The video codec of the host's elementary stream — negotiated in the Welcome and read via
|
||||||
|
/// `punktfunk_connection_codec`. Both are Annex-B with in-band parameter sets on every IDR; they
|
||||||
|
/// differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1 is not an
|
||||||
|
/// Annex-B/NAL codec and isn't handled here (hosts don't emit it on the native path yet).
|
||||||
|
public enum VideoCodec: Equatable {
|
||||||
|
case h264
|
||||||
|
case hevc
|
||||||
|
|
||||||
|
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
||||||
|
public init(wire: UInt8) {
|
||||||
|
self = wire == 0x01 ? .h264 : .hevc // 0x01 = PUNKTFUNK_CODEC_H264
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum AnnexB {
|
public enum AnnexB {
|
||||||
/// Split an Annex-B stream into NAL units (start codes 00 00 01 / 00 00 00 01 stripped).
|
/// Split an Annex-B stream into NAL units (start codes 00 00 01 / 00 00 00 01 stripped).
|
||||||
/// All zeros immediately preceding a start code are dropped: they're either the
|
/// All zeros immediately preceding a start code are dropped: they're either the
|
||||||
@@ -47,40 +61,83 @@ public enum AnnexB {
|
|||||||
return (first >> 1) & 0x3F
|
return (first >> 1) & 0x3F
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a format description from an IDR AU's in-band VPS(32)/SPS(33)/PPS(34).
|
/// H.264 NAL unit type (bits 0..4 of the first byte).
|
||||||
/// Returns nil when the AU carries no parameter sets (non-IDR).
|
public static func h264NalType(_ nal: Data) -> UInt8 {
|
||||||
public static func formatDescription(fromIDR au: Data) -> CMVideoFormatDescription? {
|
guard let first = nal.first else { return 0xFF }
|
||||||
|
return first & 0x1F
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this NAL is a parameter set for `codec` (dropped from AVCC; kept for the format desc).
|
||||||
|
/// HEVC: VPS 32 / SPS 33 / PPS 34. H.264: SPS 7 / PPS 8 (no VPS).
|
||||||
|
private static func isParameterSet(_ nal: Data, _ codec: VideoCodec) -> Bool {
|
||||||
|
switch codec {
|
||||||
|
case .hevc: let t = hevcNalType(nal); return t == 32 || t == 33 || t == 34
|
||||||
|
case .h264: let t = h264NalType(nal); return t == 7 || t == 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a format description from an IDR AU's in-band parameter sets (HEVC: VPS/SPS/PPS;
|
||||||
|
/// H.264: SPS/PPS). Returns nil when the AU carries no parameter sets (non-IDR).
|
||||||
|
public static func formatDescription(fromIDR au: Data, codec: VideoCodec)
|
||||||
|
-> CMVideoFormatDescription?
|
||||||
|
{
|
||||||
|
// Collect the parameter-set NALs in the order VideoToolbox wants them (HEVC: VPS,SPS,PPS;
|
||||||
|
// H.264: SPS,PPS).
|
||||||
var vps: Data?, sps: Data?, pps: Data?
|
var vps: Data?, sps: Data?, pps: Data?
|
||||||
for nal in nalUnits(in: au) {
|
for nal in nalUnits(in: au) {
|
||||||
switch hevcNalType(nal) {
|
switch codec {
|
||||||
case 32: vps = nal
|
case .hevc:
|
||||||
case 33: sps = nal
|
switch hevcNalType(nal) {
|
||||||
case 34: pps = nal
|
case 32: vps = nal
|
||||||
default: break
|
case 33: sps = nal
|
||||||
|
case 34: pps = nal
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
case .h264:
|
||||||
|
switch h264NalType(nal) {
|
||||||
|
case 7: sps = nal
|
||||||
|
case 8: pps = nal
|
||||||
|
default: break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard let vps, let sps, let pps else { return nil }
|
guard let sps, let pps else { return nil }
|
||||||
|
let sets: [Data] = codec == .hevc ? [vps, sps, pps].compactMap { $0 } : [sps, pps]
|
||||||
|
guard codec == .h264 || sets.count == 3 else { return nil } // HEVC needs the VPS too
|
||||||
|
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
let sets = [vps, sps, pps]
|
// Pin every parameter set's bytes for the duration of the create call, then hand
|
||||||
let status: OSStatus = sets[0].withUnsafeBytes { v in
|
// VideoToolbox parallel pointer/size arrays.
|
||||||
sets[1].withUnsafeBytes { s in
|
var pointers: [UnsafePointer<UInt8>] = []
|
||||||
sets[2].withUnsafeBytes { p in
|
var sizes: [Int] = []
|
||||||
let pointers: [UnsafePointer<UInt8>] = [
|
func withAll(_ i: Int, _ body: () -> Void) {
|
||||||
v.bindMemory(to: UInt8.self).baseAddress!,
|
if i == sets.count { body(); return }
|
||||||
s.bindMemory(to: UInt8.self).baseAddress!,
|
sets[i].withUnsafeBytes { raw in
|
||||||
p.bindMemory(to: UInt8.self).baseAddress!,
|
pointers.append(raw.bindMemory(to: UInt8.self).baseAddress!)
|
||||||
]
|
sizes.append(sets[i].count)
|
||||||
let sizes = [vps.count, sps.count, pps.count]
|
withAll(i + 1, body)
|
||||||
return CMVideoFormatDescriptionCreateFromHEVCParameterSets(
|
}
|
||||||
allocator: kCFAllocatorDefault,
|
}
|
||||||
parameterSetCount: 3,
|
var status: OSStatus = -1
|
||||||
parameterSetPointers: pointers,
|
withAll(0) {
|
||||||
parameterSetSizes: sizes,
|
switch codec {
|
||||||
nalUnitHeaderLength: 4,
|
case .hevc:
|
||||||
extensions: nil,
|
status = CMVideoFormatDescriptionCreateFromHEVCParameterSets(
|
||||||
formatDescriptionOut: &format)
|
allocator: kCFAllocatorDefault,
|
||||||
}
|
parameterSetCount: pointers.count,
|
||||||
|
parameterSetPointers: pointers,
|
||||||
|
parameterSetSizes: sizes,
|
||||||
|
nalUnitHeaderLength: 4,
|
||||||
|
extensions: nil,
|
||||||
|
formatDescriptionOut: &format)
|
||||||
|
case .h264:
|
||||||
|
status = CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||||||
|
allocator: kCFAllocatorDefault,
|
||||||
|
parameterSetCount: pointers.count,
|
||||||
|
parameterSetPointers: pointers,
|
||||||
|
parameterSetSizes: sizes,
|
||||||
|
nalUnitHeaderLength: 4,
|
||||||
|
formatDescriptionOut: &format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return status == noErr ? format : nil
|
return status == noErr ? format : nil
|
||||||
@@ -88,11 +145,10 @@ public enum AnnexB {
|
|||||||
|
|
||||||
/// Re-pack an Annex-B AU as AVCC (4-byte big-endian length before each NAL), dropping
|
/// Re-pack an Annex-B AU as AVCC (4-byte big-endian length before each NAL), dropping
|
||||||
/// the parameter-set NALs (they live in the format description).
|
/// the parameter-set NALs (they live in the format description).
|
||||||
public static func avcc(from au: Data) -> Data {
|
public static func avcc(from au: Data, codec: VideoCodec) -> Data {
|
||||||
var out = Data(capacity: au.count + 16)
|
var out = Data(capacity: au.count + 16)
|
||||||
for nal in nalUnits(in: au) {
|
for nal in nalUnits(in: au) {
|
||||||
let t = hevcNalType(nal)
|
if isParameterSet(nal, codec) { continue }
|
||||||
if t == 32 || t == 33 || t == 34 { continue } // VPS/SPS/PPS
|
|
||||||
var len = UInt32(nal.count).bigEndian
|
var len = UInt32(nal.count).bigEndian
|
||||||
withUnsafeBytes(of: &len) { out.append(contentsOf: $0) }
|
withUnsafeBytes(of: &len) { out.append(contentsOf: $0) }
|
||||||
out.append(nal)
|
out.append(nal)
|
||||||
@@ -102,9 +158,9 @@ public enum AnnexB {
|
|||||||
|
|
||||||
/// Wrap one AU as a decode-ready CMSampleBuffer.
|
/// Wrap one AU as a decode-ready CMSampleBuffer.
|
||||||
public static func sampleBuffer(
|
public static func sampleBuffer(
|
||||||
au: AccessUnit, format: CMVideoFormatDescription
|
au: AccessUnit, format: CMVideoFormatDescription, codec: VideoCodec
|
||||||
) -> CMSampleBuffer? {
|
) -> CMSampleBuffer? {
|
||||||
let avccData = avcc(from: au.data)
|
let avccData = avcc(from: au.data, codec: codec)
|
||||||
var blockBuffer: CMBlockBuffer?
|
var blockBuffer: CMBlockBuffer?
|
||||||
guard CMBlockBufferCreateWithMemoryBlock(
|
guard CMBlockBufferCreateWithMemoryBlock(
|
||||||
allocator: kCFAllocatorDefault, memoryBlock: nil,
|
allocator: kCFAllocatorDefault, memoryBlock: nil,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ public enum DefaultsKey {
|
|||||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||||
public static let audioChannels = "punktfunk.audioChannels"
|
public static let audioChannels = "punktfunk.audioChannels"
|
||||||
|
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference —
|
||||||
|
/// the host emits it when it can, else falls back. Drives the decoder via `Welcome.codec`.
|
||||||
|
public static let codec = "punktfunk.codec"
|
||||||
public static let micEnabled = "punktfunk.micEnabled"
|
public static let micEnabled = "punktfunk.micEnabled"
|
||||||
public static let speakerUID = "punktfunk.speakerUID"
|
public static let speakerUID = "punktfunk.speakerUID"
|
||||||
public static let micUID = "punktfunk.micUID"
|
public static let micUID = "punktfunk.micUID"
|
||||||
|
|||||||
@@ -255,6 +255,13 @@ public final class PunktfunkConnection {
|
|||||||
/// PCM from `nextAudioPcm` is interleaved in the canonical wire order FL FR FC LFE RL RR SL SR.
|
/// PCM from `nextAudioPcm` is interleaved in the canonical wire order FL FR FC LFE RL RR SL SR.
|
||||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||||
|
|
||||||
|
/// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`):
|
||||||
|
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
|
||||||
|
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
||||||
|
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||||
|
/// The resolved codec as an `AnnexB.VideoCodec` (H.264 vs HEVC) — drives the NAL parsing.
|
||||||
|
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||||
|
|
||||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||||
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
||||||
///
|
///
|
||||||
@@ -285,6 +292,8 @@ public final class PunktfunkConnection {
|
|||||||
bitrateKbps: UInt32 = 0,
|
bitrateKbps: UInt32 = 0,
|
||||||
videoCaps: UInt8 = 0,
|
videoCaps: UInt8 = 0,
|
||||||
audioChannels: UInt8 = 2,
|
audioChannels: UInt8 = 2,
|
||||||
|
videoCodecs: UInt8 = 0x02, // PUNKTFUNK_CODEC_HEVC — the codecs this client can decode
|
||||||
|
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
timeoutMs: UInt32 = 10_000
|
timeoutMs: UInt32 = 10_000
|
||||||
) throws {
|
) throws {
|
||||||
@@ -300,16 +309,18 @@ public final class PunktfunkConnection {
|
|||||||
withOptionalCString(launchID) { launch in
|
withOptionalCString(launchID) { launch in
|
||||||
if let pin = pinSHA256 {
|
if let pin = pinSHA256 {
|
||||||
return pin.withUnsafeBytes { p in
|
return pin.withUnsafeBytes { p in
|
||||||
punktfunk_connect_ex6(
|
punktfunk_connect_ex7(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, launch,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
|
videoCodecs, preferredCodec, launch,
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||||
cert, key, timeoutMs)
|
cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return punktfunk_connect_ex6(
|
return punktfunk_connect_ex7(
|
||||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, launch,
|
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||||
|
videoCodecs, preferredCodec, launch,
|
||||||
nil, &observed, cert, key, timeoutMs)
|
nil, &observed, cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +358,9 @@ public final class PunktfunkConnection {
|
|||||||
var ac: UInt8 = 2
|
var ac: UInt8 = 2
|
||||||
_ = punktfunk_connection_audio_channels(handle, &ac)
|
_ = punktfunk_connection_audio_channels(handle, &ac)
|
||||||
resolvedAudioChannels = ac
|
resolvedAudioChannels = ac
|
||||||
|
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||||
|
_ = punktfunk_connection_codec(handle, &codec)
|
||||||
|
resolvedCodec = codec
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||||
@@ -620,6 +634,11 @@ public final class PunktfunkConnection {
|
|||||||
/// the host then emits 4:4:4 only if it too opted in. `chromaFormat` reflects the real value.
|
/// the host then emits 4:4:4 only if it too opted in. `chromaFormat` reflects the real value.
|
||||||
public static let videoCap444: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_444)
|
public static let videoCap444: UInt8 = UInt8(PUNKTFUNK_VIDEO_CAP_444)
|
||||||
|
|
||||||
|
/// Codec bits for `videoCodecs` / `preferredCodec` and the value `resolvedCodec` returns.
|
||||||
|
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
||||||
|
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
||||||
|
public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1)
|
||||||
|
|
||||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||||
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
/// 1/50000 units; mastering luminance in 0.0001 cd/m²; MaxCLL/MaxFALL in nits.
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ public final class Stage2Pipeline {
|
|||||||
// chroma subsampling drives only the decode pixel format (orthogonal to HDR/depth); the HDR
|
// chroma subsampling drives only the decode pixel format (orthogonal to HDR/depth); the HDR
|
||||||
// config is the Welcome's latched value, which a mid-session flip then overrides per-frame.
|
// config is the Welcome's latched value, which a mid-session flip then overrides per-frame.
|
||||||
decoder.setChroma444(connection.isChroma444)
|
decoder.setChroma444(connection.isChroma444)
|
||||||
|
decoder.setCodec(connection.videoCodec)
|
||||||
presenter.configure(hdr: connection.isHDR)
|
presenter.configure(hdr: connection.isHDR)
|
||||||
|
|
||||||
let token = token
|
let token = token
|
||||||
@@ -159,7 +160,7 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
if let f = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ final class StreamPump {
|
|||||||
|
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
let idrFormat = AnnexB.formatDescription(fromIDR: au.data)
|
let idrFormat = AnnexB.formatDescription(fromIDR: au.data, codec: connection.videoCodec)
|
||||||
if let f = idrFormat {
|
if let f = idrFormat {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
if awaitingIDR {
|
if awaitingIDR {
|
||||||
@@ -119,7 +119,7 @@ final class StreamPump {
|
|||||||
}
|
}
|
||||||
wasFailed = failed
|
wasFailed = failed
|
||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
let sample = AnnexB.sampleBuffer(au: au, format: f, codec: connection.videoCodec),
|
||||||
token.isLive // don't enqueue a stale frame after a restart
|
token.isLive // don't enqueue a stale frame after a restart
|
||||||
else { continue }
|
else { continue }
|
||||||
layer.enqueue(sample)
|
layer.enqueue(sample)
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
/// depth / HDR). Read inside `createSessionLocked` under `lock`.
|
/// depth / HDR). Read inside `createSessionLocked` under `lock`.
|
||||||
private var chroma444 = false
|
private var chroma444 = false
|
||||||
|
|
||||||
|
/// The negotiated codec (`connection.videoCodec`), set once at session start. Drives the AnnexB
|
||||||
|
/// NAL parsing (H.264 vs HEVC parameter sets). Read under `lock`.
|
||||||
|
private var codec: VideoCodec = .hevc
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
onDecoded: @escaping @Sendable (ReadyFrame) -> Void,
|
||||||
onDecodeError: @escaping @Sendable (OSStatus) -> Void = { _ in }
|
onDecodeError: @escaping @Sendable (OSStatus) -> Void = { _ in }
|
||||||
@@ -73,6 +77,14 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select the negotiated codec (H.264 vs HEVC). Call once at session start, before decoding,
|
||||||
|
/// from `connection.videoCodec`. Thread-safe.
|
||||||
|
public func setCodec(_ c: VideoCodec) {
|
||||||
|
lock.lock()
|
||||||
|
codec = c
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
/// Submit one AU for asynchronous decode, (re)creating the session if `format` changed. The
|
||||||
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
|
/// caller resolves `format` from the IDR exactly as stage-1 does (`AnnexB.formatDescription`).
|
||||||
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
|
/// Returns false if the session couldn't be created or the frame couldn't be submitted.
|
||||||
@@ -93,7 +105,7 @@ public final class VideoDecoder: @unchecked Sendable {
|
|||||||
// invalidate the session between here and DecodeFrame. The VT output callback takes the
|
// invalidate the session between here and DecodeFrame. The VT output callback takes the
|
||||||
// ring lock, not this one, so there's no re-entrancy. DecodeFrame is async — non-blocking.
|
// ring lock, not this one, so there's no re-entrancy. DecodeFrame is async — non-blocking.
|
||||||
guard let session,
|
guard let session,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: newFormat)
|
let sample = AnnexB.sampleBuffer(au: au, format: newFormat, codec: codec)
|
||||||
else { lock.unlock(); return false }
|
else { lock.unlock(); return false }
|
||||||
var infoOut = VTDecodeInfoFlags()
|
var infoOut = VTDecodeInfoFlags()
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ final class AnnexBTests: XCTestCase {
|
|||||||
au.append(n)
|
au.append(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
let avcc = AnnexB.avcc(from: au)
|
let avcc = AnnexB.avcc(from: au, codec: .hevc)
|
||||||
// Only the IDR survives: 4-byte BE length, then the NAL bytes.
|
// Only the IDR survives: 4-byte BE length, then the NAL bytes.
|
||||||
var expected = Data([0, 0, 0, UInt8(idr.count)])
|
var expected = Data([0, 0, 0, UInt8(idr.count)])
|
||||||
expected.append(idr)
|
expected.append(idr)
|
||||||
@@ -74,6 +74,6 @@ final class AnnexBTests: XCTestCase {
|
|||||||
func testFormatDescriptionNilWithoutParameterSets() {
|
func testFormatDescriptionNilWithoutParameterSets() {
|
||||||
let idr = nal(type: 19, payload: [0xDD])
|
let idr = nal(type: 19, payload: [0xDD])
|
||||||
let au = Data(start4) + idr
|
let au = Data(start4) + idr
|
||||||
XCTAssertNil(AnnexB.formatDescription(fromIDR: au))
|
XCTAssertNil(AnnexB.formatDescription(fromIDR: au, codec: .hevc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
|||||||
if firstPtsNs == 0 { firstPtsNs = au.ptsNs }
|
if firstPtsNs == 0 { firstPtsNs = au.ptsNs }
|
||||||
lastPtsNs = au.ptsNs
|
lastPtsNs = au.ptsNs
|
||||||
|
|
||||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
if let f = AnnexB.formatDescription(fromIDR: au.data, codec: .hevc) {
|
||||||
format = f
|
format = f
|
||||||
if decoder == nil {
|
if decoder == nil {
|
||||||
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
let dims = CMVideoFormatDescriptionGetDimensions(f)
|
||||||
@@ -155,7 +155,7 @@ final class RemoteFirstLightTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard let f = format, let dec = decoder,
|
guard let f = format, let dec = decoder,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f)
|
let sample = AnnexB.sampleBuffer(au: au, format: f, codec: .hevc)
|
||||||
else { continue }
|
else { continue }
|
||||||
|
|
||||||
var gotPixels = false
|
var gotPixels = false
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class Stage444Tests: XCTestCase {
|
|||||||
Stage444Probe.hwDecode444_8bit, "no hardware 4:4:4 decode on this device")
|
Stage444Probe.hwDecode444_8bit, "no hardware 4:4:4 decode on this device")
|
||||||
let data = Data(Probe444Blobs.au444_8bit)
|
let data = Data(Probe444Blobs.au444_8bit)
|
||||||
let format = try XCTUnwrap(
|
let format = try XCTUnwrap(
|
||||||
AnnexB.formatDescription(fromIDR: data), "the 4:4:4 blob must yield a format description")
|
AnnexB.formatDescription(fromIDR: data, codec: .hevc), "the 4:4:4 blob must yield a format description")
|
||||||
let au = AccessUnit(data: data, ptsNs: 7_000_000, frameIndex: 0, flags: 0)
|
let au = AccessUnit(data: data, ptsNs: 7_000_000, frameIndex: 0, flags: 0)
|
||||||
|
|
||||||
let box = FrameBox()
|
let box = FrameBox()
|
||||||
|
|||||||
@@ -28,18 +28,18 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
|||||||
|
|
||||||
// 1) Parameter-set extraction → format description.
|
// 1) Parameter-set extraction → format description.
|
||||||
let rebuilt = try XCTUnwrap(
|
let rebuilt = try XCTUnwrap(
|
||||||
AnnexB.formatDescription(fromIDR: annexB),
|
AnnexB.formatDescription(fromIDR: annexB, codec: .hevc),
|
||||||
"in-band VPS/SPS/PPS should yield a format description")
|
"in-band VPS/SPS/PPS should yield a format description")
|
||||||
let dims = CMVideoFormatDescriptionGetDimensions(rebuilt)
|
let dims = CMVideoFormatDescriptionGetDimensions(rebuilt)
|
||||||
XCTAssertEqual(Int(dims.width), width)
|
XCTAssertEqual(Int(dims.width), width)
|
||||||
XCTAssertEqual(Int(dims.height), height)
|
XCTAssertEqual(Int(dims.height), height)
|
||||||
|
|
||||||
// 2) Annex-B → AVCC re-pack must reproduce the encoder's own sample bytes.
|
// 2) Annex-B → AVCC re-pack must reproduce the encoder's own sample bytes.
|
||||||
XCTAssertEqual(AnnexB.avcc(from: annexB), avccSample)
|
XCTAssertEqual(AnnexB.avcc(from: annexB, codec: .hevc), avccSample)
|
||||||
|
|
||||||
// 3) Sample buffer → real decoder → pixels.
|
// 3) Sample buffer → real decoder → pixels.
|
||||||
let au = AccessUnit(data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0)
|
let au = AccessUnit(data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0)
|
||||||
let sample = try XCTUnwrap(AnnexB.sampleBuffer(au: au, format: rebuilt))
|
let sample = try XCTUnwrap(AnnexB.sampleBuffer(au: au, format: rebuilt, codec: .hevc))
|
||||||
|
|
||||||
var session: VTDecompressionSession?
|
var session: VTDecompressionSession?
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
@@ -72,7 +72,7 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
|||||||
func testVideoDecoderAsyncCallbackDeliversPixels() throws {
|
func testVideoDecoderAsyncCallbackDeliversPixels() throws {
|
||||||
let (formatDesc, avccSample) = try encodeOneHEVCKeyframe()
|
let (formatDesc, avccSample) = try encodeOneHEVCKeyframe()
|
||||||
let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample)
|
let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample)
|
||||||
let format = try XCTUnwrap(AnnexB.formatDescription(fromIDR: annexB))
|
let format = try XCTUnwrap(AnnexB.formatDescription(fromIDR: annexB, codec: .hevc))
|
||||||
let au = AccessUnit(data: annexB, ptsNs: 42_000_000, frameIndex: 0, flags: 0)
|
let au = AccessUnit(data: annexB, ptsNs: 42_000_000, frameIndex: 0, flags: 0)
|
||||||
|
|
||||||
let box = FrameBox()
|
let box = FrameBox()
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
|||||||
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
|
||||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||||
|
/// Preferred-codec presets: `(stored value, display label)`. Soft — the host falls back if it can't
|
||||||
|
/// encode the chosen codec.
|
||||||
|
const CODECS: &[(&str, &str)] = &[
|
||||||
|
("auto", "Automatic"),
|
||||||
|
("hevc", "HEVC (H.265)"),
|
||||||
|
("h264", "H.264 (AVC)"),
|
||||||
|
("av1", "AV1"),
|
||||||
|
];
|
||||||
|
|
||||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen.
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen.
|
||||||
const APP_LICENSE: &str = concat!(
|
const APP_LICENSE: &str = concat!(
|
||||||
@@ -681,6 +689,7 @@ fn connect_with(
|
|||||||
mic_enabled: s.mic_enabled,
|
mic_enabled: s.mic_enabled,
|
||||||
hdr_enabled: s.hdr_enabled,
|
hdr_enabled: s.hdr_enabled,
|
||||||
decoder: DecoderPref::from_name(&s.decoder),
|
decoder: DecoderPref::from_name(&s.decoder),
|
||||||
|
preferred_codec: s.preferred_codec(),
|
||||||
pin,
|
pin,
|
||||||
identity: ctx.identity.clone(),
|
identity: ctx.identity.clone(),
|
||||||
connect_timeout: opts.connect_timeout,
|
connect_timeout: opts.connect_timeout,
|
||||||
@@ -1039,6 +1048,21 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let codec_i = CODECS.iter().position(|&(v, _)| v == s.codec).unwrap_or(0) as i32;
|
||||||
|
let codec_names: Vec<String> = CODECS.iter().map(|&(_, l)| l.to_string()).collect();
|
||||||
|
let codec_combo = {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
ComboBox::new(codec_names)
|
||||||
|
.header("Video codec")
|
||||||
|
.selected_index(codec_i)
|
||||||
|
.on_selection_changed(move |i: i32| {
|
||||||
|
let (v, _) = CODECS[(i.max(0) as usize).min(CODECS.len() - 1)];
|
||||||
|
let mut s = ctx.settings.lock().unwrap();
|
||||||
|
s.codec = v.to_string();
|
||||||
|
s.save();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let br_i = BITRATES_MBPS
|
let br_i = BITRATES_MBPS
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&m| m * 1000 == s.bitrate_kbps)
|
.position(|&m| m * 1000 == s.bitrate_kbps)
|
||||||
@@ -1149,6 +1173,7 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
|||||||
.font_size(12.0)
|
.font_size(12.0)
|
||||||
.foreground(ThemeRef::SecondaryText),
|
.foreground(ThemeRef::SecondaryText),
|
||||||
decoder_combo,
|
decoder_combo,
|
||||||
|
codec_combo,
|
||||||
bitrate_combo,
|
bitrate_combo,
|
||||||
hdr_toggle,
|
hdr_toggle,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
|||||||
mic_enabled: flag("--mic"),
|
mic_enabled: flag("--mic"),
|
||||||
hdr_enabled: !flag("--no-hdr"),
|
hdr_enabled: !flag("--no-hdr"),
|
||||||
decoder,
|
decoder,
|
||||||
|
// `--codec h264|hevc|av1` sets the soft preference; default auto (host decides).
|
||||||
|
preferred_codec: match arg("--codec").as_deref() {
|
||||||
|
Some("h264") | Some("avc") => punktfunk_core::quic::CODEC_H264,
|
||||||
|
Some("hevc") | Some("h265") => punktfunk_core::quic::CODEC_HEVC,
|
||||||
|
Some("av1") => punktfunk_core::quic::CODEC_AV1,
|
||||||
|
_ => 0,
|
||||||
|
},
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ pub struct SessionParams {
|
|||||||
pub hdr_enabled: bool,
|
pub hdr_enabled: bool,
|
||||||
/// Which video decode backend to use (auto/hardware/software).
|
/// Which video decode backend to use (auto/hardware/software).
|
||||||
pub decoder: DecoderPref,
|
pub decoder: DecoderPref,
|
||||||
|
/// The user's preferred video codec (a `quic::CODEC_*` bit, `0` = auto). Soft — the host honors
|
||||||
|
/// it when it can emit it, else falls back; the resolved codec drives the decoder.
|
||||||
|
pub preferred_codec: u8,
|
||||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
@@ -166,7 +169,9 @@ fn pump(
|
|||||||
0
|
0
|
||||||
},
|
},
|
||||||
params.audio_channels,
|
params.audio_channels,
|
||||||
None, // launch: the Windows client has no library picker yet
|
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||||
|
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||||
|
None, // launch: the Windows client has no library picker yet
|
||||||
params.pin,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
params.connect_timeout,
|
params.connect_timeout,
|
||||||
@@ -195,7 +200,14 @@ fn pump(
|
|||||||
fingerprint: connector.host_fingerprint,
|
fingerprint: connector.host_fingerprint,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut decoder = match Decoder::new(params.decoder) {
|
// Build the decoder for the codec the host resolved (never assume HEVC).
|
||||||
|
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||||
|
tracing::info!(
|
||||||
|
?codec_id,
|
||||||
|
welcome_codec = connector.codec,
|
||||||
|
"negotiated video codec"
|
||||||
|
);
|
||||||
|
let mut decoder = match Decoder::new(params.decoder, codec_id) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||||
|
|||||||
@@ -138,6 +138,26 @@ pub struct Settings {
|
|||||||
pub hdr_enabled: bool,
|
pub hdr_enabled: bool,
|
||||||
/// Video decode backend: `auto` (D3D11VA, fall back to software), `hardware`, or `software`.
|
/// Video decode backend: `auto` (D3D11VA, fall back to software), `hardware`, or `software`.
|
||||||
pub decoder: String,
|
pub decoder: String,
|
||||||
|
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
|
||||||
|
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
||||||
|
#[serde(default = "default_codec")]
|
||||||
|
pub codec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_codec() -> String {
|
||||||
|
"auto".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||||
|
pub fn preferred_codec(&self) -> u8 {
|
||||||
|
match self.codec.as_str() {
|
||||||
|
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
|
||||||
|
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
|
||||||
|
"av1" => punktfunk_core::quic::CODEC_AV1,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -154,6 +174,7 @@ impl Default for Settings {
|
|||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
hdr_enabled: true,
|
hdr_enabled: true,
|
||||||
decoder: "auto".into(),
|
decoder: "auto".into(),
|
||||||
|
codec: "auto".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,17 +124,46 @@ enum Backend {
|
|||||||
|
|
||||||
pub struct Decoder {
|
pub struct Decoder {
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
|
/// The negotiated codec, so a mid-session D3D11VA→software demotion rebuilds for the same codec.
|
||||||
|
codec_id: ffmpeg::codec::Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||||
|
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||||
|
match wire {
|
||||||
|
punktfunk_core::quic::CODEC_H264 => ffmpeg::codec::Id::H264,
|
||||||
|
punktfunk_core::quic::CODEC_AV1 => ffmpeg::codec::Id::AV1,
|
||||||
|
_ => ffmpeg::codec::Id::HEVC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
||||||
|
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
||||||
|
pub fn decodable_codecs() -> u8 {
|
||||||
|
let _ = ffmpeg::init();
|
||||||
|
let mut bits = 0u8;
|
||||||
|
for (id, bit) in [
|
||||||
|
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
|
||||||
|
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
|
||||||
|
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
|
||||||
|
] {
|
||||||
|
if ffmpeg::decoder::find(id).is_some() {
|
||||||
|
bits |= bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bits
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Decoder {
|
impl Decoder {
|
||||||
pub fn new(pref: DecoderPref) -> Result<Decoder> {
|
pub fn new(pref: DecoderPref, codec_id: ffmpeg::codec::Id) -> Result<Decoder> {
|
||||||
ffmpeg::init().context("ffmpeg init")?;
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
if pref != DecoderPref::Software {
|
if pref != DecoderPref::Software {
|
||||||
match D3d11vaDecoder::new() {
|
match D3d11vaDecoder::new(codec_id) {
|
||||||
Ok(d) => {
|
Ok(d) => {
|
||||||
tracing::info!("D3D11VA hardware decode active (zero-copy)");
|
tracing::info!(?codec_id, "D3D11VA hardware decode active (zero-copy)");
|
||||||
return Ok(Decoder {
|
return Ok(Decoder {
|
||||||
backend: Backend::D3d11va(d),
|
backend: Backend::D3d11va(d),
|
||||||
|
codec_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -146,7 +175,8 @@ impl Decoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Decoder {
|
Ok(Decoder {
|
||||||
backend: Backend::Software(SoftwareDecoder::new()?),
|
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
||||||
|
codec_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +194,7 @@ impl Decoder {
|
|||||||
Ok(f) => Ok(f.map(DecodedFrame::Gpu)),
|
Ok(f) => Ok(f.map(DecodedFrame::Gpu)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "D3D11VA decode failed — falling back to software");
|
tracing::warn!(error = %e, "D3D11VA decode failed — falling back to software");
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new()?);
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -183,9 +213,9 @@ struct SoftwareDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SoftwareDecoder {
|
impl SoftwareDecoder {
|
||||||
fn new() -> Result<SoftwareDecoder> {
|
fn new(codec_id: ffmpeg::codec::Id) -> Result<SoftwareDecoder> {
|
||||||
let codec =
|
let codec = ffmpeg::decoder::find(codec_id)
|
||||||
ffmpeg::decoder::find(ffmpeg::codec::Id::HEVC).ok_or(anyhow!("no HEVC decoder"))?;
|
.ok_or_else(|| anyhow!("no {codec_id:?} decoder in libavcodec"))?;
|
||||||
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
|
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
|
||||||
unsafe {
|
unsafe {
|
||||||
let raw = ctx.as_mut_ptr();
|
let raw = ctx.as_mut_ptr();
|
||||||
@@ -194,7 +224,7 @@ impl SoftwareDecoder {
|
|||||||
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
|
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
|
||||||
(*raw).thread_count = 0; // auto
|
(*raw).thread_count = 0; // auto
|
||||||
}
|
}
|
||||||
let decoder = ctx.decoder().video().context("open HEVC decoder")?;
|
let decoder = ctx.decoder().video().context("open video decoder")?;
|
||||||
Ok(SoftwareDecoder { decoder, sws: None })
|
Ok(SoftwareDecoder { decoder, sws: None })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +389,7 @@ struct D3d11vaDecoder {
|
|||||||
unsafe impl Send for D3d11vaDecoder {}
|
unsafe impl Send for D3d11vaDecoder {}
|
||||||
|
|
||||||
impl D3d11vaDecoder {
|
impl D3d11vaDecoder {
|
||||||
fn new() -> Result<D3d11vaDecoder> {
|
fn new(codec_id: ffmpeg::codec::Id) -> Result<D3d11vaDecoder> {
|
||||||
use ffmpeg::ffi;
|
use ffmpeg::ffi;
|
||||||
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||||
if !shared.hardware {
|
if !shared.hardware {
|
||||||
@@ -387,11 +417,11 @@ impl D3d11vaDecoder {
|
|||||||
bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r));
|
bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
let codec = ffi::avcodec_find_decoder(ffi::AVCodecID::AV_CODEC_ID_HEVC);
|
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||||
if codec.is_null() {
|
if codec.is_null() {
|
||||||
let mut hw = hw_device;
|
let mut hw = hw_device;
|
||||||
ffi::av_buffer_unref(&mut hw);
|
ffi::av_buffer_unref(&mut hw);
|
||||||
bail!("no HEVC decoder");
|
bail!("no {codec_id:?} decoder");
|
||||||
}
|
}
|
||||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||||
|
|||||||
Reference in New Issue
Block a user