Ein eigenes Bild bringt auch seinen Ausschnitt mit

Hochladen konnte man Plakat, Hintergrund und Schriftzug schon lange — der
Knopf stand da, der Endpunkt nahm an. Nur ankommen tat nichts: die Ablage
baute einen Schlüssel, den sie selbst nicht wiederfand (heute früh
repariert). Was blieb, waren drei Lücken, die man erst sieht, wenn der Weg
überhaupt funktioniert.

**Der Ausschnitt.** Ein Plakat steht hochkant im Verhältnis 2∶3, ein
Hintergrund liegt quer. Was man aussucht, passt selten von selbst — und
den Rest beschnitt bisher der Browser beim Anzeigen: mittig. Jetzt wählt
man selbst, zieht und vergrössert, und hochgeladen wird genau dieser
Ausschnitt: 600×900 beim Plakat, 1920×1080 beim Hintergrund. Gerechnet
wird im Browser; das spart die Leitung und dem Server eine Bildbibliothek.

**Der Schriftzug nicht.** Er ist ein PNG mit durchsichtigem Grund. Durch
dieselbe Mühle geschickt würde aus der Durchsicht ein weisses Rechteck —
ein Aufkleber über dem Bild. Er geht unverändert hinaus, Byte für Byte.

**Die Kachel merkte nichts.** Nach dem Hochladen fehlte der Anstoss nach
aussen; die Oberfläche zeigte weiter das alte Bild, bis man sie neu lud.

**Und zurück ging es gar nicht.** „Von Hand gesetzt" heisst auch: beim
Auffrischen bleibt es stehen. Ohne Rückweg wäre ein Fehlgriff ein
Dauerzustand — jetzt steht „Wieder automatisch" daneben, sobald etwas
festgesetzt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 19:41:04 +02:00
co-authored by Claude Opus 5
parent 327e2e9195
commit 6dc7be9822
5 changed files with 137 additions and 40 deletions
+10
View File
@@ -747,6 +747,16 @@ export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, u
anfrage<void>(`/${art}/${id}/artwork`, anfrage<void>(`/${art}/${id}/artwork`,
{ method: 'PUT', body: JSON.stringify({ kind, url }) }); { method: 'PUT', body: JSON.stringify({ kind, url }) });
/** Nimmt ein von Hand gesetztes Bild wieder weg.
*
* **Damit ein Fehlgriff kein Dauerzustand ist.** Ein hochgeladenes Bild war
* sonst endgültig: es stand fest und kam beim Auffrischen auch nicht zurück,
* weil „von Hand gesetzt" genau das bedeutet. Ohne Adresse setzt der Server
* beides zurück — das Bild und die Festlegung. */
export const bildFreigeben = (art: 'movies' | 'series', id: string, kind: string) =>
anfrage<void>(`/${art}/${id}/artwork`,
{ method: 'PUT', body: JSON.stringify({ kind, url: '' }) });
/** Ein eigenes Bild hochladen. Ohne `Content-Type` — den setzt der Browser /** Ein eigenes Bild hochladen. Ohne `Content-Type` — den setzt der Browser
* mitsamt der Grenzmarkierung selbst, und von Hand gesetzt wäre er falsch. */ * mitsamt der Grenzmarkierung selbst, und von Hand gesetzt wäre er falsch. */
export const bildHochladen = async ( export const bildHochladen = async (
+73 -10
View File
@@ -1,14 +1,28 @@
import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api'; import {
import { knopf, fehlerzeile, meldung } from './util'; bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, type Bildkandidat,
} from './api';
import { knopf, fehlerzeile, meldung, hinweisZeigen } from './util';
import { icons } from './icons'; import { icons } from './icons';
import { beimSeitenwechselSchliessen } from './dialog'; import { beimSeitenwechselSchliessen } from './dialog';
import { zuschneiden } from './bildzuschnitt';
type Bildart = 'poster' | 'backdrop' | 'logo'; type Bildart = 'poster' | 'backdrop' | 'logo';
const ARTEN: { art: Bildart; titel: string; verhaeltnis: string }[] = [ /** Was jede Bildart ist — und in welcher Form sie hochgeladen wird.
{ art: 'poster', titel: 'Plakat', verhaeltnis: '2 / 3' }, *
{ art: 'backdrop', titel: 'Hintergrund', verhaeltnis: '16 / 9' }, * **`zuschnitt: null` beim Schriftzug, und das ist kein Versehen.** Ein
{ art: 'logo', titel: 'Schriftzug', verhaeltnis: '16 / 9' }, * Schriftzug ist ein PNG mit durchsichtigem Grund; durch die Zuschneidemühle
* geschickt würde aus der Durchsicht ein weisses Rechteck, und er läge wie
* ein Aufkleber über dem Bild. Er geht unverändert hinaus. */
const ARTEN: {
art: Bildart; titel: string; verhaeltnis: string;
zuschnitt: { verhaeltnis: number; breite: number } | null;
}[] = [
{ art: 'poster', titel: 'Plakat', verhaeltnis: '2 / 3',
zuschnitt: { verhaeltnis: 2 / 3, breite: 600 } },
{ art: 'backdrop', titel: 'Hintergrund', verhaeltnis: '16 / 9',
zuschnitt: { verhaeltnis: 16 / 9, breite: 1920 } },
{ art: 'logo', titel: 'Schriftzug', verhaeltnis: '16 / 9', zuschnitt: null },
]; ];
/** Plakat, Hintergrund und Schriftzug von Hand wählen. /** Plakat, Hintergrund und Schriftzug von Hand wählen.
@@ -122,10 +136,13 @@ export function bilderflaeche(
const bloecke = new Map<Bildart, HTMLElement>(); const bloecke = new Map<Bildart, HTMLElement>();
ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis }) => { ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis, zuschnitt }) => {
const aktuell = bildart === 'poster' ? auswahl.current.poster const aktuell = bildart === 'poster' ? auswahl.current.poster
: bildart === 'backdrop' ? auswahl.current.backdrop : bildart === 'backdrop' ? auswahl.current.backdrop
: auswahl.current.logo; : auswahl.current.logo;
const festgesetzt = bildart === 'poster' ? auswahl.current.posterPinned
: bildart === 'backdrop' ? auswahl.current.backdropPinned
: auswahl.current.logoPinned;
const passende = auswahl.candidates.filter(k => k.kind === bildart); const passende = auswahl.candidates.filter(k => k.kind === bildart);
@@ -148,7 +165,8 @@ export function bilderflaeche(
const kopfzeile = document.createElement('div'); const kopfzeile = document.createElement('div');
kopfzeile.className = 'knopfreihe'; kopfzeile.className = 'knopfreihe';
const u = document.createElement('h3'); const u = document.createElement('h3');
u.textContent = aktuell ? `${name} — eines ist gesetzt` : name; u.textContent = festgesetzt ? `${name} — von Hand gesetzt`
: aktuell ? `${name} — eines ist gesetzt` : name;
kopfzeile.appendChild(u); kopfzeile.appendChild(u);
// Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil // Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil
@@ -162,15 +180,60 @@ export function bilderflaeche(
eigenes.onclick = () => feld.click(); eigenes.onclick = () => feld.click();
feld.onchange = async () => { feld.onchange = async () => {
const datei = feld.files?.[0]; const datei = feld.files?.[0];
feld.value = ''; // Dieselbe Datei zweimal soll wieder auslösen.
if (!datei) return; if (!datei) return;
// **Erst zuschneiden, dann hochladen** — ein Plakat steht hochkant,
// ein Hintergrund liegt quer. Was nicht passt, beschnitt sonst der
// Browser beim Anzeigen: mittig, und das trifft selten.
let inhalt: Blob = datei;
if (zuschnitt) {
const ausschnitt = await zuschneiden(datei, {
...zuschnitt, titel: `${name} zuschneiden`,
});
if (!ausschnitt) return; // Abgebrochen — das ist keine Meldung wert.
inhalt = ausschnitt;
}
eigenes.disabled = true; eigenes.disabled = true;
try { await bildHochladen(art, id, bildart, datei); await laden(); } try {
catch (fehler) { await bildHochladen(art, id, bildart,
new File([inhalt], zuschnitt ? 'bild.jpg' : datei.name,
{ type: zuschnitt ? 'image/jpeg' : datei.type }));
// **Auch das zählt als Änderung.** Hier stand nichts, und die
// Kachel zeigte danach das alte Bild weiter — bis man die ganze
// Oberfläche neu lud.
beiAenderung?.();
hinweisZeigen(`${name} gesetzt.`);
await laden();
} catch (fehler) {
abschnitt.appendChild(fehlerzeile(meldung(fehler))); abschnitt.appendChild(fehlerzeile(meldung(fehler)));
eigenes.disabled = false; eigenes.disabled = false;
} }
}; };
kopfzeile.append(eigenes, feld); kopfzeile.append(eigenes, feld);
// **Nur, wenn es etwas zurückzunehmen gibt.** „Von Hand gesetzt" heisst
// auch: beim Auffrischen bleibt es stehen. Ohne diesen Knopf wäre ein
// Fehlgriff ein Dauerzustand.
if (festgesetzt) {
const frei = knopf('Wieder automatisch', 'btn');
frei.type = 'button';
frei.onclick = async () => {
frei.disabled = true;
try {
await bildFreigeben(art, id, bildart);
beiAenderung?.();
hinweisZeigen('Kommt beim nächsten Auffrischen wieder aus der Quelle.');
await laden();
} catch (fehler) {
abschnitt.appendChild(fehlerzeile(meldung(fehler)));
frei.disabled = false;
}
};
kopfzeile.appendChild(frei);
}
abschnitt.appendChild(kopfzeile); abschnitt.appendChild(kopfzeile);
if (passende.length === 0 && !aktuell) { if (passende.length === 0 && !aktuell) {
+47 -27
View File
@@ -2,19 +2,34 @@ import { knopf } from './util';
import { icons } from './icons'; import { icons } from './icons';
import { beimSeitenwechselSchliessen } from './dialog'; import { beimSeitenwechselSchliessen } from './dialog';
/** Schneidet ein Bild auf ein Quadrat zu, bevor es hochgeladen wird. /** Was für ein Ausschnitt gebraucht wird. */
export type Zuschnitt = {
/** Breite geteilt durch Höhe. 1 für ein Quadrat, 2/3 für ein Plakat. */
verhaeltnis: number;
/** Wie breit das Ergebnis wird; die Höhe folgt aus dem Verhältnis. */
breite: number;
/** Rund gezeigt — beim Profilbild, weil es rund dasteht. */
rund?: boolean;
titel?: string;
};
/** Schneidet ein Bild zu, bevor es hochgeladen wird.
* *
* **Weil ein Profilbild rund ist.** Was hochgeladen wird, ist selten * **Weil ein Bild eine Form hat.** Ein Profilbild ist rund, ein Plakat steht
* quadratisch — ein Foto ist hochkant, ein Ausschnitt quer. Der Browser * hochkant im Verhältnis 2∶3, ein Hintergrund liegt quer. Was hochgeladen
* beschnitt es mit `object-fit: cover` von selbst: mittig, und das trifft bei * wird, passt selten von selbst — und der Browser beschnitt es mit
* einem Porträt den Hals. Wer sein Bild aussucht, soll auch aussuchen, welcher * `object-fit: cover` mittig, was bei einem Porträt den Hals trifft. Wer ein
* Teil davon zu sehen ist. * Bild aussucht, soll auch aussuchen, welcher Teil davon zu sehen ist.
* *
* **Im Browser gerechnet, nicht auf dem Server.** Hochgeladen wird genau der * **Im Browser gerechnet, nicht auf dem Server.** Hochgeladen wird genau der
* Ausschnitt — ein Quadrat von 512 Pixeln. Das spart die Leitung, den Platz * Ausschnitt. Das spart die Leitung, den Platz und dem Server eine
* und dem Server eine Bildbibliothek, die er sonst nirgends braucht. * Bildbibliothek, die er sonst nirgends braucht.
*
* **Nicht für Schriftzüge.** Die liegen als PNG mit durchsichtigem Grund vor;
* sie durch diese Mühle zu schicken hiesse, aus der Durchsicht ein weisses
* Rechteck zu machen. Sie gehen unverändert hinaus.
*/ */
export function zuschneiden(datei: File): Promise<Blob | null> { export function zuschneiden(datei: File, o: Zuschnitt): Promise<Blob | null> {
return new Promise(fertig => { return new Promise(fertig => {
const bild = new Image(); const bild = new Image();
const adresse = URL.createObjectURL(datei); const adresse = URL.createObjectURL(datei);
@@ -31,7 +46,7 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
const kopf = document.createElement('div'); const kopf = document.createElement('div');
kopf.className = 'dlg-kopf'; kopf.className = 'dlg-kopf';
const h = document.createElement('h2'); const h = document.createElement('h2');
h.textContent = 'Ausschnitt wählen'; h.textContent = o.titel ?? 'Ausschnitt wählen';
const zu = document.createElement('button'); const zu = document.createElement('button');
zu.className = 'dlg-zu'; zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen; zu.innerHTML = icons.schliessen;
@@ -42,12 +57,17 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
const buehne = document.createElement('div'); const buehne = document.createElement('div');
buehne.className = 'zuschnitt-buehne'; buehne.className = 'zuschnitt-buehne';
const leinwand = document.createElement('canvas'); const leinwand = document.createElement('canvas');
// Die Vorschau ist so gross, wie sie dasteht; gerechnet wird am Ende // **Die Vorschau passt in ein Quadrat von 320**, egal welches Verhältnis
// auf 512 — zweimal dieselbe Zahl wäre eine zum Verwechseln. // — ein Plakat wird hoch und schmal, ein Hintergrund breit und flach,
const SEITE = 320; // und beides bleibt im Dialog.
leinwand.width = SEITE; const LANG = 320;
leinwand.height = SEITE; const feldB = o.verhaeltnis >= 1 ? LANG : Math.round(LANG * o.verhaeltnis);
leinwand.className = 'zuschnitt-feld'; const feldH = o.verhaeltnis >= 1 ? Math.round(LANG / o.verhaeltnis) : LANG;
leinwand.width = feldB;
leinwand.height = feldH;
leinwand.className = 'zuschnitt-feld' + (o.rund ? ' zuschnitt-rund' : '');
leinwand.style.width = `${feldB}px`;
leinwand.style.height = `${feldH}px`;
buehne.appendChild(leinwand); buehne.appendChild(leinwand);
const regler = document.createElement('input'); const regler = document.createElement('input');
@@ -82,7 +102,7 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
// **Der Nullpunkt ist „ganz drin".** Die kleinere Seite füllt das // **Der Nullpunkt ist „ganz drin".** Die kleinere Seite füllt das
// Quadrat; alles darüber ist Vergrösserung. Damit ist der Ausschnitt // Quadrat; alles darüber ist Vergrösserung. Damit ist der Ausschnitt
// am Anfang immer gültig, egal wie das Bild liegt. // am Anfang immer gültig, egal wie das Bild liegt.
const grund = Math.max(SEITE / bild.width, SEITE / bild.height); const grund = Math.max(feldB / bild.width, feldH / bild.height);
let faktor = 1; let faktor = 1;
let x = 0; let x = 0;
let y = 0; let y = 0;
@@ -91,15 +111,15 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
const breite = bild.width * grund * faktor; const breite = bild.width * grund * faktor;
const hoehe = bild.height * grund * faktor; const hoehe = bild.height * grund * faktor;
// Keine Lücke am Rand: der Ausschnitt bleibt im Bild. // Keine Lücke am Rand: der Ausschnitt bleibt im Bild.
x = Math.min(0, Math.max(SEITE - breite, x)); x = Math.min(0, Math.max(feldB - breite, x));
y = Math.min(0, Math.max(SEITE - hoehe, y)); y = Math.min(0, Math.max(feldH - hoehe, y));
} }
function zeichnen(): void { function zeichnen(): void {
const stift = leinwand.getContext('2d'); const stift = leinwand.getContext('2d');
if (!stift) return; if (!stift) return;
begrenzen(); begrenzen();
stift.clearRect(0, 0, SEITE, SEITE); stift.clearRect(0, 0, feldB, feldH);
stift.drawImage(bild, x, y, bild.width * grund * faktor, bild.height * grund * faktor); stift.drawImage(bild, x, y, bild.width * grund * faktor, bild.height * grund * faktor);
} }
@@ -109,9 +129,10 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
const neu = Number(regler.value) / 100; const neu = Number(regler.value) / 100;
// Um die Mitte vergrössern, nicht um die linke obere Ecke — sonst // Um die Mitte vergrössern, nicht um die linke obere Ecke — sonst
// wandert das Motiv beim Ziehen am Regler aus dem Bild. // wandert das Motiv beim Ziehen am Regler aus dem Bild.
const mitte = SEITE / 2; const mx = feldB / 2;
x = mitte - (mitte - x) * (neu / faktor); const my = feldH / 2;
y = mitte - (mitte - y) * (neu / faktor); x = mx - (mx - x) * (neu / faktor);
y = my - (my - y) * (neu / faktor);
faktor = neu; faktor = neu;
zeichnen(); zeichnen();
}; };
@@ -138,14 +159,13 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
let ergebnis: Blob | null = null; let ergebnis: Blob | null = null;
nehmen.onclick = () => { nehmen.onclick = () => {
const ZIEL = 512;
const aus = document.createElement('canvas'); const aus = document.createElement('canvas');
aus.width = ZIEL; aus.width = o.breite;
aus.height = ZIEL; aus.height = Math.round(o.breite / o.verhaeltnis);
const stift = aus.getContext('2d'); const stift = aus.getContext('2d');
if (!stift) { d.close(); return; } if (!stift) { d.close(); return; }
const massstab = ZIEL / SEITE; const massstab = o.breite / feldB;
stift.drawImage( stift.drawImage(
bild, bild,
x * massstab, y * massstab, x * massstab, y * massstab,
+4 -2
View File
@@ -1409,11 +1409,13 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
.zuschnitt-dlg .dlg-koerper { display: flex; flex-direction: column; gap: var(--s-loose); } .zuschnitt-dlg .dlg-koerper { display: flex; flex-direction: column; gap: var(--s-loose); }
.zuschnitt-buehne { display: flex; justify-content: center; } .zuschnitt-buehne { display: flex; justify-content: center; }
.zuschnitt-feld { .zuschnitt-feld {
width: 320px; height: 320px; max-width: 100%; max-width: 100%;
border-radius: 50%; background: var(--placeholder); border-radius: var(--r-karte); background: var(--placeholder);
cursor: grab; touch-action: none; cursor: grab; touch-action: none;
box-shadow: 0 0 0 1px var(--line); box-shadow: 0 0 0 1px var(--line);
} }
/* Rund nur, wo es rund dasteht — beim Profilbild. */
.zuschnitt-feld.zuschnitt-rund { border-radius: 50%; }
.zuschnitt-feld:active { cursor: grabbing; } .zuschnitt-feld:active { cursor: grabbing; }
/* --- Kontextmenü --------------------------------------------------------- /* --- Kontextmenü ---------------------------------------------------------
+3 -1
View File
@@ -104,7 +104,9 @@ export function kontoseite(): HTMLElement {
// **Erst zuschneiden, dann hochladen.** Ein Profilbild ist rund; was // **Erst zuschneiden, dann hochladen.** Ein Profilbild ist rund; was
// hochgeladen wird, ist selten quadratisch. Den Ausschnitt dem Browser zu // hochgeladen wird, ist selten quadratisch. Den Ausschnitt dem Browser zu
// überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals. // überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals.
const ausschnitt = await zuschneiden(datei); const ausschnitt = await zuschneiden(datei, {
verhaeltnis: 1, breite: 512, rund: true, titel: 'Profilbild zuschneiden',
});
if (!ausschnitt) return; // Abgebrochen — das ist keine Meldung wert. if (!ausschnitt) return; // Abgebrochen — das ist keine Meldung wert.
try { try {