Kamera: Zoom mit zwei Fingern, Antippen belichtet auch dort [deploy]

- Zwei Finger im Sucher zoomen (nur wenn die Kamera Zoom meldet), Anzeige
  „1×"/„2,5×" unten im Sucher, Antippen der Anzeige setzt auf 1× zurück.
  Während der Geste geht immer nur ein Aufruf an die Kamera; das Ende
  der Geste zählt nicht als Fokus-Tipp.
- Hinweis beim Öffnen: „Antippen stellt dort scharf · zwei Finger zoomen".
- Kein eigener Helligkeitsregler: Chrome setzt mit der Tippstelle auch
  Belichtung und Weißabgleich (im Emulator gemessen).

Im Android-Emulator mit echter Zwei-Finger-Geste geprüft; das Foto bei
2,5× zeigt genau den Sucher-Ausschnitt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-10-19 23:22:04 +02:00
parent 7c8a3c4bc0
commit 3028ff5731
3 changed files with 109 additions and 5 deletions

View file

@ -31,6 +31,26 @@ Bericht-Moduls.
---
## 10. Kamera vernünftig + offene Handy-Prüfungen im Emulator (Eddy 19.10.2026: „mach das vernünftig")
Anlass: Nach der Blitz/Fokus-Gegenprobe (Punkt 9) meldet Chrome außerdem Zoom 1–10× und Belichtungskorrektur ±3.
Der Emulator läuft jetzt (SVM an) — die bisher „nur am Handy" prüfbaren Punkte gehen dort mit echtem Android-Chrome.
- [x] ✅ 19.10.2026 **Zoom mit zwei Fingern im Sucher** (`applyConstraints({advanced:[{zoom}]})`, nur wenn `caps.zoom`
gemeldet) + Anzeige „1×"/„2,5×" unten im Sucher (gelb, sobald gezoomt), Antippen der Anzeige → zurück auf 1×.
Während der Geste immer nur ein Aufruf an die Kamera, Zwischenwerte verfallen; Ende der Geste löst keinen
Fokus-Tipp aus. Im Emulator mit **echter Zwei-Finger-Geste** (Emulator-Konsole → Touchscreen, siehe unten) geprüft:
Fingerabstand ×2,87 → 2,9×, zusammen → 1×, kein Fokus-Aufruf/Klick während der Gesten; Foto bei 2,5× zeigt genau
den Sucher-Ausschnitt. Chrome 153 zoomt über `control.zoomRatio` (nicht Ausschnitt) — die Fokus-/Belichtungs-
bereiche gelten dann laut AOSP-Definition relativ zum gezoomten Bild, die Tippstelle passt also auch im Zoom.
- [x] ✅ Belichtung: **kein eigener Regler** — Antippen setzt bei Chrome Fokus-, Belichtungs- und Weißabgleich-Bereich
gemeinsam; im Emulator gemessen: `aeRegions` = `afRegions` = `awbRegions` = Tippstelle
→ Display/Typenschild antippen = dort scharf UND richtig belichtet.
- [ ] Emulator: echte Kamera + „Fertig" im Funkloch (Fotos gesichert, nach Netz-Rückkehr hochgeladen, Server zählt genau +n)
- [ ] Emulator: Hochladen bei ausgeschaltetem Display (Background Sync)
- [ ] Emulator: 8–10 Fotos auf einmal aus der Galerie (Fix aus Punkt 7)
- [ ] Emulator: Zwei-Finger-Zoom im PDF-Betrachter mit echtem Mehrfinger-Touch (statt CDP-Nachbildung)
## 8. Knopf „Geteiltes ablegen" bleibt dauerhaft stehen (gemeldet 2026-10-19)
Eddy: *„bei der App Baustelle ist permanent ein Button relativ weit unten angezeigt über den aufgelisteten

19
app.css
View file

@ -1219,6 +1219,7 @@ body {
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none; /* zwei Finger zoomen die Kamera, nicht die Seite */
}
/* contain: die Vorschau zeigt exakt das, was gespeichert wird (wichtig für Doku-Fotos) */
.camera-modal #cam-video {
@ -1329,6 +1330,24 @@ body {
.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; } }
/* Zoomstufe unten mittig im Sucher; Antippen = zurück auf 1× */
.camera-modal .cam-zoom {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
min-width: 44px;
height: 32px;
padding: 0 10px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.camera-modal .cam-zoom.aktiv { color: #ffd34d; border-color: #ffd34d; }
.camera-modal [hidden] { display: none !important; }
/* ============================================================

75
app.js
View file

@ -3626,6 +3626,7 @@ async function openCameraModal(orderId) {
<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-zoom" id="cam-zoom" title="Zoom zurück auf 1×" hidden>1×</button>
</div>
<div class="cam-filmstrip" id="cam-strip"></div>
<div class="cam-controls">
@ -3645,6 +3646,7 @@ async function openCameraModal(orderId) {
const blitzBtn = modal.querySelector('#cam-blitz');
const fokusRing = modal.querySelector('#cam-fokus-ring');
const flipBtn = modal.querySelector('#cam-flip');
const zoomBtn = modal.querySelector('#cam-zoom');
let stream = null;
let facing = 'environment';
@ -3791,6 +3793,10 @@ async function openCameraModal(orderId) {
* → der Autofokus stellt laufend auf diese Stelle scharf. 'single-shot' meldet Chrome zwar,
* loest es auf Android aber nicht ein (kein AF-Trigger in VideoCaptureCamera2.java, der
* Modus bleibt CONTINUOUS_PICTURE — im Emulator per dumpsys gemessen, 19.10.2026).
* Chrome setzt mit dem Punkt zugleich Belichtungs- und Weissabgleich-Bereich (AE/AWB_REGIONS):
* Display oder Typenschild antippen = dort auch richtig belichtet, kein eigener Regler noetig.
* - Zoom mit zwei Fingern: track.getCapabilities().zoom, Chrome schneidet den Sensor aus
* (SCALER_CROP_REGION, gilt auch fuer takePhoto). Beim Zoomen verwirft Chrome den Tippbereich.
* 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' };
@ -3800,6 +3806,8 @@ async function openCameraModal(orderId) {
let blitz = 'off';
let fotoBlitz = false; // kann takePhoto() blitzen (fillLightMode)?
let fokusModus = ''; // 'continuous' | 'single-shot' | '' (kein Antippen)
let zoomBereich = null; // { min, max, step } aus getCapabilities, sonst kein Zoom
let zoom = 1;
let tippHinweis = false;
function videoTrack() {
@ -3807,8 +3815,8 @@ async function openCameraModal(orderId) {
}
async function kameraExtras(my) {
imageCap = null; blitzModi = []; fotoBlitz = false; fokusModus = '';
blitzBtn.hidden = true; fokusRing.hidden = true;
imageCap = null; blitzModi = []; fotoBlitz = false; fokusModus = ''; zoomBereich = null; zoom = 1;
blitzBtn.hidden = true; fokusRing.hidden = true; zoomBtn.hidden = true;
const track = videoTrack();
if (!track) return;
let caps = {};
@ -3841,14 +3849,70 @@ async function openCameraModal(orderId) {
const fm = Array.isArray(caps.focusMode) ? caps.focusMode : [];
fokusModus = fm.includes('continuous') ? 'continuous' : (fm.includes('single-shot') ? 'single-shot' : '');
if (fokusModus && !tippHinweis) {
const zb = caps.zoom;
if (zb && typeof zb.max === 'number' && zb.max > (zb.min || 1)) {
zoomBereich = { min: zb.min || 1, max: zb.max, step: zb.step || 0.1 };
try { zoom = track.getSettings().zoom || zoomBereich.min; } catch (_) { zoom = zoomBereich.min; }
zoomAnzeigen();
zoomBtn.hidden = false;
}
const tipps = [];
if (fokusModus) tipps.push('Antippen stellt dort scharf');
if (zoomBereich) tipps.push('zwei Finger zoomen');
if (tipps.length && !tippHinweis) {
tippHinweis = true;
hint.textContent = 'Antippen stellt dort scharf';
const text = tipps.join(' · ');
hint.textContent = text;
hint.style.display = '';
setTimeout(() => { if (hint.textContent === 'Antippen stellt dort scharf') hint.style.display = 'none'; }, 2500);
setTimeout(() => { if (hint.textContent === text) hint.style.display = 'none'; }, 2500);
}
}
function zoomAnzeigen() {
const z = Math.round(zoom * 10) / 10;
zoomBtn.textContent = (Number.isInteger(z) ? String(z) : z.toFixed(1).replace('.', ',')) + '×';
zoomBtn.classList.toggle('aktiv', z > (zoomBereich ? zoomBereich.min : 1));
}
// Zoom setzen: waehrend der Geste kommen viele Werte — immer nur einen Aufruf gleichzeitig
// an die Kamera schicken, danach den zuletzt gewuenschten (Zwischenwerte verfallen).
let zoomLaeuft = false, zoomZiel = null;
async function zoomSetzen(z) {
if (!zoomBereich) return;
const { min, max, step } = zoomBereich;
zoomZiel = Math.min(max, Math.max(min, Math.round(Math.round(z / step) * step * 100) / 100));
zoom = zoomZiel;
zoomAnzeigen();
if (zoomLaeuft) return;
zoomLaeuft = true;
while (zoomZiel !== null) {
const ziel = zoomZiel; zoomZiel = null;
const track = videoTrack();
if (!track) break;
try { await track.applyConstraints({ advanced: [{ zoom: ziel }] }); } catch (_) {}
}
zoomLaeuft = false;
}
zoomBtn.onclick = (ev) => { ev.stopPropagation(); if (zoomBereich) zoomSetzen(zoomBereich.min); };
// Zwei Finger auf dem Sucher = zoomen (Abstand der Finger relativ zum Start)
let pinch = null, pinchEnde = 0;
const fingerAbstand = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
stage.addEventListener('touchstart', (e) => {
if (e.touches.length === 2 && zoomBereich) pinch = { d0: fingerAbstand(e.touches), z0: zoom };
}, { passive: true });
stage.addEventListener('touchmove', (e) => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
zoomSetzen(pinch.z0 * fingerAbstand(e.touches) / pinch.d0);
}, { passive: false });
const pinchAus = (e) => { if (pinch && (!e.touches || e.touches.length < 2)) { pinch = null; pinchEnde = Date.now(); } };
stage.addEventListener('touchend', pinchAus);
stage.addEventListener('touchcancel', pinchAus);
async function lichtSchalten(an) {
const track = videoTrack();
if (!track) return false;
@ -3893,6 +3957,7 @@ async function openCameraModal(orderId) {
stage.addEventListener('click', async (ev) => {
const track = videoTrack();
if (!track || !fokusModus || !video.videoWidth) return;
if (pinch || Date.now() - pinchEnde < 400) return; // Ende einer Zoom-Geste ist kein Tipp
// 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);