Laufende Barcode-Suche statt Einzelbild beim Knopfdruck

Solange Kamera laeuft und keine Erkennung/kein Dialog/keine
Sitzungsliste im Weg steht, sucht die App ~5x/s im Zielrahmen nach
Barcodes. Ein gefundener Code durchlaeuft denselben Weg wie der
Knopfdruck (processCapture). Sperrzeit von 2s plus Codevergleich
verhindert, dass ein laenger vor die Kamera gehaltenes Modul mehrfach
gebucht wird, ohne einen zuegigen Modulwechsel auszubremsen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:59:23 +02:00
co-authored by Claude Opus 5
parent abb70e8b23
commit b94639ccd1
+136 -2
View File
@@ -1,6 +1,6 @@
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
import { startCamera, grabFrame, imageDataFromFile } from './camera.js';
import { startCamera, grabFrame, grabFrameRegion, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js';
import { recognize } from './pipeline.js';
@@ -162,6 +162,134 @@ async function processCapture(getFrame) {
}
}
// --- Dauersuche nach Barcodes -----------------------------------------
//
// Solange die Kamera laeuft, sucht die App fortwaehrend im Zielrahmen nach
// Barcodes, statt auf den Knopfdruck zu warten - ein Modul in Freihand vor
// die Kamera zu halten und zu treffen, gelingt selten beim ersten Bild.
// Etwa fuenf Versuche pro Sekunde: haeufig genug, dass ein kurzes Hinhalten
// genuegt, aber selten genug, dass ein Handy dabei nicht dauerhaft ausgelastet
// ist. Die tatsaechliche Dauer eines Versuchs wird unten gemessen und von
// dieser Zielspanne abgezogen - dauert ein Versuch laenger (z. B. weil er
// eine Buchung samt kurzer Rueckmeldung ausgeloest hat), startet der naechste
// sofort, ohne zusaetzlich zu warten.
const SEARCH_INTERVAL_MS = 200;
// Sperrzeit gegen Doppelerfassung: haelt man ein Modul mehrere Sekunden vor
// die Kamera, darf es nicht mehrfach gebucht werden. Zwei Sekunden reichen,
// um eine kurze Verdeckung oder ein Wackeln zu ueberbruecken, waehrend
// derselbe Barcode noch im Bild ist - ein zuegiger Wechsel zu einem anderen
// Modul wird davon nicht ausgebremst, denn ein *anderer* Code hebt die Sperre
// sofort auf (siehe runSearchAttempt). Bewusst kurz gehalten, damit ein
// tatsaechlich zweites Exemplar desselben Teils (z. B. beim Sortieren vieler
// gleicher Module) nach dem Wegnehmen ohne spuerbare Wartezeit erneut gezaehlt
// werden kann, sobald kein Code mehr im Bild ist.
const REBOOK_COOLDOWN_MS = 2000;
// Zuletzt automatisch (per Dauersuche) gebuchte Codemenge, waehrend die Suche
// wegen der Sperrzeit ruht - null, solange keine Sperre aktiv ist.
let restingCodes = null;
// Zeitpunkt (performance.now()), ab dem die Sperrzeit abgelaufen ist.
let restUntil = 0;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Dieselben Bedingungen, unter denen heute schon die Bedienelemente gesperrt
* sind: eine Erkennung laeuft (busy) oder ein Dialog/die Sitzungsliste liegt
* als Vollbild-Overlay ueber der Ansicht.
*/
function searchPaused() {
return busy || app.querySelector('.overlay') !== null;
}
/**
* Ein einzelner Durchlauf der Dauersuche: Ausschnitt in nativer Aufloesung
* holen, auf Barcodes pruefen, Rahmen entsprechend hervorheben. Wird ein noch
* nicht gesperrter Code gefunden, laeuft ab hier derselbe Weg wie beim
* Antippen von "Modul scannen" (siehe processCapture) - der Nutzer muss dafuer
* nichts tun.
*/
async function runSearchAttempt() {
if (searchPaused()) {
view.setFrameDetected(false);
return;
}
let region;
try {
region = grabFrameRegion(view.video);
} catch {
// Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf
// versucht es erneut, kein Grund die Dauersuche abzubrechen.
view.setFrameDetected(false);
return;
}
let codes;
try {
codes = await decodeBarcodes(region);
} catch {
view.setFrameDetected(false);
return;
}
view.setFrameDetected(codes.length > 0);
if (codes.length === 0) {
// Kein Code mehr im Bild: Die Sperrzeit darf jetzt ablaufen (siehe
// REBOOK_COOLDOWN_MS oben), aber nur wenn sie tatsaechlich verstrichen ist.
if (restingCodes !== null && performance.now() >= restUntil) {
restingCodes = null;
}
return;
}
const normalizedCodes = codes.map((code) => normalizeToken(code));
if (restingCodes !== null) {
const stillSameModule = normalizedCodes.some((code) => restingCodes.has(code));
if (stillSameModule) {
// Dasselbe Modul haengt noch im Bild - nicht erneut buchen, egal ob die
// Sperrzeit schon verstrichen ist.
return;
}
// Ein anderer Code ist erschienen: Modulwechsel, die Sperre faellt sofort.
restingCodes = null;
}
// Waehrend decodeBarcodes lief, koennte sich der Zustand veraendert haben
// (z. B. Knopfdruck des Nutzers) - unmittelbar vor dem Ausloesen erneut pruefen.
if (searchPaused()) return;
restingCodes = new Set(normalizedCodes);
restUntil = performance.now() + REBOOK_COOLDOWN_MS;
// Derselbe Ausschnitt, der den Code gerade geliefert hat, geht direkt in
// denselben Ablauf wie beim Knopfdruck - kein zweiter, zeitversetzter Griff
// zur Kamera noetig.
await processCapture(() => Promise.resolve(region));
}
/**
* Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn
* der vorherige vollstaendig fertig ist - nie ueberlappend.
*/
async function continuousSearchLoop() {
for (;;) {
const started = performance.now();
try {
await runSearchAttempt();
} catch {
// Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht
// dauerhaft abbrechen.
}
const elapsed = performance.now() - started;
await sleep(Math.max(0, SEARCH_INTERVAL_MS - elapsed));
}
}
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
function closeOverlays() {
app.querySelectorAll('.overlay').forEach((element) => element.remove());
@@ -199,7 +327,13 @@ function openSessionList() {
});
}
startCamera(view.video).catch((error) => {
startCamera(view.video)
.then(() => {
// Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche
// laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss.
continuousSearchLoop();
})
.catch((error) => {
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar.