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:
co-authored by
Claude Opus 5
parent
327e2e9195
commit
6dc7be9822
@@ -747,6 +747,16 @@ export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, u
|
||||
anfrage<void>(`/${art}/${id}/artwork`,
|
||||
{ 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
|
||||
* mitsamt der Grenzmarkierung selbst, und von Hand gesetzt wäre er falsch. */
|
||||
export const bildHochladen = async (
|
||||
|
||||
+73
-10
@@ -1,14 +1,28 @@
|
||||
import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api';
|
||||
import { knopf, fehlerzeile, meldung } from './util';
|
||||
import {
|
||||
bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, type Bildkandidat,
|
||||
} from './api';
|
||||
import { knopf, fehlerzeile, meldung, hinweisZeigen } from './util';
|
||||
import { icons } from './icons';
|
||||
import { beimSeitenwechselSchliessen } from './dialog';
|
||||
import { zuschneiden } from './bildzuschnitt';
|
||||
|
||||
type Bildart = 'poster' | 'backdrop' | 'logo';
|
||||
|
||||
const ARTEN: { art: Bildart; titel: string; verhaeltnis: string }[] = [
|
||||
{ art: 'poster', titel: 'Plakat', verhaeltnis: '2 / 3' },
|
||||
{ art: 'backdrop', titel: 'Hintergrund', verhaeltnis: '16 / 9' },
|
||||
{ art: 'logo', titel: 'Schriftzug', verhaeltnis: '16 / 9' },
|
||||
/** Was jede Bildart ist — und in welcher Form sie hochgeladen wird.
|
||||
*
|
||||
* **`zuschnitt: null` beim Schriftzug, und das ist kein Versehen.** Ein
|
||||
* 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.
|
||||
@@ -122,10 +136,13 @@ export function bilderflaeche(
|
||||
|
||||
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
|
||||
: bildart === 'backdrop' ? auswahl.current.backdrop
|
||||
: 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);
|
||||
|
||||
@@ -148,7 +165,8 @@ export function bilderflaeche(
|
||||
const kopfzeile = document.createElement('div');
|
||||
kopfzeile.className = 'knopfreihe';
|
||||
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);
|
||||
|
||||
// Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil
|
||||
@@ -162,15 +180,60 @@ export function bilderflaeche(
|
||||
eigenes.onclick = () => feld.click();
|
||||
feld.onchange = async () => {
|
||||
const datei = feld.files?.[0];
|
||||
feld.value = ''; // Dieselbe Datei zweimal soll wieder auslösen.
|
||||
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;
|
||||
try { await bildHochladen(art, id, bildart, datei); await laden(); }
|
||||
catch (fehler) {
|
||||
try {
|
||||
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)));
|
||||
eigenes.disabled = false;
|
||||
}
|
||||
};
|
||||
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);
|
||||
|
||||
if (passende.length === 0 && !aktuell) {
|
||||
|
||||
@@ -2,19 +2,34 @@ import { knopf } from './util';
|
||||
import { icons } from './icons';
|
||||
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
|
||||
* quadratisch — ein Foto ist hochkant, ein Ausschnitt quer. Der Browser
|
||||
* beschnitt es mit `object-fit: cover` von selbst: mittig, und das trifft bei
|
||||
* einem Porträt den Hals. Wer sein Bild aussucht, soll auch aussuchen, welcher
|
||||
* Teil davon zu sehen ist.
|
||||
* **Weil ein Bild eine Form hat.** Ein Profilbild ist rund, ein Plakat steht
|
||||
* hochkant im Verhältnis 2∶3, ein Hintergrund liegt quer. Was hochgeladen
|
||||
* wird, passt selten von selbst — und der Browser beschnitt es mit
|
||||
* `object-fit: cover` mittig, was bei einem Porträt den Hals trifft. Wer ein
|
||||
* Bild aussucht, soll auch aussuchen, welcher Teil davon zu sehen ist.
|
||||
*
|
||||
* **Im Browser gerechnet, nicht auf dem Server.** Hochgeladen wird genau der
|
||||
* Ausschnitt — ein Quadrat von 512 Pixeln. Das spart die Leitung, den Platz
|
||||
* und dem Server eine Bildbibliothek, die er sonst nirgends braucht.
|
||||
* Ausschnitt. Das spart die Leitung, den Platz und dem Server eine
|
||||
* 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 => {
|
||||
const bild = new Image();
|
||||
const adresse = URL.createObjectURL(datei);
|
||||
@@ -31,7 +46,7 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'dlg-kopf';
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Ausschnitt wählen';
|
||||
h.textContent = o.titel ?? 'Ausschnitt wählen';
|
||||
const zu = document.createElement('button');
|
||||
zu.className = 'dlg-zu';
|
||||
zu.innerHTML = icons.schliessen;
|
||||
@@ -42,12 +57,17 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
|
||||
const buehne = document.createElement('div');
|
||||
buehne.className = 'zuschnitt-buehne';
|
||||
const leinwand = document.createElement('canvas');
|
||||
// Die Vorschau ist so gross, wie sie dasteht; gerechnet wird am Ende
|
||||
// auf 512 — zweimal dieselbe Zahl wäre eine zum Verwechseln.
|
||||
const SEITE = 320;
|
||||
leinwand.width = SEITE;
|
||||
leinwand.height = SEITE;
|
||||
leinwand.className = 'zuschnitt-feld';
|
||||
// **Die Vorschau passt in ein Quadrat von 320**, egal welches Verhältnis
|
||||
// — ein Plakat wird hoch und schmal, ein Hintergrund breit und flach,
|
||||
// und beides bleibt im Dialog.
|
||||
const LANG = 320;
|
||||
const feldB = o.verhaeltnis >= 1 ? LANG : Math.round(LANG * o.verhaeltnis);
|
||||
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);
|
||||
|
||||
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
|
||||
// Quadrat; alles darüber ist Vergrösserung. Damit ist der Ausschnitt
|
||||
// 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 x = 0;
|
||||
let y = 0;
|
||||
@@ -91,15 +111,15 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
|
||||
const breite = bild.width * grund * faktor;
|
||||
const hoehe = bild.height * grund * faktor;
|
||||
// Keine Lücke am Rand: der Ausschnitt bleibt im Bild.
|
||||
x = Math.min(0, Math.max(SEITE - breite, x));
|
||||
y = Math.min(0, Math.max(SEITE - hoehe, y));
|
||||
x = Math.min(0, Math.max(feldB - breite, x));
|
||||
y = Math.min(0, Math.max(feldH - hoehe, y));
|
||||
}
|
||||
|
||||
function zeichnen(): void {
|
||||
const stift = leinwand.getContext('2d');
|
||||
if (!stift) return;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -109,9 +129,10 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
|
||||
const neu = Number(regler.value) / 100;
|
||||
// Um die Mitte vergrössern, nicht um die linke obere Ecke — sonst
|
||||
// wandert das Motiv beim Ziehen am Regler aus dem Bild.
|
||||
const mitte = SEITE / 2;
|
||||
x = mitte - (mitte - x) * (neu / faktor);
|
||||
y = mitte - (mitte - y) * (neu / faktor);
|
||||
const mx = feldB / 2;
|
||||
const my = feldH / 2;
|
||||
x = mx - (mx - x) * (neu / faktor);
|
||||
y = my - (my - y) * (neu / faktor);
|
||||
faktor = neu;
|
||||
zeichnen();
|
||||
};
|
||||
@@ -138,14 +159,13 @@ export function zuschneiden(datei: File): Promise<Blob | null> {
|
||||
let ergebnis: Blob | null = null;
|
||||
|
||||
nehmen.onclick = () => {
|
||||
const ZIEL = 512;
|
||||
const aus = document.createElement('canvas');
|
||||
aus.width = ZIEL;
|
||||
aus.height = ZIEL;
|
||||
aus.width = o.breite;
|
||||
aus.height = Math.round(o.breite / o.verhaeltnis);
|
||||
const stift = aus.getContext('2d');
|
||||
if (!stift) { d.close(); return; }
|
||||
|
||||
const massstab = ZIEL / SEITE;
|
||||
const massstab = o.breite / feldB;
|
||||
stift.drawImage(
|
||||
bild,
|
||||
x * massstab, y * massstab,
|
||||
|
||||
@@ -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-buehne { display: flex; justify-content: center; }
|
||||
.zuschnitt-feld {
|
||||
width: 320px; height: 320px; max-width: 100%;
|
||||
border-radius: 50%; background: var(--placeholder);
|
||||
max-width: 100%;
|
||||
border-radius: var(--r-karte); background: var(--placeholder);
|
||||
cursor: grab; touch-action: none;
|
||||
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; }
|
||||
|
||||
/* --- Kontextmenü ---------------------------------------------------------
|
||||
|
||||
@@ -104,7 +104,9 @@ export function kontoseite(): HTMLElement {
|
||||
// **Erst zuschneiden, dann hochladen.** Ein Profilbild ist rund; was
|
||||
// hochgeladen wird, ist selten quadratisch. Den Ausschnitt dem Browser zu
|
||||
// ü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.
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user