small refactoring

AuthorKonata <konata@posteo.jp>
Date
Commit664ab495d25a0ad8136f1d750eb4d5864439adb8
Parentdbd744e
6 files changed, 128 insertions(+), 151 deletions(-)
▾Mserver/src/ffmpeg.ts
@@ -1,5 +1,5 @@
import { AudioCodec, MediaContainer, type Metadata, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { createRFC6381String, ladderMaxWidth, type VideoStreamInfo } from "music-server-shared/utils";
import { createRFC6381String, ladderMaxWidth, videoStreamInfo } from "music-server-shared/utils";
const KEYFRAME_INTERVAL_SECONDS = 5;
@@ -122,17 +122,8 @@ export async function convertWithFFmpeg(
languages: string,
metadata: Metadata,
) {
const videoStreamInfo: VideoStreamInfo | undefined =
metadata.videoWidth && metadata.videoHeight && metadata.frameRate
? {
sourceWidth: metadata.videoWidth,
sourceHeight: metadata.videoHeight,
frameRate: metadata.frameRate,
interlaced: metadata.interlaced === true,
bitrate: videoBitrate,
}
: undefined;
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec, videoStreamInfo);
const video = videoStreamInfo(metadata, videoBitrate);
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec, video);
const audioEncoder = lookup(AUDIO_ENCODERS, requestedAudioCodec, "audio codec");
const hasVideo = requestedVideoCodec !== VideoCodec.none;
▾Mserver/src/index.ts
@@ -66,6 +66,12 @@ async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string
type FileHandlerContext = Context<{ params: { "*": string } }>;
//re-encoding above the source bitrate only costs bandwidth, it cannot add back detail. an unknown source
//bitrate leaves the request as it is - there is nothing to compare against
function clampToSource(requested: number | undefined, source: number | undefined): number | undefined {
return requested && source ? Math.min(requested, source) : requested;
}
function transcodeFailed(set: FileHandlerContext["set"], exitCode: number | null, stderr: string) {
set.status = "Internal Server Error";
//the first lines are the root cause; what follows is each thread unwinding and reporting the same
@@ -180,14 +186,8 @@ const app = setup
const { filePath, info: fileScan } = resolved;
const probe = await probeFile(filePath);
//don't use higher bitrate than what the file has, use requested bitrate if unknown
const audioBitrate =
query.audioBitrate && probe.audioBitrate
? Math.min(query.audioBitrate, probe.audioBitrate)
: query.audioBitrate;
const videoBitrate =
probe.videoBitrate && query.videoBitrate
? Math.min(query.videoBitrate, probe.videoBitrate)
: query.videoBitrate;
const audioBitrate = clampToSource(query.audioBitrate, probe.audioBitrate);
const videoBitrate = clampToSource(query.videoBitrate, probe.videoBitrate);
if (!matchesType(fileScan.mimeType, mediaTypes)) {
set.status = "Temporary Redirect";
▾Mshared/utils.ts
@@ -1,4 +1,4 @@
import { AudioCodec, MediaContainer, VideoCodec } from "./types";
import { AudioCodec, MediaContainer, type Metadata, VideoCodec } from "./types";
export function decodePath(path: string) {
return path
@@ -15,10 +15,8 @@ export function encodePath(path: string) {
}
//scale the video down to keep the requested bitrate plausible for its resolution. each width is the
//largest one still reasonable at that bitrate ceiling; above the last rung the source is left alone.
//shared because the server needs it to build the scale filter and the client needs it to predict the
//output resolution the codec level is derived from - the two strings have to agree
export const RESOLUTION_LADDER: { upToBitrate: number; width: number }[] = [
//largest one still reasonable at that bitrate ceiling; above the last rung the source is left alone
const RESOLUTION_LADDER: { upToBitrate: number; width: number }[] = [
{ upToBitrate: 400_000, width: 640 },
{ upToBitrate: 800_000, width: 854 },
{ upToBitrate: 2_500_000, width: 1280 },
@@ -26,6 +24,8 @@ export const RESOLUTION_LADDER: { upToBitrate: number; width: number }[] = [
{ upToBitrate: 12_000_000, width: 2560 },
];
//shared because the server needs it to build the scale filter and the client needs it to predict the
//output resolution the codec level is derived from - the two strings have to agree
export function ladderMaxWidth(videoBitrate: number): number | undefined {
return RESOLUTION_LADDER.find((rung) => videoBitrate <= rung.upToBitrate)?.width;
}
@@ -45,8 +45,21 @@ export interface VideoStreamInfo {
bitrate: number;
}
export function videoStreamInfo(metadata: Metadata | undefined, videoBitrate: number): VideoStreamInfo | undefined {
if (!metadata?.videoWidth || !metadata.videoHeight || !metadata.frameRate) return undefined;
return {
sourceWidth: metadata.videoWidth,
sourceHeight: metadata.videoHeight,
frameRate: metadata.frameRate,
interlaced: metadata.interlaced === true,
//transcoding never raises the bitrate above the source's, so the level must be derived from the
//capped value. clamping twice changes nothing, which lets the server pass its already-capped number
bitrate: metadata.videoBitrate ? Math.min(videoBitrate, metadata.videoBitrate) : videoBitrate,
};
}
//mirrors -vf scale='if(gt(iw,W),W,iw)':-2 - below the rung width the source passes through untouched
export function transcodeOutputSize(sourceWidth: number, sourceHeight: number, videoBitrate: number) {
function transcodeOutputSize(sourceWidth: number, sourceHeight: number, videoBitrate: number) {
const maxWidth = ladderMaxWidth(videoBitrate);
if (maxWidth === undefined || sourceWidth <= maxWidth) return { width: sourceWidth, height: sourceHeight };
//-2 keeps the aspect ratio at a multiple of two. rounding up rather than to nearest can only ever
@@ -139,84 +152,84 @@ const AV1_LEVELS = [
{ idx: 18, maxPicSize: 35651584, maxH: 16384, maxV: 8704, maxDisplayRate: 4278190080, mbps: 160.0 },
];
//an input beyond the last level, or missing source metadata, falls back to the highest level. claiming
//more than the stream needs can only make a weak client refuse a type it would have managed, and it
//then falls back to progressive playback; claiming less lets it start and fail part-way through
function highest<T>(table: T[]): T {
return table[table.length - 1];
//the stream as the encoder will see it, which is what the level limits are written against
function encodedStream(video: VideoStreamInfo) {
const size = transcodeOutputSize(video.sourceWidth, video.sourceHeight, video.bitrate);
return {
size,
pixels: size.width * size.height,
//the encoder sees one frame per field once yadif has run
fps: video.interlaced ? video.frameRate * 2 : video.frameRate,
kbps: (video.bitrate * BITRATE_PEAK_MARGIN) / 1000,
};
}
export function videoCodecToken(videoCodec: VideoCodec, video: VideoStreamInfo | undefined): string {
//the encoder sees one frame per field once yadif has run
const fps = video ? (video.interlaced ? video.frameRate * 2 : video.frameRate) : 0;
const size = video ? transcodeOutputSize(video.sourceWidth, video.sourceHeight, video.bitrate) : undefined;
const kbps = video ? (video.bitrate * BITRATE_PEAK_MARGIN) / 1000 : 0;
const stream = video && encodedStream(video);
//an input beyond the last level, or missing source metadata, takes the highest level. claiming more than
//the stream needs can only make a weak client refuse a type it would have managed, and it then falls back
//to progressive playback; claiming less lets it start and fail part-way through
function pickLevel<T>(table: T[], fits: (level: T, s: ReturnType<typeof encodedStream>) => boolean): T {
const highest = table[table.length - 1];
if (!stream) return highest;
return table.find((entry) => fits(entry, stream)) ?? highest;
}
switch (videoCodec) {
case VideoCodec.av1: {
const pixels = size ? size.width * size.height : 0;
const level =
(size &&
AV1_LEVELS.find(
(l) =>
pixels <= l.maxPicSize &&
size.width <= l.maxH &&
size.height <= l.maxV &&
pixels * fps <= l.maxDisplayRate &&
kbps / 1000 <= l.mbps,
)) ||
highest(AV1_LEVELS);
const level = pickLevel(
AV1_LEVELS,
(l, { size, pixels, fps, kbps }) =>
pixels <= l.maxPicSize &&
size.width <= l.maxH &&
size.height <= l.maxV &&
pixels * fps <= l.maxDisplayRate &&
kbps / 1000 <= l.mbps,
);
//profile 0 (Main), Main tier, 8 bit
return `av01.0.${level.idx.toString().padStart(2, "0")}M.08`;
}
case VideoCodec.h264: {
const mbWidth = size ? Math.ceil(size.width / 16) : 0;
const mbHeight = size ? Math.ceil(size.height / 16) : 0;
const fs = mbWidth * mbHeight;
const level =
(size &&
H264_LEVELS.find(
(l) =>
fs <= l.maxFS &&
fs * fps <= l.maxMBPS &&
//Annex A.3.1 also caps each dimension individually
mbWidth <= Math.sqrt(l.maxFS * 8) &&
mbHeight <= Math.sqrt(l.maxFS * 8) &&
kbps <= l.maxBR * 1.25,
)) ||
highest(H264_LEVELS);
const level = pickLevel(H264_LEVELS, (l, { size, fps, kbps }) => {
const mbWidth = Math.ceil(size.width / 16);
const mbHeight = Math.ceil(size.height / 16);
const fs = mbWidth * mbHeight;
return (
fs <= l.maxFS &&
fs * fps <= l.maxMBPS &&
//Annex A.3.1 also caps each dimension individually
mbWidth <= Math.sqrt(l.maxFS * 8) &&
mbHeight <= Math.sqrt(l.maxFS * 8) &&
kbps <= l.maxBR * 1.25
);
});
//High profile (profile_idc 100 = 0x64) with no constraint flags set
return `avc1.6400${level.idc.toString(16).padStart(2, "0")}`;
}
case VideoCodec.h265: {
const pixels = size ? size.width * size.height : 0;
const level =
(size &&
H265_LEVELS.find(
(l) =>
pixels <= l.maxLumaPs &&
pixels * fps <= l.maxLumaSr &&
size.width <= Math.sqrt(l.maxLumaPs * 8) &&
size.height <= Math.sqrt(l.maxLumaPs * 8) &&
kbps <= l.maxBR,
)) ||
highest(H265_LEVELS);
const level = pickLevel(
H265_LEVELS,
(l, { size, pixels, fps, kbps }) =>
pixels <= l.maxLumaPs &&
pixels * fps <= l.maxLumaSr &&
size.width <= Math.sqrt(l.maxLumaPs * 8) &&
size.height <= Math.sqrt(l.maxLumaPs * 8) &&
kbps <= l.maxBR,
);
//Main profile: profile_space 0, profile_idc 1, compatibility flags 0x60000000 written
//bit-reversed as hex ("6"), Main tier ("L"). B0 = progressive, non-packed, frame-only.
return `hev1.1.6.L${level.idc}.B0`;
}
case VideoCodec.vp9: {
const pixels = size ? size.width * size.height : 0;
const level =
(size &&
VP9_LEVELS.find(
(l) =>
pixels <= l.maxPs &&
pixels * fps <= l.maxSr &&
Math.max(size.width, size.height) <= l.maxDim &&
kbps <= l.maxBR,
)) ||
highest(VP9_LEVELS);
const level = pickLevel(
VP9_LEVELS,
(l, { size, pixels, fps, kbps }) =>
pixels <= l.maxPs &&
pixels * fps <= l.maxSr &&
Math.max(size.width, size.height) <= l.maxDim &&
kbps <= l.maxBR,
);
//profile 0 (4:2:0), 8 bit
return `vp09.00.${level.code.toString().padStart(2, "0")}.08`;
}
▾Mwebclient/src/App.tsx
@@ -6,7 +6,7 @@ import {
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
import { createRFC6381String, decodePath, encodePath, videoStreamInfo } from "music-server-shared/utils";
import type { Component } from "solid-js";
import { batch, createEffect, createResource, createSignal, on, onMount, Show } from "solid-js";
import { createStore, reconcile } from "solid-js/store";
@@ -292,34 +292,15 @@ const App: Component = () => {
let mediaSource: MediaSource | null = null;
let mediaSourceAbortController: AbortController | null = null;
async function getMSEUrl(srcUrl: string, offsetSeconds: number, isVideo: boolean) {
let mimeType: string;
if (isVideo) {
const metadata = playerState.currentTrack?.metadata;
const videoStreamInfo =
metadata?.videoWidth && metadata.videoHeight && metadata.frameRate
? {
sourceWidth: metadata.videoWidth,
sourceHeight: metadata.videoHeight,
frameRate: metadata.frameRate,
interlaced: metadata.interlaced === true,
bitrate: metadata.videoBitrate
? Math.min(options.videoSettings.videoBitrate, metadata.videoBitrate)
: options.videoSettings.videoBitrate,
}
: undefined;
mimeType = createRFC6381String(
options.videoSettings.container,
options.videoSettings.videoCodec,
options.videoSettings.audioCodec,
videoStreamInfo,
);
} else {
mimeType = createRFC6381String(
options.audioSettings.container,
VideoCodec.none,
options.audioSettings.audioCodec,
);
}
const settings = isVideo ? options.videoSettings : options.audioSettings;
const mimeType = createRFC6381String(
settings.container,
isVideo ? options.videoSettings.videoCodec : VideoCodec.none,
settings.audioCodec,
isVideo
? videoStreamInfo(playerState.currentTrack?.metadata, options.videoSettings.videoBitrate)
: undefined,
);
if (!MediaSource.isTypeSupported(mimeType)) return null;
mediaSource = new MediaSource();
▾Mwebclient/src/capabilities.ts
@@ -51,11 +51,12 @@ const CONTAINER_VIDEO_CODECS: Record<MediaContainer, VideoCodec[]> = {
};
export function containerAudioCodecs(container: MediaContainer): AudioCodec[] {
return CONTAINER_AUDIO_CODECS[container] ?? [];
return CONTAINER_AUDIO_CODECS[container];
}
//"none" is always an option: every container can carry audio alone
export function containerVideoCodecs(container: MediaContainer): VideoCodec[] {
return [...(CONTAINER_VIDEO_CODECS[container] ?? []), VideoCodec.none];
return [...CONTAINER_VIDEO_CODECS[container], VideoCodec.none];
}
//actual values don't seem to matter, besides for a possible "smoothnes" verdict
@@ -87,9 +88,10 @@ function progressivelyPlayable(mimeType: string): boolean {
const decodingCache = new Map<string, { supported: boolean; smooth: boolean }>();
const pending = new Map<string, Promise<void>>();
function startDecodingProbe(key: string, configuration: MediaDecodingConfiguration) {
const existing = pending.get(key);
if (existing) return existing;
//the same key really does come up twice: progressive and buffered are both type "file", so initCapabilities
//enumerates every one of these configurations a second time
function startDecodingProbe(key: string, configuration: MediaDecodingConfiguration): void {
if (pending.has(key)) return;
const probe = navigator.mediaCapabilities
.decodingInfo(configuration)
.then((info) => {
@@ -104,7 +106,6 @@ function startDecodingProbe(key: string, configuration: MediaDecodingConfigurati
pending.delete(key);
});
pending.set(key, probe);
return probe;
}
export function checkSupport(combination: Combination): Support {
@@ -161,7 +162,7 @@ export function checkSupport(combination: Combination): Support {
const key = `${configuration.type}|${configuration.audio?.contentType}|${configuration.video?.contentType}|${video?.bitrate}`;
const cached = decodingCache.get(key);
if (!cached) {
void startDecodingProbe(key, configuration);
startDecodingProbe(key, configuration);
return { supported: true };
}
if (!cached.supported) return { supported: false, reason: `the browser's decoders reject ${mimeType}` };
@@ -183,13 +184,13 @@ function checkContainer(combination: Combination, videoCodecs: VideoCodec[]): Su
return firstFailure ?? { supported: false, reason: `${combination.container} offers no usable codecs` };
}
export function checkVideoContainerSupport(combination: Combination): Support {
return checkContainer(combination, containerVideoCodecs(combination.container));
}
//an audio-only profile must not be told mp4 works because mp4 video works, only because mp4 audio does
export function checkAudioContainerSupport(combination: Combination): Support {
return checkContainer(combination, [VideoCodec.none]);
//an audio-only profile must not be told mp4 works because mp4 video works, only because mp4 audio does -
//and an absent videoCodec is exactly what makes a profile audio-only
export function checkContainerSupport(combination: Combination): Support {
return checkContainer(
combination,
combination.videoCodec === undefined ? [VideoCodec.none] : containerVideoCodecs(combination.container),
);
}
function usableAudioCodecs(combination: Combination): AudioCodec[] {
@@ -205,8 +206,9 @@ function usableVideoCodecs(combination: Combination): VideoCodec[] {
}
function usableContainers(combination: Combination): MediaContainer[] {
const check = combination.videoCodec === undefined ? checkAudioContainerSupport : checkVideoContainerSupport;
return Object.values(MediaContainer).filter((container) => check({ ...combination, container }).supported);
return Object.values(MediaContainer).filter(
(container) => checkContainerSupport({ ...combination, container }).supported,
);
}
//used to decide whether the video section is worth showing at all - it is not when nothing but "none" would
@@ -256,7 +258,8 @@ export function playableSettings<T extends Combination>(settings: T): T {
}
export async function initCapabilities(): Promise<void> {
for (const streamingMode of Object.values(StreamingMode)) {
//buffered and progressive have the same support ("file"), so only one check needed
for (const streamingMode of [StreamingMode.mse, StreamingMode.progressive]) {
for (const container of Object.values(MediaContainer)) {
for (const audioCodec of containerAudioCodecs(container)) {
for (const videoCodec of containerVideoCodecs(container)) {
▾Mwebclient/src/components/OptionsModal.tsx
@@ -1,4 +1,4 @@
import { type Accessor, batch, createEffect, createSignal, For, Match, on, type Setter, Show, Switch } from "solid-js";
import { type Accessor, createEffect, createSignal, For, Match, on, type Setter, Show, Switch } from "solid-js";
import { type AppOptions, AUDIO_BITRATES, FileDisplay, StreamingMode, VIDEO_BITRATES } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
@@ -7,9 +7,8 @@ import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "mu
import { audioCodecHasBitrate } from "music-server-shared/utils";
import { createStore } from "solid-js/store";
import {
checkAudioContainerSupport,
checkContainerSupport,
checkSupport,
checkVideoContainerSupport,
type Combination,
containerAudioCodecs,
containerVideoCodecs,
@@ -57,21 +56,11 @@ export default function OptionsModal(props: OptionsModalProps) {
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
const videoSupport = () => checkSupport(newOptions.videoSettings);
const audioSupport = () => checkSupport(newOptions.audioSettings);
//keep the selection playable, so the greyed-out entries in the lists are only ever ones the user could
//pick and never the one they are already on. writing a value a store already holds notifies nobody, so
//once the selection is playable this re-runs, finds the same values and settles
//pick and never the one they are already on. the store setter merges key by key and skips values it
//already holds, so a settled selection writes nothing, notifies nobody and this re-run is the last
function coerceProfile(profile: Profile) {
const playable = playableSettings<Combination>(newOptions[profile]);
batch(() => {
setNewOptions(profile, "streamingMode", playable.streamingMode);
setNewOptions(profile, "container", playable.container);
setNewOptions(profile, "audioCodec", playable.audioCodec);
//only videoSettings has a video codec, which is exactly when playableSettings returns one
if (playable.videoCodec !== undefined) setNewOptions("videoSettings", "videoCodec", playable.videoCodec);
});
setNewOptions(profile, playableSettings<Combination>(newOptions[profile]));
}
createEffect(() => coerceProfile("videoSettings"));
createEffect(() => coerceProfile("audioSettings"));
@@ -355,7 +344,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<label for="container-select">Container:</label>
<select
id="container-select"
title={videoSupport().reason}
title={checkSupport(newOptions.videoSettings).reason}
value={newOptions.videoSettings.container}
onChange={(e) => setNewOptions("videoSettings", "container", e.target.value as MediaContainer)}
>
@@ -363,7 +352,7 @@ export default function OptionsModal(props: OptionsModalProps) {
{(format) => (
<SupportedOption
value={format}
support={checkVideoContainerSupport({ ...newOptions.videoSettings, container: format })}
support={checkContainerSupport({ ...newOptions.videoSettings, container: format })}
/>
)}
</For>
@@ -471,7 +460,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<label for="music-container-select">Container:</label>
<select
id="music-container-select"
title={audioSupport().reason}
title={checkSupport(newOptions.audioSettings).reason}
value={newOptions.audioSettings.container}
onChange={(e) => setNewOptions("audioSettings", "container", e.target.value as MediaContainer)}
>
@@ -479,7 +468,7 @@ export default function OptionsModal(props: OptionsModalProps) {
{(format) => (
<SupportedOption
value={format}
support={checkAudioContainerSupport({ ...newOptions.audioSettings, container: format })}
support={checkContainerSupport({ ...newOptions.audioSettings, container: format })}
/>
)}
</For>