// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung // und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig. import { startCamera, grabFrame, grabFrameRegion, imageDataFromFile } from './camera.js'; import { decodeBarcodes } from './barcode.js'; import { runOcr, isOcrAvailable } from './ocr.js'; import { recognize } from './pipeline.js'; import { normalizeToken } from './spec.js'; import { getScanMode } from './scan-modes.js'; import { buildRecognitionAdapters } from './scan-recognition.js'; import { createSession, proposeAssignment, commitAssignment, undoLast, moveEntry, removeEntry, nextStackId, } from './session.js'; import { saveSession, loadSession, clearSession } from './storage.js'; import { renderScanView } from './ui/scan-view.js'; import { showResult } from './ui/result-overlay.js'; import { askForStack } from './ui/ambiguous-dialog.js'; import { askResumeSession } from './ui/resume-dialog.js'; import { askForScanMode } from './ui/mode-dialog.js'; import { renderSessionList } from './ui/session-list.js'; import { describeSpec } from './ui/describe-spec.js'; const app = document.querySelector('#app'); const store = window.localStorage; // loadSession liefert entweder eine in sich stimmige Sitzung oder null - nie // einen halb brauchbaren Zustand (siehe storage.js). Eine gefundene Sitzung // wird nicht stillschweigend uebernommen, sondern dem Nutzer zur Fortsetzung // angeboten (siehe initResumeOffer() weiter unten). const restoredSession = loadSession(store); let session = createSession(); // Sperrt die Erfassung, waehrend eine Erkennung laeuft. Wird in processCapture() // im finally-Block in jedem Ausgang wieder aufgehoben - auch wenn der Nutzer // im Rot-Dialog "nochmal scannen" waehlt und die Funktion damit vorzeitig // verlassen wird (der fruehe return liegt innerhalb des try-Blocks). let busy = false; // Kamera nicht verfuegbar: der Hinweis darauf muss dauerhaft sichtbar // bleiben und darf nicht von der OCR-Verfuegbarkeitsanzeige am Ende jedes // Scan-Durchlaufs ueberschrieben werden. let cameraUnavailableMessage = null; // Vor dem ersten Start der Kamera waehlt der Nutzer eine der drei Scan- // Funktionen (siehe scan-modes.js); erst danach steht diese Kennung fest. // Waehrend applyMode() noch nicht gelaufen ist, darf weder die Dauersuche // noch ein Scan-Durchlauf ausgeloest werden - beide sind bis dahin durch das // Auswahl-Overlay (searchPaused()) bzw. durch busy/den fehlenden Kamerastart // ohnehin unerreichbar. let currentModeId = null; const view = renderScanView(app, { onCapture: () => processCapture(() => grabFrame(view.video)), onUndo: () => { // Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen // werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen, // der gerade erst durch die laufende Erkennung entstehen wird. if (busy) return; const entry = undoLast(session); view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen'); syncView(); }, onOpenList: () => { // Waehrend eine Erkennung laeuft, bleibt die Sitzungsliste (und damit // auch "Sitzung beenden") unerreichbar - siehe processCapture(). if (busy) return; openSessionList(); }, onPickFile: (file) => processCapture(() => imageDataFromFile(file)), onChangeMode: () => { // Waehrend eine Erkennung laeuft, bleibt auch der Funktionswechsel // unerreichbar - aus demselben Grund wie bei Rueckgaengig/Sitzungsliste. if (busy) return; openModeDialog(); }, }); /** * Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit von * "Modul scannen" und - bei einem Wechsel weg von laufender Codesuche - der * Zielrahmen wird sofort zurueckgesetzt, statt auf den naechsten Durchlauf * der Dauersuche zu warten. * @param {string} modeId */ function applyMode(modeId) { currentModeId = modeId; const mode = getScanMode(modeId); view.setMode(mode.label); // "Modul scannen" hat nur dort etwas zu tun, wo es keine laufende Suche // gibt (Funktion 3) - siehe Kommentar in scan-view.js/setCaptureVisible. view.setCaptureVisible(!mode.continuousSearch); if (!mode.continuousSearch) { view.setFrameDetected(false); } } /** * Oeffnet die Funktionswahl (auch fuer den Wechsel aus der laufenden * Scan-Ansicht heraus, nicht nur beim Start). Solange das Overlay offen ist, * pausiert die Dauersuche von selbst (siehe searchPaused()). */ async function openModeDialog() { closeOverlays(); const modeId = await askForScanMode(app, { current: currentModeId }); applyMode(modeId); } /** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */ function syncView() { view.setStacks(session.stacks); saveSession(session, store); } /** Kurzbeschreibung fuer die "Zuletzt"-Zeile. */ function describeEntry(spec, stackId) { return `${describeSpec(spec)} → Stapel ${stackId}`; } /** * Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung * nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch, * solange die Erkennung eindeutig ist - haelt nur bei roter Konfidenz oder * mehrdeutiger Stapelzuordnung an. */ async function processCapture(getFrame) { if (busy) return; busy = true; // Die Sitzung, gegen die dieser Durchlauf arbeitet, wird hier fest // gehalten. Wird "Sitzung beenden" ausgeloest, waehrend diese Erkennung // noch laeuft (bis zu 20 Sekunden bei OCR), zeigt das Modul-level `session` // danach auf eine neue, leere Sitzung - der Vergleich am Ende dieser // Funktion verhindert, dass das verspaetete Ergebnis dort noch gebucht wird. const targetSession = session; view.setStatus('erkenne …'); let failed = false; try { const frame = await getFrame(); // Ist einer der gleich gelesenen Codes bereits einem Stapel dieser // Sitzung bekannt, kann die Pipeline die bis zu 20 Sekunden dauernde // Texterkennung ueberspringen (siehe recognize() in pipeline.js). const isKnownCode = (code) => { const normalized = normalizeToken(code); return targetSession.stacks.some( (stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized), ); }; // Welcher Adapter tatsaechlich etwas tut, haengt von der gewaehlten // Scan-Funktion ab (siehe scan-modes.js/scan-recognition.js) - recognize() // selbst (pipeline.js) bleibt unveraendert und kennt keine Funktionen. const { decodeBarcodes: decode, runOcr: recognizeText } = buildRecognitionAdapters(currentModeId, { decodeBarcodes, runOcr }); const { spec, source, confidence, codes } = await recognize( frame, { decodeBarcodes: decode, runOcr: recognizeText, isKnownCode }, ); const plan = proposeAssignment(targetSession, spec, codes); let stackId = plan.stackId; if (confidence === 'red' || plan.kind === 'ambiguous') { const answer = await askForStack(app, { spec, candidates: plan.kind === 'ambiguous' ? plan.candidates : targetSession.stacks.map((stack) => stack.id), // Nur eine echte Stapel-Mehrdeutigkeit (plan.kind === 'ambiguous') // bedeutet "mehrere passen" - alles andere landet nur wegen roter // Konfidenz hier, also weil nichts Verwertbares erkannt wurde. nothingRecognized: plan.kind !== 'ambiguous', }); // "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt // innerhalb des try - der finally-Block hebt die Sperre trotzdem auf. if (answer.action === 'retry') return; if (answer.action === 'stack') { // Nutzer hat einen bestehenden Stapel gewaehlt: genau dieser wird genommen. stackId = answer.stackId; } else { // "neuer Stapel": unabhaengig von jeder Vermutung der naechste freie Buchstabe. stackId = nextStackId(targetSession); } } // Siehe Kommentar zu targetSession oben: eine zwischenzeitlich beendete // Sitzung bucht das Ergebnis nicht mehr nach. if (targetSession !== session) return; const entry = commitAssignment(targetSession, spec, source, stackId, codes); view.setLast(describeEntry(spec, entry.stackId)); syncView(); // Nach einer manuellen Entscheidung im Rot-Dialog gibt es nichts mehr // rueckzumelden - der Dialog selbst war die Rueckmeldung. if (confidence !== 'red') { await showResult(app, { stackId: entry.stackId, spec, confidence }); } } catch (error) { failed = true; view.setStatus(`Fehler: ${error.message}`, true); } finally { busy = false; // Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer // noch lesen muss, nicht ueberschreiben. Der Hinweis auf eine nicht // verfuegbare Kamera hat Vorrang vor beidem und bleibt dauerhaft stehen. if (!failed) { if (cameraUnavailableMessage) { view.setStatus(cameraUnavailableMessage, true); } else { const available = isOcrAvailable(); view.setStatus( available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen', !available, ); } } } } // --- 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() { // Funktion 3 (Text erkennen) hat keine laufende Suche - siehe scan-modes.js. // Ein Wechsel dorthin setzt den Rahmen bereits in applyMode() zurueck; // hier genuegt es, schlicht nichts zu tun. if (!getScanMode(currentModeId).continuousSearch) return; 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, getScanMode(currentModeId).barcodeFormats); } 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()); } /** * Oeffnet die Sitzungsliste. Raeumt zuerst jede bereits offene Liste weg - * unabhaengig davon, ob dieser Aufruf von aussen (Stapel-Leiste) oder aus * einem eigenen Rueckruf (Umsortieren, Entfernen) kommt -, damit nie zwei * Listen uebereinanderliegen, etwa bei einem Doppeltipp auf die * Stapel-Schaltflaeche. Nach einer Aenderung wird die Liste anschliessend * frisch neu aufgebaut, damit Stapel-Kopfzeilen sowie Zaehler stets aktuell * bleiben. */ function openSessionList() { closeOverlays(); renderSessionList(app, session, { onMove: (entryId, stackId) => { moveEntry(session, entryId, stackId); syncView(); openSessionList(); }, onRemove: (entryId) => { removeEntry(session, entryId); syncView(); openSessionList(); }, onEndSession: () => { session = createSession(); clearSession(store); view.setLast('Sitzung beendet'); view.setStacks(session.stacks); }, onClose: () => {}, }); } /** Startet die Kamera und, sobald sie brauchbare Bilder liefert, die Dauersuche. */ function startCameraAndSearch() { 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. // runSearchAttempt() selbst haelt sich zurueck, wenn die gewaehlte // Funktion keine laufende Suche vorsieht (Funktion 3). 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. cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`; view.setStatus(cameraUnavailableMessage, true); view.setFilePickerVisible(true); }); } /** * Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an, * statt sie stillschweigend zu uebernehmen. Wird fortgesetzt, zeigt die * "Zuletzt"-Zeile den tatsaechlich letzten Eintrag, damit die Rueckgaengig- * Flaeche das tut, was daneben steht. Wird verworfen, wird der gesicherte * Stand geloescht. */ async function initResumeOffer() { if (restoredSession && restoredSession.entries.length > 0) { const answer = await askResumeSession(app, { entryCount: restoredSession.entries.length }); if (answer.action === 'resume') { session = restoredSession; const last = session.entries[session.entries.length - 1]; view.setLast(describeEntry(last.spec, last.stackId)); } else { clearSession(store); } } syncView(); } /** * Ablauf beim Start: erst - falls vorhanden - die Frage nach dem Fortsetzen * einer gesicherten Sitzung, danach die Wahl der Scan-Funktion, und erst * danach die Kamera. So sieht der Nutzer die Funktionswahl nie, bevor die * Fortsetzen-Frage (falls noetig) beantwortet ist, und die Kamera startet * nie, bevor feststeht, welche Codearten sie ueberhaupt suchen soll. */ async function boot() { await initResumeOffer(); const modeId = await askForScanMode(app); applyMode(modeId); startCameraAndSearch(); } boot();