Compare commits

...

2 commits

Author SHA1 Message Date
Eddy
85a64c237a Kamera: Blitz (Aus/Auto/An/Licht) und Scharfstellen per Antippen [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s
Eddy: „beim Fotografieren kann man den Blitz nicht einstellen und den Fokus auch nicht".
Knopf oben schaltet reihum, was das Gerät meldet (fillLightMode über takePhoto, Dauerlicht über
torch); Auswahl bleibt gemerkt. Antippen stellt an der Stelle scharf (pointsOfInterest +
focusMode single-shot) und hält den Fokus, „Fokus fest · zurück auf Auto" löst ihn wieder.
Ohne gemeldete Fähigkeiten (Frontkamera, Tablet-Webcam) bleibt alles wie bisher.
Lokal mit nachgebildeter Kamera geprüft, Gegenprobe am Handy steht in ROADMAP Punkt 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:09:35 +02:00
Eddy
91f3f483b5 Verkleinerte Fotos heißen .jpg, Transparenz wird weiß statt schwarz
Ist eine Maximalgröße eingestellt, ersetzte das im Browser verkleinerte JPEG den Inhalt in der
Queue, der Name blieb aber z. B. „.avif"/„.webp" — der Server lehnte das ab. replaceQueuedBlob()
zieht die Endung jetzt auf .jpg nach (Galerie, Teilen, Kamera). Beim Verkleinern wird vor dem
Zeichnen weiß gefüllt, sonst wurden transparente Flächen im JPEG schwarz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:09:35 +02:00
5 changed files with 209 additions and 8 deletions

View file

@ -23,6 +23,8 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
**Foto-Upload**
- ✅ Live-Kamera-Modal (getUserMedia): bleibt für Serien-Aufnahmen offen — Auslöser + Filmstreifen mit Upload-Status je Foto (💾 gesichert → ⏳ lädt hoch → ✓ fertig), Front-/Rückkamera-Umschaltung, Fehlschuss löschbar
- ✅ **Volle Kameraauflösung**: ausgelöst wird über `ImageCapture.takePhoto()` (native JPEG der Kamera inkl. EXIF) — ein Standbild aus dem Video-Stream hat nur Stream-Auflösung und ist nur noch die Rückfallebene (iOS/Safari, ältere Browser)
- ✅ **Blitz und Fokus** (19.10.2026): Knopf oben schaltet reihum Aus → Auto → An → 🔦 Licht (nur was das Gerät meldet, Auswahl bleibt gemerkt; Blitz wirkt über `takePhoto({fillLightMode})`). Antippen stellt an der Stelle scharf und hält den Fokus („🎯 Fokus fest · zurück auf Auto“). Frontkamera/Tablet-Webcam ohne diese Fähigkeiten: kein Knopf, Antippen ohne Wirkung
- ✅ **Verkleinerte Fotos heißen `.jpg`** und transparente Bilder bekommen beim Verkleinern weißen Grund (vorher behielt ein verkleinertes AVIF/WebP seinen Namen und wurde vom Server abgelehnt)
- ✅ Alternativ Aufnahme aus der Galerie (Mehrfachauswahl: alle Dateien werden sofort nach der Auswahl eingelesen, erst dann hochgeladen, weil Android die Galerie-Dateien nur kurz lesbar hält, siehe ROADMAP Punkt 7)
- ✅ **Fotoqualität im Admin einstellbar** (Bericht-Modul → Fotoqualität, `api/config.php`): Vorgabe ist **1:1, gar nicht verkleinern**; optional längste Seite + JPEG-Qualität. Gesichert wird immer zuerst das Original, die kleine Fassung ersetzt es danach — hängt das Verkleinern (Display aus), bleibt das Original
- ✅ **Wake Lock** während Foto-Serie und Upload: schaltet sich das Display ab, friert der Browser laufende Uploads und das Verkleinern ein

View file

@ -47,10 +47,29 @@ Aufträgen: Geteiltes ablegen"*.
klebt am unteren Rand, am Ende lässt sich alles darüber scrollen.
- [ ] Am Handy gegenprüfen: Knopf ist nach dem Update weg (beim ersten Laden räumt die App den leeren Rest ab).
### Befunde aus der AVIF-Prüfung in Bericht (19.10.2026, nicht geändert)
- [ ] Ist eine Maximalgröße für Fotos gesetzt (`photo_maxside` > 0), behält das im Browser verkleinerte JPEG in
`app.js` `uploadPhoto` seinen Originalnamen — ein `.avif`/`.webp` wird dann vom Server abgelehnt.
- [ ] Beim Verkleinern im Browser (Canvas → JPEG) wird Transparenz schwarz.
### Befunde aus der AVIF-Prüfung in Bericht (19.10.2026)
- [x] ✅ 19.10.2026: Ist eine Maximalgröße für Fotos gesetzt (`photo_maxside` > 0), behielt das im Browser
verkleinerte JPEG seinen Originalnamen — ein `.avif`/`.webp` lehnte der Server ab. Jetzt zieht
`offline.replaceQueuedBlob()` die Endung auf `.jpg` nach (gilt für Galerie, Teilen und Kamera).
- [x] ✅ 19.10.2026: Beim Verkleinern im Browser (Canvas → JPEG) wurde Transparenz schwarz — jetzt weißer Grund.
Lokal geprüft: Queue-Eintrag „bild.avif" (PNG mit Transparenz) → „bild.jpg", Ecke weiß.
## 9. Kamera: Blitz und Fokus einstellbar (gemeldet 2026-10-19)
Eddy: *„beim Fotografieren kann man den Blitz nicht einstellen und den Fokus auch nicht"*.
- [x] ✅ 19.10.2026: Blitz-Knopf in der Kopfzeile, schaltet reihum durch, was das Gerät meldet: Aus → Auto → An
(`ImageCapture.getPhotoCapabilities().fillLightMode`, wirkt beim Auslösen über `takePhoto({fillLightMode})`;
klappt das nicht, wird ohne Einstellung ausgelöst) → 🔦 Licht (Dauerlicht, `track.getCapabilities().torch`,
nach dem Auslösen erneut gesetzt). Meldet das Gerät nichts davon, gibt es keinen Knopf. Auswahl bleibt gemerkt
(`localStorage baustelle.kamera.blitz`).
- [x] ✅ 19.10.2026: Antippen stellt dort scharf (`pointsOfInterest` + `focusMode: single-shot`, sonst `continuous`;
ohne Punktwahl nur neu scharfstellen), Ring an der Stelle. Bei `single-shot` bleibt der Fokus fest, oben steht
„🎯 Fokus fest · zurück auf Auto" (Tippen → `continuous`). Einmaliger Hinweis „Antippen stellt dort scharf".
- [x] Lokal geprüft mit nachgebildeter Kamera (Canvas-Strom + gemeldete Fähigkeiten): Reihenfolge der Modi,
Licht an/aus, `takePhoto({fillLightMode:"flash"})`, Antippstelle richtig umgerechnet (eingepasste Vorschau,
0,803 statt 0,75 bei seitlichem Rand), „Fokus fest" → continuous; ohne Fähigkeiten kein Knopf, keine Wirkung.
- [ ] **Am Handy gegenprüfen** (am Schreibtisch nicht nachstellbar): blitzt „An" wirklich, geht „Licht" an,
wird nach Antippen die angetippte Stelle scharf.
## 7. Galerie-Auswahl: „permission"-Fehler ab dem 3. Foto (gemeldet 2026-09-30)

30
app.css
View file

@ -1310,6 +1310,36 @@ body {
}
.cam-shutter:active { transform: scale(0.92); }
.cam-shutter.flash { background: #ffd34d; }
/* Blitz-Knopf: Text statt nur Symbol, damit der Zustand lesbar ist (Aus/Auto/An/Licht) */
.camera-modal .cam-blitz { font-size: 15px; white-space: nowrap; }
/* Scharfstellen per Antippen */
.camera-modal .cam-fokus-ring {
position: absolute;
width: 72px;
height: 72px;
margin: -36px 0 0 -36px;
border: 2px solid #fff;
border-radius: 50%;
pointer-events: none;
opacity: 0.9;
}
.camera-modal .cam-fokus-ring.aktiv { animation: cam-fokus 0.35s ease-out; }
.camera-modal .cam-fokus-ring.ok { border-color: #ffd34d; }
@keyframes cam-fokus { from { transform: scale(1.5); opacity: 0.4; } to { transform: scale(1); opacity: 0.9; } }
.camera-modal .cam-fokus-fest {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #ffd34d;
border: 1px solid #ffd34d;
border-radius: 16px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.camera-modal [hidden] { display: none !important; }
/* ============================================================
Eigene Dialoge (Ersatz für confirm/alert/prompt)

153
app.js
View file

@ -1320,7 +1320,11 @@ async function resizeImage(file, maxSide, timeoutMs, quality) {
const c = document.createElement('canvas');
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
const ctx = c.getContext('2d');
// Weisser Grund: JPEG kennt keine Transparenz, ein freigestelltes PNG/WebP wuerde sonst schwarz
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
c.toBlob(b => resolve(b || file), 'image/jpeg', typeof quality === 'number' ? quality : 0.85);
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
@ -3613,11 +3617,14 @@ async function openCameraModal(orderId) {
<div class="fs-header">
<button class="icon-btn" id="cam-close">✕</button>
<div class="fs-title">📷 Kamera</div>
<button class="icon-btn cam-blitz" id="cam-blitz" title="Blitz" hidden></button>
<button class="icon-btn" id="cam-flip" title="Kamera wechseln">🔄</button>
</div>
<div class="cam-stage">
<div class="cam-stage" id="cam-stage">
<video id="cam-video" playsinline autoplay muted></video>
<div class="cam-hint" id="cam-hint">Kamera wird gestartet…</div>
<div class="cam-fokus-ring" id="cam-fokus-ring" hidden></div>
<button class="cam-fokus-fest" id="cam-fokus-fest" hidden>🎯 Fokus fest · zurück auf Auto</button>
</div>
<div class="cam-filmstrip" id="cam-strip"></div>
<div class="cam-controls">
@ -3633,6 +3640,10 @@ async function openCameraModal(orderId) {
const hint = modal.querySelector('#cam-hint');
const countEl = modal.querySelector('#cam-count');
const shutter = modal.querySelector('#cam-shutter');
const stage = modal.querySelector('#cam-stage');
const blitzBtn = modal.querySelector('#cam-blitz');
const fokusRing = modal.querySelector('#cam-fokus-ring');
const fokusFest = modal.querySelector('#cam-fokus-fest');
let stream = null;
let facing = 'environment';
@ -3705,6 +3716,7 @@ async function openCameraModal(orderId) {
video.srcObject = stream;
await video.play().catch(() => {});
hint.style.display = 'none';
await kameraExtras(my);
} catch (err) {
if (my !== gen || !document.body.contains(modal)) return; // abgebrochen, nicht mehr relevant
console.warn('[Kamera]', err);
@ -3763,6 +3775,132 @@ async function openCameraModal(orderId) {
}
}
/* Blitz und Fokus (Eddy, 19.10.2026: „den Blitz kann man nicht einstellen und den Fokus auch nicht").
*
* Angeboten wird nur, was das Geraet meldet — Chrome kennt es erst, wenn der Strom laeuft:
* - Blitz beim Ausloesen: ImageCapture.getPhotoCapabilities().fillLightMode (auto/flash),
* wirkt nur ueber takePhoto({ fillLightMode }) — nicht beim Standbild aus dem Sucher.
* - Licht (Dauerlicht): track.getCapabilities().torch, per applyConstraints.
* - Scharfstellen per Antippen: focusMode 'single-shot' (stellt einmal scharf und haelt den
* Fokus fest) bzw. 'continuous', jeweils mit pointsOfInterest = angetippte Stelle (0..1).
* Frontkamera, Webcam am Tablet (X12) usw. melden meist nichts davon → kein Knopf, kein Antippen.
*/
const BLITZ_TEXT = { off: '⚡ Aus', auto: '⚡ Auto', flash: '⚡ An', torch: '🔦 Licht' };
const BLITZ_KEY = 'baustelle.kamera.blitz';
let imageCap = null;
let blitzModi = [];
let blitz = 'off';
let fotoBlitz = false; // kann takePhoto() blitzen (fillLightMode)?
let fokusModus = ''; // 'single-shot' | 'continuous' | '' (kein Antippen)
let tippHinweis = false;
function videoTrack() {
return stream ? stream.getVideoTracks()[0] || null : null;
}
async function kameraExtras(my) {
imageCap = null; blitzModi = []; fotoBlitz = false; fokusModus = '';
blitzBtn.hidden = true; fokusFest.hidden = true; fokusRing.hidden = true;
const track = videoTrack();
if (!track) return;
let caps = {};
try { caps = typeof track.getCapabilities === 'function' ? (track.getCapabilities() || {}) : {}; } catch (_) {}
let licht = [];
if (typeof ImageCapture === 'function') {
try {
imageCap = new ImageCapture(track);
const pc = await imageCap.getPhotoCapabilities();
licht = Array.isArray(pc && pc.fillLightMode) ? pc.fillLightMode : [];
} catch (_) { /* kein Standbild-Erfasser — Sucherbild bleibt */ }
}
if (my !== gen) return; // inzwischen geschlossen/gewechselt
fotoBlitz = licht.includes('auto') || licht.includes('flash');
const modi = [];
if (fotoBlitz || caps.torch) modi.push('off');
if (licht.includes('auto')) modi.push('auto');
if (licht.includes('flash')) modi.push('flash');
if (caps.torch) modi.push('torch');
blitzModi = modi;
let gemerkt = 'off';
try { gemerkt = localStorage.getItem(BLITZ_KEY) || 'off'; } catch (_) {}
blitz = modi.includes(gemerkt) ? gemerkt : 'off';
if (modi.length > 1) {
blitzBtn.hidden = false;
blitzBtn.textContent = BLITZ_TEXT[blitz];
if (blitz === 'torch') lichtSchalten(true);
}
const fm = Array.isArray(caps.focusMode) ? caps.focusMode : [];
fokusModus = fm.includes('single-shot') ? 'single-shot' : (fm.includes('continuous') ? 'continuous' : '');
if (fokusModus && !tippHinweis) {
tippHinweis = true;
hint.textContent = 'Antippen stellt dort scharf';
hint.style.display = '';
setTimeout(() => { if (hint.textContent === 'Antippen stellt dort scharf') hint.style.display = 'none'; }, 2500);
}
}
async function lichtSchalten(an) {
const track = videoTrack();
if (!track) return false;
try { await track.applyConstraints({ advanced: [{ torch: an }] }); return true; }
catch (_) { return false; }
}
blitzBtn.onclick = async () => {
if (blitzModi.length < 2) return;
const vorher = blitz;
blitz = blitzModi[(blitzModi.indexOf(blitz) + 1) % blitzModi.length];
if (vorher === 'torch' || blitz === 'torch') {
if (!await lichtSchalten(blitz === 'torch') && blitz === 'torch') {
showToast('Das Licht lässt sich hier nicht schalten', 'warn');
blitz = blitzModi[(blitzModi.indexOf(blitz) + 1) % blitzModi.length];
}
}
blitzBtn.textContent = BLITZ_TEXT[blitz];
try { localStorage.setItem(BLITZ_KEY, blitz); } catch (_) {}
};
stage.addEventListener('click', async (ev) => {
const track = videoTrack();
if (!track || !fokusModus || !video.videoWidth || ev.target === fokusFest) return;
// Antippstelle in Bildkoordinaten 0..1 — die Vorschau ist eingepasst (object-fit: contain)
const r = video.getBoundingClientRect();
const sk = Math.min(r.width / video.videoWidth, r.height / video.videoHeight);
const w = video.videoWidth * sk, h = video.videoHeight * sk;
const x = (ev.clientX - r.left - (r.width - w) / 2) / w;
const y = (ev.clientY - r.top - (r.height - h) / 2) / h;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
const sr = stage.getBoundingClientRect();
fokusRing.style.left = (ev.clientX - sr.left) + 'px';
fokusRing.style.top = (ev.clientY - sr.top) + 'px';
fokusRing.hidden = false;
fokusRing.classList.remove('ok', 'aktiv');
void fokusRing.offsetWidth; // Animation neu starten
fokusRing.classList.add('aktiv');
let ok = false;
try {
await track.applyConstraints({ advanced: [{ pointsOfInterest: [{ x, y }], focusMode: fokusModus }] });
ok = true;
} catch (_) {
// Ohne Punktwahl wenigstens neu scharfstellen
try { await track.applyConstraints({ advanced: [{ focusMode: fokusModus }] }); ok = true; } catch (_) {}
}
fokusRing.classList.toggle('ok', ok);
setTimeout(() => { fokusRing.hidden = true; }, 900);
if (ok && fokusModus === 'single-shot') fokusFest.hidden = false;
});
fokusFest.onclick = async (ev) => {
ev.stopPropagation();
const track = videoTrack();
fokusFest.hidden = true;
if (track) { try { await track.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }); } catch (_) {} }
};
/* Echtes Kamerafoto in voller Sensoraufloesung.
*
* Ein Standbild aus dem Video-Stream hat hoechstens Stream-Aufloesung (hier bis 4K
@ -3777,8 +3915,15 @@ async function openCameraModal(orderId) {
if (typeof ImageCapture === 'undefined' || !stream) return null;
const track = stream.getVideoTracks()[0];
if (!track || typeof track.getCapabilities !== 'function') return null;
const cap = new ImageCapture(track);
const blob = await cap.takePhoto();
const cap = imageCap || new ImageCapture(track);
let blob = null;
if (fotoBlitz && blitz !== 'torch') {
// Blitz ausdruecklich setzen (auch „aus"); klappt das nicht, ohne Einstellungen ausloesen
try { blob = await cap.takePhoto({ fillLightMode: blitz }); } catch (_) { blob = null; }
}
if (!blob) blob = await cap.takePhoto();
// Manche Geraete schalten das Dauerlicht beim Ausloesen ab
if (blitz === 'torch') lichtSchalten(true);
return (blob && blob.size > 0) ? blob : null;
} catch (_) {
return null; // z.B. „takePhoto not supported" — Standbild nehmen

View file

@ -74,6 +74,11 @@
const it = await idb.queuePatch(id, (x) => {
x.data = buf;
x.mime = fileBlob.type || x.mime || 'image/jpeg';
// Verkleinert = JPEG aus dem Canvas: Endung angleichen. Sonst laege JPEG-Inhalt unter
// „.avif"/„.webp" in der Queue, und der Server lehnt die Endung ab.
if (x.mime === 'image/jpeg' && x.filename && !/\.jpe?g$/i.test(x.filename)) {
x.filename = x.filename.replace(/\.[^.\/]*$/, '') + '.jpg';
}
});
return !!it; // null: schon hochgeladen und geloescht
}