kimal3 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/coach/de/kimal3-coach.md +2 -2
- package/coach/en/kimal3-coach.md +2 -2
- package/dist/befehle/einrichten.js +1 -1
- package/dist/befehle/login.js +6 -2
- package/dist/befehle/plan-hoch.js +114 -0
- package/dist/befehle/plan.js +110 -0
- package/dist/befehle/skill-install.js +4 -1
- package/dist/befehle/zeigen-einrichten.js +104 -0
- package/dist/befehle/zeigen-start.js +143 -0
- package/dist/befehle/zeigen.js +288 -0
- package/dist/befehle-katalog-en.js +41 -0
- package/dist/befehle-katalog.js +45 -0
- package/dist/befehlswoerter.js +16 -0
- package/dist/kimal3.js +15 -0
- package/dist/logo-ablegen.js +26 -6
- package/dist/plan-datei.js +172 -0
- package/dist/slash-befehle.js +2 -0
- package/dist/texte/einrichten.js +10 -3
- package/dist/texte/plan.js +93 -0
- package/dist/texte/zeigen.js +189 -0
- package/dist/zeigen-rechnung.js +226 -0
- package/dist/zeigen-umgebung.js +190 -0
- package/package.json +30 -29
- package/skill/SKILL.md +4 -2
- package/skill/commands/k3.md +5 -3
- package/skill/commands/k3plan.md +347 -0
- package/skill/commands/k3start.md +1 -1
- package/skill/commands/k3zeigen.md +303 -0
- package/skill/en/SKILL.md +5 -3
- package/skill/en/commands/k3.md +5 -3
- package/skill/en/commands/k3plan.md +342 -0
- package/skill/en/commands/k3show.md +302 -0
- package/skill/en/commands/k3start.md +1 -1
- package/zeigen/gemeinsam.py +82 -0
- package/zeigen/starter.py +163 -0
- package/zeigen/werkzeug.py +250 -0
- package/zeigen/zeitleiste.py +393 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# Zeitleiste, Einzelbild und Sprachmodell hinter `kimal3 zeigen auswerten | bild` und `einrichten` (Ruling R-6: eigene
|
|
2
|
+
# Datei, werkzeug.py ruft nur auf). Laeuft mit dem Python des Aufnehmers, Arbeitsordner = Aufnahmeordner.
|
|
3
|
+
#
|
|
4
|
+
# Die Regel steckt in `zeitleiste_aus` (rein, ohne Modell geprueft von scripts/zeigen/test_zeitleiste.py); `auswerten`
|
|
5
|
+
# liest nur die Aufnahme, wandelt Ton in Woerter und schreibt die Datei. Spec 2026-10-18-k3zeigen-design.md, US-7/US-8.
|
|
6
|
+
#
|
|
7
|
+
# 🔴 Was hier zurueckkommt, sind Zahlen und feste Codes -- nie Gesagtes, Getipptes, Fenstertitel, keine Meldung einer
|
|
8
|
+
# fremden Bibliothek. Die Zeitleiste selbst liest Claude erst nach Sabines Ja.
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import time
|
|
17
|
+
import warnings
|
|
18
|
+
|
|
19
|
+
# Vor jedem Import von huggingface_hub (auch dem in werkzeug._hf_cache, das nach `import zeitleiste` laeuft): sonst
|
|
20
|
+
# schreibt der Download Fortschrittsbalken nach stderr.
|
|
21
|
+
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
|
|
22
|
+
|
|
23
|
+
MODELL = "large-v3-turbo"
|
|
24
|
+
SATZ_PAUSE_SEK = 0.8
|
|
25
|
+
TIPP_PAUSE_SEK = 2.0
|
|
26
|
+
BILD_BREITE = 1600 # Lesekosten bei 3440 x 2880 (Spec US-8)
|
|
27
|
+
DATEI = "zeitleiste.md"
|
|
28
|
+
ROH = "roh" # Zwilling von gemeinsam.ROH: die Aufnahme selbst liegt dort, Zeitleiste und Bilder eine Ebene hoeher
|
|
29
|
+
|
|
30
|
+
TEXTE = {
|
|
31
|
+
"de": {"titel": "# Zeitleiste", "aufgabe": "Aufgabe", "datum": "Datum", "dauer": "Dauer", "ohne_aufgabe": "(keine)",
|
|
32
|
+
"sagt": "sagt", "tippt": "tippt", "klickt": "klickt", "auf": "„", "zu": "\"",
|
|
33
|
+
"passwort": "[Passwortfeld, ausgeblendet]", "ausgelassen": "[getippt, ausgelassen]",
|
|
34
|
+
"unbekannt": "[unbekanntes Element]", "Strg": "Strg", "Umschalt": "Umschalt"},
|
|
35
|
+
"en": {"titel": "# Timeline", "aufgabe": "Task", "datum": "Date", "dauer": "Duration", "ohne_aufgabe": "(none)",
|
|
36
|
+
"sagt": "says", "tippt": "types", "klickt": "clicks", "auf": "“", "zu": "”",
|
|
37
|
+
"passwort": "[password field, hidden]", "ausgelassen": "[typed, left out]",
|
|
38
|
+
"unbekannt": "[unknown element]", "Strg": "Ctrl", "Umschalt": "Shift"},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Tasten des Aufnehmers (pynput-Namen). AltGr fehlt in KOMBI absichtlich: AltGr+Q ist „@", getippt, keine Kombination.
|
|
42
|
+
UMSCHALTER = {"ctrl": "Strg", "ctrl_l": "Strg", "ctrl_r": "Strg", "alt": "Alt", "alt_l": "Alt", "alt_r": "Alt",
|
|
43
|
+
"cmd": "Win", "cmd_l": "Win", "cmd_r": "Win", "shift": "Umschalt", "shift_l": "Umschalt",
|
|
44
|
+
"shift_r": "Umschalt", "alt_gr": "AltGr"}
|
|
45
|
+
KOMBI = ("Strg", "Alt", "Win")
|
|
46
|
+
ZEICHEN = {"space": " ", "enter": "⏎", "tab": "⇥", "up": "↑", "down": "↓", "left": "←", "right": "→"}
|
|
47
|
+
# Passwortfelder: Windows meldet `IsPassword`, gespeichert wird das Merkmal aber nicht (StructuralElement kennt kein
|
|
48
|
+
# Feld dafuer, structural.py). Deshalb wie der Aufnehmer selbst (shadow/events.py native_field_is_secure) zusaetzlich
|
|
49
|
+
# an Rolle, Klasse und Automation-ID -- und am Namen eines Eingabefelds, dort auch auf Deutsch.
|
|
50
|
+
# `\bpass\d*\b`/`\bpw\d*\b` fuer Web-Kennungen wie user_pass, pass2, login-pw (nach _woerter_teilen: "user pass").
|
|
51
|
+
GEHEIM = re.compile(r"passw|kennw|passcode|\bpass\d*\b|\bpw\d*\b|\bpin\b|\bpwd\b|\botp\b|\btan\b|secret|geheim",
|
|
52
|
+
re.IGNORECASE)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _uhr(sek: float) -> str:
|
|
56
|
+
sek = max(0, int(sek))
|
|
57
|
+
return f"{sek // 60}:{sek % 60:02d}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _eine_zeile(wert) -> str:
|
|
61
|
+
"""Jedes Ereignis bleibt eine Zeile: Zeilenumbrueche und Tabulatoren in Namen und Gesagtem werden Leerzeichen."""
|
|
62
|
+
return " ".join(str(wert or "").split())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _woerter_teilen(text: str) -> str:
|
|
66
|
+
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", re.sub(r"[-_.#]+", " ", text))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def passwortfeld(beobachtung: dict | None) -> bool:
|
|
70
|
+
if not isinstance(beobachtung, dict):
|
|
71
|
+
return False
|
|
72
|
+
e = beobachtung.get("element") or {}
|
|
73
|
+
if e.get("is_password") is True or e.get("IsPassword") is True:
|
|
74
|
+
return True
|
|
75
|
+
rollen = " ".join(str(e.get(k) or "") for k in ("role", "control_type", "class_name")).casefold()
|
|
76
|
+
if "password" in rollen or "secure" in rollen:
|
|
77
|
+
return True
|
|
78
|
+
namen = [e.get("automation_id"), e.get("class_name")]
|
|
79
|
+
if str(e.get("control_type") or e.get("role") or "").casefold() == "edit":
|
|
80
|
+
namen.append(e.get("name"))
|
|
81
|
+
return any(isinstance(n, str) and GEHEIM.search(_woerter_teilen(n)) for n in namen)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _programm(beobachtung: dict) -> str | None:
|
|
85
|
+
name = ((beobachtung.get("process") or {}).get("process_name") or "").strip() # nie window.title (Spec US-7)
|
|
86
|
+
return re.sub(r"\.exe$", "", name, flags=re.IGNORECASE).casefold() or None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _klick_text(beobachtung: dict | None, t: dict) -> str:
|
|
90
|
+
if not isinstance(beobachtung, dict) or not beobachtung.get("element"):
|
|
91
|
+
return t["unbekannt"]
|
|
92
|
+
e = beobachtung["element"]
|
|
93
|
+
teile = [_eine_zeile(e.get("role") or e.get("control_type"))]
|
|
94
|
+
if _eine_zeile(e.get("name")):
|
|
95
|
+
teile.append(f"{t['auf']}{_eine_zeile(e['name'])}{t['zu']}")
|
|
96
|
+
programm = _programm(beobachtung)
|
|
97
|
+
if programm:
|
|
98
|
+
teile.append(f"({programm})")
|
|
99
|
+
return " ".join(x for x in teile if x) or t["unbekannt"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _saetze(woerter: list) -> list:
|
|
103
|
+
# Pause = Stille zwischen Ende des einen und Anfang des naechsten Worts. Nur Anfaenge zu vergleichen trennte
|
|
104
|
+
# gedehnte Woerter mitten im Satz (gemessen an wetter-3: „aktuellen" 1,6 s lang, 0 s Stille vor „Tag.").
|
|
105
|
+
saetze, satz, ende = [], [], None
|
|
106
|
+
for zeit, bis, wort in sorted(woerter):
|
|
107
|
+
wort = _eine_zeile(wort)
|
|
108
|
+
if not wort:
|
|
109
|
+
continue
|
|
110
|
+
if satz and zeit - ende > SATZ_PAUSE_SEK:
|
|
111
|
+
saetze.append(satz)
|
|
112
|
+
satz = []
|
|
113
|
+
satz.append((zeit, wort))
|
|
114
|
+
ende = bis
|
|
115
|
+
if wort[-1] in ".!?…":
|
|
116
|
+
saetze.append(satz)
|
|
117
|
+
satz = []
|
|
118
|
+
return saetze + ([satz] if satz else [])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _kombi_name(e: dict) -> str:
|
|
122
|
+
zeichen = e.get("zeichen") or ""
|
|
123
|
+
if len(zeichen) == 1 and 1 <= ord(zeichen) <= 26: # Strg+Buchstabe kommt als Steuerzeichen: \x03 = Strg+C
|
|
124
|
+
return chr(64 + ord(zeichen))
|
|
125
|
+
if zeichen.isprintable() and zeichen:
|
|
126
|
+
return zeichen.upper()
|
|
127
|
+
return (e.get("taste") or "?").replace("_", " ").title()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def zeitleiste_aus(kopf: dict, woerter: list, ereignisse: list, sprache: str, ohne_getipptes: bool) -> tuple:
|
|
131
|
+
"""Text der Zeitleiste und ihre Zahlen. kopf: aufgabe, datum (fertiger Text), dauer_sek. woerter: (Anfang, Ende, Wort)
|
|
132
|
+
in Sekunden ab Aufnahmestart. ereignisse: dicts mit t, art (click|press|release|…), beobachtung, bei Tasten
|
|
133
|
+
taste und zeichen."""
|
|
134
|
+
t = TEXTE[sprache]
|
|
135
|
+
zahlen = {"saetze": 0, "klicks": 0, "tippstellen": 0, "passwortfelder": 0}
|
|
136
|
+
zeilen = [] # (Sekunde, Verb, Text)
|
|
137
|
+
|
|
138
|
+
for satz in _saetze(woerter):
|
|
139
|
+
zeilen.append((satz[0][0], t["sagt"], f"{t['auf']}{' '.join(w for _, w in satz)}{t['zu']}"))
|
|
140
|
+
zahlen["saetze"] += 1
|
|
141
|
+
|
|
142
|
+
folge = None # laufende Tippfolge: t, text, geheim, zuletzt
|
|
143
|
+
gehalten = set()
|
|
144
|
+
klick_geheim = False
|
|
145
|
+
|
|
146
|
+
def folge_ende():
|
|
147
|
+
nonlocal folge
|
|
148
|
+
if folge and folge["text"]:
|
|
149
|
+
if folge["geheim"]:
|
|
150
|
+
text, zaehler = t["passwort"], "passwortfelder"
|
|
151
|
+
elif ohne_getipptes:
|
|
152
|
+
text, zaehler = t["ausgelassen"], "tippstellen"
|
|
153
|
+
else:
|
|
154
|
+
text, zaehler = f"{t['auf']}{folge['text']}{t['zu']}", "tippstellen"
|
|
155
|
+
zeilen.append((folge["t"], t["tippt"], text))
|
|
156
|
+
zahlen[zaehler] += 1
|
|
157
|
+
folge = None
|
|
158
|
+
|
|
159
|
+
for e in sorted(ereignisse, key=lambda e: e["t"]):
|
|
160
|
+
art, taste = e["art"], e.get("taste")
|
|
161
|
+
if art == "click":
|
|
162
|
+
folge_ende()
|
|
163
|
+
gehalten.clear()
|
|
164
|
+
klick_geheim = passwortfeld(e.get("beobachtung"))
|
|
165
|
+
zeilen.append((e["t"], t["klickt"], _klick_text(e.get("beobachtung"), t)))
|
|
166
|
+
zahlen["klicks"] += 1
|
|
167
|
+
continue
|
|
168
|
+
if art == "release":
|
|
169
|
+
gehalten.discard(UMSCHALTER.get(taste))
|
|
170
|
+
continue
|
|
171
|
+
if art != "press":
|
|
172
|
+
continue # Mausbewegung, Scrollen
|
|
173
|
+
if taste in UMSCHALTER:
|
|
174
|
+
gehalten.add(UMSCHALTER[taste])
|
|
175
|
+
continue
|
|
176
|
+
zeichen = e.get("zeichen") or ""
|
|
177
|
+
steuer = taste is None and len(zeichen) == 1 and 1 <= ord(zeichen) <= 26 # Tab/Enter haben einen Namen
|
|
178
|
+
# Windows meldet AltGr als ctrl_l + alt_r, also Strg+Alt: ein druckbares Zeichen dabei ist Text („@", „€").
|
|
179
|
+
altgr = bool(zeichen) and zeichen.isprintable() and ("AltGr" in gehalten or {"Strg", "Alt"} <= gehalten)
|
|
180
|
+
# Im Passwortfeld wird keine Kombination eine eigene Zeile: sie verriete Tasten und teilte die Ausblendung.
|
|
181
|
+
geheim = klick_geheim or (folge is not None and folge["geheim"]) or passwortfeld(e.get("beobachtung"))
|
|
182
|
+
kombi = [m for m in KOMBI if m in gehalten] or (["Strg"] if steuer else [])
|
|
183
|
+
if kombi and not altgr and not geheim:
|
|
184
|
+
folge_ende()
|
|
185
|
+
mods = kombi + (["Umschalt"] if "Umschalt" in gehalten else [])
|
|
186
|
+
zeilen.append((e["t"], t["tippt"], "[" + "+".join(t.get(m, m) for m in mods) + f"+{_kombi_name(e)}]"))
|
|
187
|
+
gehalten.clear() # eine Kombination verbraucht die Umschalter; die naechste Taste kommt wieder mit eigenen
|
|
188
|
+
continue
|
|
189
|
+
gehalten.clear()
|
|
190
|
+
if folge and e["t"] - folge["zuletzt"] > TIPP_PAUSE_SEK:
|
|
191
|
+
folge_ende()
|
|
192
|
+
if folge is None:
|
|
193
|
+
folge = {"t": e["t"], "text": "", "geheim": klick_geheim, "zuletzt": e["t"]}
|
|
194
|
+
folge["zuletzt"] = e["t"]
|
|
195
|
+
folge["geheim"] = folge["geheim"] or passwortfeld(e.get("beobachtung"))
|
|
196
|
+
if taste == "backspace":
|
|
197
|
+
folge["text"] = folge["text"][:-1]
|
|
198
|
+
elif zeichen and zeichen.isprintable():
|
|
199
|
+
folge["text"] += zeichen
|
|
200
|
+
else:
|
|
201
|
+
folge["text"] += ZEICHEN.get(taste) or f"[{(taste or '?').replace('_', ' ').title()}]"
|
|
202
|
+
folge_ende()
|
|
203
|
+
|
|
204
|
+
kopfzeilen = [t["titel"], "", f"{t['aufgabe']}: {kopf.get('aufgabe') or t['ohne_aufgabe']}",
|
|
205
|
+
f"{t['datum']}: {kopf['datum']}", f"{t['dauer']}: {_uhr(kopf['dauer_sek'])}", ""]
|
|
206
|
+
rumpf = [f"{_uhr(s)} {verb:<8}{text}" for s, verb, text in sorted(zeilen, key=lambda z: z[0])]
|
|
207
|
+
return "\n".join(kopfzeilen + rumpf) + "\n", zahlen
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --- Aufnahme lesen, Ton in Woerter ------------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def _lies_aufnahme(ordner: str):
|
|
213
|
+
db = sqlite3.connect(f"file:{os.path.join(ordner, ROH, 'recording.db')}?mode=ro", uri=True)
|
|
214
|
+
try:
|
|
215
|
+
start, beschreibung = db.execute("select timestamp, task_description from recording").fetchone()
|
|
216
|
+
start = float(start)
|
|
217
|
+
ereignisse = []
|
|
218
|
+
for zeit, art, taste, zeichen, gedrueckt, roh in db.execute(
|
|
219
|
+
"select timestamp, name, key_name, key_char, mouse_pressed, structural_observation from action_event "
|
|
220
|
+
"where name in ('click', 'press', 'release') order by timestamp"):
|
|
221
|
+
if art == "click" and not gedrueckt:
|
|
222
|
+
continue # Loslassen der Maustaste: doppelt zum Klick davor
|
|
223
|
+
try:
|
|
224
|
+
beobachtung = json.loads(roh) if roh else None
|
|
225
|
+
except ValueError:
|
|
226
|
+
beobachtung = None
|
|
227
|
+
ereignisse.append({"t": float(zeit) - start, "art": art, "taste": taste, "zeichen": zeichen,
|
|
228
|
+
"beobachtung": beobachtung})
|
|
229
|
+
letzte = db.execute("select max(timestamp) from action_event").fetchone()[0]
|
|
230
|
+
ton = [(float(ts) - start, flac) for ts, flac in
|
|
231
|
+
db.execute("select timestamp, flac_data from audio_info where length(flac_data) > 0 order by timestamp")]
|
|
232
|
+
finally:
|
|
233
|
+
db.close()
|
|
234
|
+
return start, beschreibung, ereignisse, (float(letzte) - start if letzte is not None else 0.0), ton
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _still() -> None:
|
|
238
|
+
"""Fremde Bibliotheken schreiben sonst nach stderr (loguru im Aufnehmer, Warnungen, logging) -- womoeglich mit
|
|
239
|
+
Fenstertiteln oder Erkanntem. Vor ihrem Import aufrufen."""
|
|
240
|
+
warnings.simplefilter("ignore")
|
|
241
|
+
logging.disable(logging.CRITICAL)
|
|
242
|
+
try:
|
|
243
|
+
from loguru import logger
|
|
244
|
+
|
|
245
|
+
logger.remove()
|
|
246
|
+
except ImportError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _dekodiere(flac: bytes):
|
|
251
|
+
"""16 kHz mono float32 direkt ueber PyAV (faster_whisper.decode_audio scheitert an PyAV 19, Befund B3)."""
|
|
252
|
+
import av
|
|
253
|
+
import numpy
|
|
254
|
+
|
|
255
|
+
teile = []
|
|
256
|
+
with av.open(io.BytesIO(flac)) as quelle:
|
|
257
|
+
wandler = av.AudioResampler(format="flt", layout="mono", rate=16000)
|
|
258
|
+
for rahmen in quelle.decode(audio=0):
|
|
259
|
+
teile += [r.to_ndarray().reshape(-1) for r in wandler.resample(rahmen)]
|
|
260
|
+
teile += [r.to_ndarray().reshape(-1) for r in wandler.resample(None)]
|
|
261
|
+
return numpy.concatenate(teile) if teile else numpy.zeros(0, dtype=numpy.float32)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _erkenne(ton: list, sprache: str) -> tuple:
|
|
265
|
+
"""Woerter (Anfang, Ende ab Aufnahmestart, Wort) und Ende des Tons. Ob das Modell da ist, prueft auswerten vorher."""
|
|
266
|
+
os.environ["HF_HUB_OFFLINE"] = "1" # das Modell hat `einrichten` geladen; hier geht nichts ins Netz
|
|
267
|
+
_still()
|
|
268
|
+
from faster_whisper import WhisperModel
|
|
269
|
+
|
|
270
|
+
modell = WhisperModel(MODELL, device="cpu", compute_type="int8")
|
|
271
|
+
woerter, ende = [], 0.0
|
|
272
|
+
for abstand, flac in ton:
|
|
273
|
+
samples = _dekodiere(flac)
|
|
274
|
+
ende = max(ende, abstand + len(samples) / 16000)
|
|
275
|
+
if not len(samples):
|
|
276
|
+
continue
|
|
277
|
+
segmente, _ = modell.transcribe(samples, language=sprache, word_timestamps=True, vad_filter=True)
|
|
278
|
+
woerter += [(abstand + w.start, abstand + w.end, w.word) for s in segmente for w in (s.words or [])]
|
|
279
|
+
return woerter, ende
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _aufgabe(ordner: str, beschreibung: str | None) -> str | None:
|
|
283
|
+
try:
|
|
284
|
+
with open(os.path.join(ordner, "aufgabe.txt"), encoding="utf-8-sig") as f:
|
|
285
|
+
return f.read().strip() or beschreibung
|
|
286
|
+
except OSError:
|
|
287
|
+
return beschreibung
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _weg(pfad: str) -> None:
|
|
291
|
+
try:
|
|
292
|
+
os.remove(pfad)
|
|
293
|
+
except FileNotFoundError:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def auswerten(ordner: str, sprache: str, ohne_getipptes: bool, modell_da) -> dict:
|
|
298
|
+
"""`modell_da()` = werkzeug.modell_da gegen den Zwischenspeicher: nur ein fehlendes Modell heisst modell-fehlt."""
|
|
299
|
+
daten = {"ergebnis": "fehler", "dauer_sek": 0.0, "saetze": 0, "klicks": 0, "tippstellen": 0, "passwortfelder": 0,
|
|
300
|
+
"grund": None}
|
|
301
|
+
if not os.path.isdir(ordner):
|
|
302
|
+
return dict(daten, grund="ordner-fehlt")
|
|
303
|
+
ziel = os.path.join(ordner, DATEI)
|
|
304
|
+
_weg(ziel) # eine alte Zeitleiste darf nie als Ergebnis dieses Laufs dastehen
|
|
305
|
+
_weg(ziel + ".tmp")
|
|
306
|
+
if not os.path.isfile(os.path.join(ordner, ROH, "recording.db")):
|
|
307
|
+
return dict(daten, grund="keine-aufnahme")
|
|
308
|
+
try:
|
|
309
|
+
start, beschreibung, ereignisse, letzte, ton = _lies_aufnahme(ordner)
|
|
310
|
+
except (sqlite3.Error, TypeError, ValueError):
|
|
311
|
+
return dict(daten, grund="aufnahme-unlesbar")
|
|
312
|
+
daten["dauer_sek"] = round(max(letzte, 0.0), 2)
|
|
313
|
+
if not ton:
|
|
314
|
+
return dict(daten, ergebnis="kein-ton")
|
|
315
|
+
if not modell_da():
|
|
316
|
+
return dict(daten, grund="modell-fehlt")
|
|
317
|
+
try:
|
|
318
|
+
woerter, ton_ende = _erkenne(ton, sprache)
|
|
319
|
+
except Exception:
|
|
320
|
+
return dict(daten, grund="erkennung") # kein Text der Ausnahme: sie koennte Erkanntes tragen
|
|
321
|
+
dauer = max(letzte, ton_ende)
|
|
322
|
+
kopf = {"aufgabe": _aufgabe(ordner, beschreibung), "datum": time.strftime("%Y-%m-%d %H:%M", time.localtime(start)),
|
|
323
|
+
"dauer_sek": dauer}
|
|
324
|
+
text, zahlen = zeitleiste_aus(kopf, woerter, ereignisse, sprache, ohne_getipptes)
|
|
325
|
+
daten.update(zahlen, dauer_sek=round(dauer, 2))
|
|
326
|
+
if zahlen["saetze"] == 0:
|
|
327
|
+
return dict(daten, ergebnis="keine-sprache")
|
|
328
|
+
try:
|
|
329
|
+
with open(ziel + ".tmp", "w", encoding="utf-8", newline="\n") as f:
|
|
330
|
+
f.write(text)
|
|
331
|
+
os.replace(ziel + ".tmp", ziel)
|
|
332
|
+
except OSError:
|
|
333
|
+
_weg(ziel + ".tmp")
|
|
334
|
+
return dict(daten, grund="schreiben-gescheitert")
|
|
335
|
+
return dict(daten, ergebnis="fertig")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --- Einzelbild ----------------------------------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def bild_name(sekunden: float) -> str:
|
|
341
|
+
return _uhr(sekunden).replace(":", "-") + ".png"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _einzelbild(ordner: str, sekunden: float):
|
|
345
|
+
_still()
|
|
346
|
+
from openadapt_capture.capture import CaptureSession # erst nach chdir: der Aufnehmer liest beim Import eine .env
|
|
347
|
+
|
|
348
|
+
sitzung = CaptureSession.load(os.path.join(ordner, ROH))
|
|
349
|
+
if sitzung.video_path is None:
|
|
350
|
+
return None
|
|
351
|
+
return sitzung.get_frame_at(sitzung._recording.timestamp + sekunden, tolerance=1.0)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def bild(ordner: str, sekunden: float) -> dict:
|
|
355
|
+
if not os.path.isdir(ordner):
|
|
356
|
+
return {"ergebnis": "fehler", "datei": None, "grund": "ordner-fehlt"}
|
|
357
|
+
if not math.isfinite(sekunden) or sekunden < 0:
|
|
358
|
+
return {"ergebnis": "fehler", "datei": None, "grund": "zeit-ungueltig"}
|
|
359
|
+
roh_ordner = os.path.join(ordner, ROH)
|
|
360
|
+
if not (os.path.isdir(roh_ordner) and any(n.endswith(".mp4") for n in os.listdir(roh_ordner))):
|
|
361
|
+
return {"ergebnis": "fehler", "datei": None, "grund": "kein-video"}
|
|
362
|
+
try:
|
|
363
|
+
roh = _einzelbild(ordner, sekunden)
|
|
364
|
+
except Exception:
|
|
365
|
+
roh = None
|
|
366
|
+
if roh is None:
|
|
367
|
+
return {"ergebnis": "fehler", "datei": None, "grund": "kein-bild"}
|
|
368
|
+
if roh.width > BILD_BREITE:
|
|
369
|
+
roh = roh.resize((BILD_BREITE, round(roh.height * BILD_BREITE / roh.width)))
|
|
370
|
+
os.makedirs(os.path.join(ordner, "bilder"), exist_ok=True)
|
|
371
|
+
datei = os.path.join(ordner, "bilder", bild_name(sekunden))
|
|
372
|
+
roh.convert("RGB").save(datei, "PNG")
|
|
373
|
+
return {"ergebnis": "fertig", "datei": datei, "grund": None}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# --- Sprachmodell ----------------------------------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def _lade_modell() -> None:
|
|
379
|
+
_still()
|
|
380
|
+
from faster_whisper.utils import download_model
|
|
381
|
+
|
|
382
|
+
download_model(MODELL)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def modell_laden(vollstaendig) -> dict:
|
|
386
|
+
"""`vollstaendig()` = werkzeug.modell_da gegen den Zwischenspeicher; erst danach zaehlt ein Laden als geladen."""
|
|
387
|
+
if vollstaendig():
|
|
388
|
+
return {"ergebnis": "da", "grund": None}
|
|
389
|
+
try:
|
|
390
|
+
_lade_modell()
|
|
391
|
+
except Exception:
|
|
392
|
+
return {"ergebnis": "fehler", "grund": "laden-gescheitert"}
|
|
393
|
+
return {"ergebnis": "geladen", "grund": None} if vollstaendig() else {"ergebnis": "fehler", "grund": "unvollstaendig"}
|