„Kenne ich" heisst nicht „kann ich diese Datei"

Der Spieler schickte sein Profil aus `canPlayType` — und das ist eine
Auskunft des `<video>`-Elements. Ein Strom läuft aber nicht durch das
Element, sondern durch den MSE-Puffer, und die beiden sind sich nicht
einig.

Gemessen in Chromium:

| | canPlayType | MediaSource.isTypeSupported |
|---|---|---|
| `audio/flac` | probably | **false** |
| `audio/mp4; codecs="mp3"` | probably | **false** |

Der Server glaubte dem Profil, packte die Datei pflichtschuldig um und
liess die Spur unberührt — und der Browser stand vor etwas, das er nach
eigener Auskunft können müsste. Heraus kam „Dieser Browser kann die
Spuren dieser Datei nicht öffnen".

Jetzt zählt ein Codec nur, wenn **beide** Auskünfte ihn bestätigen; welcher
der beiden Wege es wird, entscheidet der Server erst danach. Und geprüft
wird jeder Typ in der Form, in der er wirklich ankommt — `audio/mp4` und
nicht `audio/flac`: genau dieser Unterschied entschied bei FLAC zwischen
„geht" und „geht nicht". Gemessen: MP3 fällt dadurch aus dem Profil, der
Server rechnet es um, statt es durchzureichen.

**Und wenn doch etwas durchrutscht, wird gerechnet statt gemeldet.**
Scheitert die Wiedergabe mit einem Codec- oder Dekodierfehler, baut der
Spieler den Strom genau einmal neu auf — mit `transcode: true`, an
derselben Stelle. Der Satz „über das Zahnrad lässt sich eine Fassung
wählen, die er versteht" ist weg: wer das liest, weiss weder welche, noch
warum der Server sie nicht gleich gegeben hat.

Lief schon ein gerechneter Strom, unterbleibt der zweite Versuch — dann
liegt es nicht am Codec, und dreissig Sekunden Warten auf dasselbe sind
keine Hilfe.

**Nicht geprüft:** dass der Rückfall im Betrieb auslöst. Jede Datei im
Prüfbestand spielt; ein echtes Scheitern liess sich hier nicht
herbeiführen. Gemessen ist der Serverteil (`remux` wird zu `transcode`)
und das neue Profil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 17:44:11 +02:00
co-authored by Claude Opus 5
parent e30158b84c
commit 9c2e2b5f47
7 changed files with 184 additions and 25 deletions
+2
View File
@@ -627,6 +627,8 @@ export type Stromwunsch = {
height?: number | null;
audioStream?: number | null;
startSeconds?: number;
/** Umkodieren, egal was das Profil sagt — für den zweiten Versuch. */
transcode?: boolean;
};
/** Legt einen Strom an, der sofort anfängt.
+92 -18
View File
@@ -18,34 +18,57 @@ import { icons, icon } from './icons';
*
* `canPlayType` antwortet mit `"probably"`, `"maybe"` oder `""`. Alles ausser
* der leeren Zeichenkette zählt: `"maybe"` heisst „der Codec ist bekannt, über
* das Profil sage ich nichts" — und ein Film, der vielleicht läuft, ist
* besser als einer, der sicher umgerechnet wird.
* das Profil sage ich nichts".
*
* **Und es zählt nur, wenn der MSE-Puffer mitgeht.** Gemessen in Chromium:
* `canPlayType('audio/flac')` sagt „probably", `MediaSource.isTypeSupported`
* derselben Zeichenkette sagt `false`. Ein Strom läuft über den Puffer, eine
* durchgereichte Datei über das Element — und welcher der beiden Wege es
* wird, entscheidet der Server erst danach. Also muss beides gehen.
*/
export function geraeteprofil(): {
containers: string[]; videoCodecs: string[]; audioCodecs: string[];
} {
const v = document.createElement('video');
const kann = (typ: string): boolean => v.canPlayType(typ) !== '';
const hatPuffer = typeof MediaSource !== 'undefined'
&& typeof MediaSource.isTypeSupported === 'function';
/** Ein Typ zählt nur, wenn **beide** Auskünfte ihn bestätigen.
*
* **Gemessen, und es ist der Grund für „kann die Spuren nicht öffnen".**
* In Chromium sagt `canPlayType('audio/flac')` „probably", und
* `MediaSource.isTypeSupported` derselben Zeichenkette sagt `false`. Für
* MP3 in einer MP4 ebenso. Der Spieler weiss vorher nicht, auf welchem Weg
* die Datei kommt — als fertige Datei im `<video>`-Element oder als Strom
* durch den MSE-Puffer —, also muss sie auf beiden Wegen gehen.
*
* Ohne Puffer (Safari auf dem iPhone) gibt es nur den einen Weg, und dort
* stimmt `canPlayType` auch. */
const kann = (...typen: string[]): boolean => typen.some(t =>
v.canPlayType(t) !== '' && (!hatPuffer || MediaSource.isTypeSupported(t)));
const schachteln = ['mp4'];
if (kann('video/webm')) schachteln.push('webm');
if (kann('video/webm; codecs="vp8"', 'video/webm; codecs="vp9"')) schachteln.push('webm');
// **Jeder Typ in der Form, in der er wirklich ankommt.** Ein Codec steckt
// hier in einem Film, nicht in einer nackten Tondatei: also `audio/mp4` und
// nicht `audio/flac`. Genau dieser Unterschied entschied bei FLAC zwischen
// „geht" und „geht nicht".
const bild: string[] = [];
// H.264 kann jeder Browser, der Video kann — die Prüfung steht trotzdem da,
// weil „jeder" eine Behauptung ist und `canPlayType` eine Auskunft.
if (kann('video/mp4; codecs="avc1.640029"')) bild.push('h264', 'avc1');
if (kann('video/mp4; codecs="hvc1.1.6.L93.B0"')
|| kann('video/mp4; codecs="hev1.1.6.L93.B0"')) bild.push('hevc', 'h265', 'hvc1');
if (kann('video/mp4; codecs="hvc1.1.6.L93.B0"', 'video/mp4; codecs="hev1.1.6.L93.B0"')) {
bild.push('hevc', 'h265', 'hvc1');
}
if (kann('video/webm; codecs="vp9"')) bild.push('vp9');
if (kann('video/mp4; codecs="av01.0.05M.08"')) bild.push('av1');
const ton: string[] = [];
if (kann('audio/mp4; codecs="mp4a.40.2"')) ton.push('aac');
if (kann('audio/mpeg')) ton.push('mp3');
if (kann('audio/mp4; codecs="mp3"')) ton.push('mp3');
if (kann('audio/mp4; codecs="ac-3"')) ton.push('ac3');
if (kann('audio/mp4; codecs="ec-3"')) ton.push('eac3');
if (kann('audio/ogg; codecs="opus"') || kann('audio/webm; codecs="opus"')) ton.push('opus');
if (kann('audio/flac')) ton.push('flac');
if (kann('audio/mp4; codecs="opus"', 'audio/webm; codecs="opus"')) ton.push('opus');
if (kann('audio/mp4; codecs="flac"')) ton.push('flac');
// **Eine leere Liste wäre schlimmer als eine ungenaue.** Der Server hielte
// sie für „kein Profil mitgeschickt" und fiele auf Apples zurück — also
@@ -127,6 +150,15 @@ export function spieler(anfrage: Spieleranfrage): void {
let tonspur: number | null = null;
let untertitel: number | null = null;
/** Ob der Server umkodieren soll, egal was das Profil sagt.
*
* **Das Urteil des Browsers schlägt seine eigene Auskunft.** `canPlayType`
* ist eine Behauptung — „kenne ich" heisst nicht „kann ich diese Datei".
* Scheitert die Wiedergabe trotzdem, wird einmal umgerechnet statt den
* Benutzer ans Zahnrad zu schicken. Wer eine Fassung von Hand wählen soll,
* weiss selten, welche. */
let umkodierenErzwingen = false;
const filmzeit = (): number => vorlauf + (Number.isFinite(video.currentTime) ? video.currentTime : 0);
/* ---------- Aufbau ---------- */
@@ -331,7 +363,10 @@ export function spieler(anfrage: Spieleranfrage): void {
video.onplaying = () => { dreher.classList.add('hidden'); verbergenPlanen(); };
video.ontimeupdate = () => { zeitAuffrischen(); melden(); };
video.onended = () => { melden(true, true); };
video.onerror = () => zeigeFehler(videofehler());
video.onerror = () => {
if (nochmalMitUmkodieren(video.error?.code)) return;
zeigeFehler(videofehler());
};
zeitleiste.oninput = () => { stand.textContent = dauer(Number(zeitleiste.value)); };
zeitleiste.onchange = () => void springen(Number(zeitleiste.value));
@@ -422,6 +457,7 @@ export function spieler(anfrage: Spieleranfrage): void {
height: stufe,
audioStream: tonspur,
startSeconds: ab,
transcode: umkodierenErzwingen,
});
} catch (fehler) {
zeigeFehler(meldung(fehler));
@@ -472,6 +508,10 @@ export function spieler(anfrage: Spieleranfrage): void {
});
hls.on(Hls.Events.ERROR, (_: unknown, daten: { fatal: boolean; type: string }) => {
if (!daten.fatal) return;
// Dasselbe wie beim `<video>`: ein Medienfehler heisst, dass der
// Browser mit dem Bild nicht zurechtkam. Dann wird gerechnet, nicht
// gemeldet.
if (daten.type === Hls.ErrorTypes.MEDIA_ERROR && nochmalMitUmkodieren()) return;
zeigeFehler(hlsfehler(daten.type, Hls.ErrorTypes));
});
hls.loadSource(auskunft.url);
@@ -589,6 +629,38 @@ export function spieler(anfrage: Spieleranfrage): void {
/* ---------- Fehler ---------- */
/** Zweiter Versuch, diesmal gerechnet.
*
* **Der Benutzer soll nicht raten müssen.** Hier stand „über das Zahnrad
* lässt sich eine Fassung wählen, die er versteht" — und wer das liest,
* weiss weder, welche das wäre, noch warum der Server sie ihm nicht gleich
* gegeben hat. Er hat recht: das ist die Aufgabe des Servers.
*
* **Genau einmal.** Scheitert auch der gerechnete Strom, liegt es nicht am
* Codec, und ein dritter Anlauf wäre eine Schleife statt einer Lösung.
*
* Gibt `true` zurück, wenn der Versuch läuft — dann bleibt die Meldung aus.
*/
function nochmalMitUmkodieren(code?: number): boolean {
const kannAmCodecLiegen = code === undefined
|| code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|| code === MediaError.MEDIA_ERR_DECODE;
if (!kannAmCodecLiegen || umkodierenErzwingen || geschlossen) return false;
// **Lief schon ein gerechneter Strom, hilft ein zweiter nicht.** Dann
// liegt es nicht am Codec, und dreissig Sekunden zu warten, um dasselbe
// noch einmal zu bekommen, ist keine Hilfe, sondern eine Verzögerung.
if (auskunft?.method === 'transcode') return false;
umkodierenErzwingen = true;
meldungsfeld.classList.add('hidden');
dreher.classList.remove('hidden');
// Die Stelle, an der es hing — nicht von vorn.
void stromAufbauen(filmzeit());
return true;
}
function zeigeFehler(text: string): void {
dreher.classList.add('hidden');
meldungsfeld.textContent = text;
@@ -606,11 +678,12 @@ export function spieler(anfrage: Spieleranfrage): void {
case MediaError.MEDIA_ERR_NETWORK:
return 'Die Verbindung zum Server ist abgerissen. Läuft er noch?';
case MediaError.MEDIA_ERR_DECODE:
return 'Der Browser konnte das Bild nicht dekodieren. '
+ 'Versuche eine kleinere Auflösung über das Zahnrad.';
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return 'Dieser Browser kann die Spuren dieser Datei nicht öffnen. '
+ 'Über das Zahnrad lässt sich eine Fassung wählen, die er versteht.';
// Hierher kommt nur, wer den gerechneten Strom auch nicht öffnen
// konnte — der erste Versuch wird ohne Rückfrage wiederholt.
return 'Dieser Browser kommt mit dem Bild nicht zurecht, auch '
+ 'umgerechnet nicht. Eine kleinere Auflösung über das Zahnrad hilft '
+ 'vielleicht; sonst lade den Film herunter.';
default:
return 'Die Wiedergabe ist gescheitert. Die Einzelheiten stehen im Protokoll von heute.';
}
@@ -622,8 +695,9 @@ export function spieler(anfrage: Spieleranfrage): void {
+ 'oder der Server hat die Sitzung aufgeräumt — fang die Wiedergabe neu an.';
}
if (art === arten.MEDIA_ERROR) {
return 'Der Browser kam mit dem Bild nicht zurecht. '
+ 'Versuche eine kleinere Auflösung über das Zahnrad.';
return 'Der Browser kommt mit dem Bild nicht zurecht, auch umgerechnet '
+ 'nicht. Eine kleinere Auflösung über das Zahnrad hilft vielleicht; '
+ 'sonst lade den Film herunter.';
}
return 'Die Wiedergabe ist gescheitert. Die Einzelheiten stehen im Protokoll von heute.';
}