From 596e8ea3973c65dc8e93b504f7c27cb2cd9fe6e7 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 8 May 2026 21:46:11 -0400 Subject: [PATCH] filter by hw decode --- .../asset-viewer/VideoNativeViewer.svelte | 47 ++++++------ .../media-capabilities-manager.svelte.ts | 72 +++++++++++++++++++ web/src/lib/utils/server.ts | 2 + 3 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 web/src/lib/managers/media-capabilities-manager.svelte.ts diff --git a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte index 29c4c01efc..4a7c01ca4d 100644 --- a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte +++ b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte @@ -43,6 +43,7 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte'; interface Props { asset: AssetResponseDto; @@ -116,6 +117,7 @@ errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 }, }, }, + useMediaCapabilities: false, }; const releaseSession = () => { @@ -138,24 +140,36 @@ return; } - api.on(Hls.Events.MANIFEST_PARSED, () => { + api.on(Hls.Events.MANIFEST_PARSED, async () => { + // Defer hls.js's first fragment load until we filter out suboptimal variants + api.stopLoad(); const id = api.levels[0]?.url[0]?.match(SESSION_ID_REGEX)?.[1]; if (id) { activeSession = { assetId, id }; } - }); - // Once ABR has picked an initial codec tier, drop the others. - // The player already avoids switching codecs, and removing the other codecs - // makes the quality selector cleaner with only resolution options. - api.once(Hls.Events.LEVEL_SWITCHED, (_event, data) => { - const chosenLevel = api.levels[data.level]; - for (let idx = api.levels.length - 1; idx >= 0; idx--) { - const { codecSet, videoRange } = api.levels[idx]; - if (codecSet !== chosenLevel.codecSet || videoRange !== chosenLevel.videoRange) { - api.removeLevel(idx); + const decodingInfo = await Promise.all(api.levels.map((level) => mediaCapabilitiesManager.decodingInfo(level))); + const lowestBitrateByHeight = new Map(); + for (let i = 0; i < api.levels.length; i++) { + if (!decodingInfo[i].powerEfficient) { + continue; + } + + const { bitrate, height } = api.levels[i]; + const cur = lowestBitrateByHeight.get(height); + if (cur === undefined || bitrate < api.levels[cur].bitrate) { + lowestBitrateByHeight.set(height, i); } } + + const keep = new Set(lowestBitrateByHeight.values()); + for (let i = api.levels.length - 1; i >= 0; i--) { + if (!keep.has(i)) { + api.removeLevel(i); + } + } + + api.startLoad(resumeTime); }); api.on(Hls.Events.FRAG_LOADED, () => (rebuildCount = 0)); @@ -174,17 +188,10 @@ } activeSession = undefined; resumeTime = el.currentTime; - // Re-setting src triggers attributeChangedCallback → load(), which - // destroys the old Hls and instantiates a new one with our config. - const url = el.src; - el.removeAttribute('src'); - el.setAttribute('src', url); + el.load(); + // wireHlsListeners must run after el.api is repopulated. queueMicrotask(() => wireHlsListeners(el, assetId, resumeTime)); }); - - if (resumeTime) { - el.addEventListener('loadedmetadata', () => (el.currentTime = resumeTime!), { once: true }); - } }; onMount(() => { diff --git a/web/src/lib/managers/media-capabilities-manager.svelte.ts b/web/src/lib/managers/media-capabilities-manager.svelte.ts new file mode 100644 index 0000000000..f366ef2c0a --- /dev/null +++ b/web/src/lib/managers/media-capabilities-manager.svelte.ts @@ -0,0 +1,72 @@ +export type Level = { videoCodec?: string; width: number; height: number; bitrate: number; frameRate: number }; + +export const DEFAULT_DECODING_INFO: MediaCapabilitiesDecodingInfo = { + powerEfficient: true, + smooth: true, + supported: true, + keySystemAccess: null, +}; + +class MediaCapabilitiesManager { + private cache = new Map>(); + + init() { + for (const level of [ + { videoCodec: 'av01.0.04M.08', width: 854, height: 480, bitrate: 1_000_000, frameRate: 60 }, + { videoCodec: 'hvc1.1.6.L90.B0', width: 854, height: 480, bitrate: 1_200_000, frameRate: 60 }, + { videoCodec: 'av01.0.08M.08', width: 1280, height: 720, bitrate: 2_000_000, frameRate: 60 }, + { videoCodec: 'hvc1.1.6.L93.B0', width: 1280, height: 720, bitrate: 2_500_000, frameRate: 60 }, + { videoCodec: 'av01.0.09M.08', width: 1920, height: 1080, bitrate: 4_000_000, frameRate: 60 }, + { videoCodec: 'hvc1.1.6.L120.B0', width: 1920, height: 1080, bitrate: 4_500_000, frameRate: 60 }, + { videoCodec: 'av01.0.12M.08', width: 2560, height: 1440, bitrate: 7_000_000, frameRate: 60 }, + { videoCodec: 'hvc1.2.4.L150.B0', width: 2560, height: 1440, bitrate: 8_000_000, frameRate: 60 }, + ]) { + this.cache.set(this.cacheKey(level), this.queryDecodingInfo(level)); + } + + for (const level of [ + { videoCodec: 'avc1.64001e', width: 854, height: 480, bitrate: 2_500_000, frameRate: 60 }, + { videoCodec: 'avc1.64001f', width: 1280, height: 720, bitrate: 5_000_000, frameRate: 60 }, + { videoCodec: 'avc1.640028', width: 1920, height: 1080, bitrate: 8_000_000, frameRate: 60 }, + { videoCodec: 'avc1.640032', width: 2560, height: 1440, bitrate: 16_000_000, frameRate: 60 }, + ]) { + this.cache.set(this.cacheKey(level), Promise.resolve(DEFAULT_DECODING_INFO)); + } + } + + decodingInfo(level: Level) { + const key = this.cacheKey(level); + const existing = this.cache.get(key); + if (existing) { + return existing; + } + const promise = this.queryDecodingInfo(level); + this.cache.set(key, promise); + return promise; + } + + private async queryDecodingInfo(level: Level) { + try { + return await navigator.mediaCapabilities.decodingInfo({ + type: 'media-source', + video: { + contentType: `video/mp4; codecs="${level.videoCodec}"`, + width: level.width, + height: level.height, + bitrate: level.bitrate, + framerate: level.frameRate, + }, + }); + } catch { + return DEFAULT_DECODING_INFO; + } + } + + private cacheKey({ videoCodec, width, height, frameRate }: Level) { + const resolution = Math.min(width, height); + const fpsBucket = Math.trunc(frameRate / 61) * 60; + return `${videoCodec}|${resolution}|${fpsBucket}`; + } +} + +export const mediaCapabilitiesManager = new MediaCapabilitiesManager(); diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts index e2d0048b20..8c09d9dc5b 100644 --- a/web/src/lib/utils/server.ts +++ b/web/src/lib/utils/server.ts @@ -2,6 +2,7 @@ import { defaults } from '@immich/sdk'; import { memoize } from 'lodash-es'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; +import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { initLanguage } from '$lib/utils'; @@ -12,6 +13,7 @@ async function _init(fetch: Fetch) { // https://kit.svelte.dev/docs/load#making-fetch-requests // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options defaults.fetch = fetch; + mediaCapabilitiesManager.init(); await initLanguage(); await serverConfigManager.init(); await authManager.load();