diff --git a/config_loader.py b/config_loader.py index bff37de..68ddb2e 100644 --- a/config_loader.py +++ b/config_loader.py @@ -63,6 +63,9 @@ def _load_config_file(path: pathlib.Path): "FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"), "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), + "TIMELAPSE_LOCAL": (CONFIG_SECTION_PRINT, "timelapse_local"), + "TIMELAPSE_INTERVAL_SEC": (CONFIG_SECTION_PRINT, "timelapse_interval_sec"), + "TIMELAPSE_PRINTER": (CONFIG_SECTION_PRINT, "timelapse_printer"), "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), } for env_key, (section, option) in mapping.items(): @@ -103,6 +106,9 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): "flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"), "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), "web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), + "timelapse_local": env_vals.get("TIMELAPSE_LOCAL", "0"), + "timelapse_interval_sec": env_vals.get("TIMELAPSE_INTERVAL_SEC", "4"), + "timelapse_printer": env_vals.get("TIMELAPSE_PRINTER", "0"), } cfg[CONFIG_SECTION_BRIDGE] = { "poll_interval": "3", @@ -265,3 +271,6 @@ VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) +TIMELAPSE_LOCAL = int(get("TIMELAPSE_LOCAL", "0")) +TIMELAPSE_INTERVAL_SEC = int(get("TIMELAPSE_INTERVAL_SEC", "4")) +TIMELAPSE_PRINTER = int(get("TIMELAPSE_PRINTER", "0")) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index f95ea1a..f88ae62 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -407,6 +407,18 @@ class GCodeStore: filament_assignments TEXT, abort_reason TEXT ); + CREATE TABLE IF NOT EXISTS timelapses ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + printer_id TEXT NOT NULL, + created_at TEXT NOT NULL, + filename TEXT NOT NULL, + path TEXT NOT NULL, + frame_count INTEGER DEFAULT 0, + fps INTEGER DEFAULT 24, + status TEXT NOT NULL, + duration_sec INTEGER DEFAULT 0 + ); """) # Migration: Spalte gcode_filaments nachrüsten falls DB älter try: @@ -570,6 +582,56 @@ class GCodeStore: ).fetchall() return [dict(r) for r in rows] + def create_timelapse(self, job_id: str, printer_id: str, path: str, fps: int = 24) -> str: + tid = str(uuid.uuid4()) + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + filename = os.path.basename(path) + with self._lock: + self._conn.execute( + """INSERT INTO timelapses (id, job_id, printer_id, created_at, filename, path, fps, status, frame_count, duration_sec) + VALUES (?,?,?,?,?,?,?,'recording',0,0)""", + (tid, job_id, printer_id, now, filename, path, fps), + ) + self._conn.commit() + return tid + + def update_timelapse(self, tid: str, status: str, frame_count: int = 0, duration_sec: int = 0): + with self._lock: + self._conn.execute( + "UPDATE timelapses SET status=?, frame_count=?, duration_sec=? WHERE id=?", + (status, frame_count, duration_sec, tid), + ) + self._conn.commit() + + def list_timelapses(self) -> list: + with self._lock: + rows = self._conn.execute( + "SELECT * FROM timelapses ORDER BY created_at DESC" + ).fetchall() + return [dict(r) for r in rows] + + def get_timelapse(self, tid: str) -> "dict | None": + with self._lock: + row = self._conn.execute( + "SELECT * FROM timelapses WHERE id=?", (tid,) + ).fetchone() + return dict(row) if row else None + + def delete_timelapse(self, tid: str) -> bool: + rec = self.get_timelapse(tid) + if not rec: + return False + path = rec.get("path", "") + if path and os.path.isfile(path): + try: + os.remove(path) + except Exception: + pass + with self._lock: + self._conn.execute("DELETE FROM timelapses WHERE id=?", (tid,)) + self._conn.commit() + return True + class CameraCache: """Zentraler Kamera-Demuxer. @@ -732,6 +794,96 @@ class CameraCache: await asyncio.sleep(2.0) +class TimelapseSpool: + """Erfasst JPEG-Frames von CameraCache während eines Drucks und kodiert sie + nach Druckende zu einer MP4-Datei mit ffmpeg.""" + + def __init__(self, camera: CameraCache, timelapse_dir: str): + self._camera = camera + self._dir = timelapse_dir + self._task: "asyncio.Task | None" = None + self._frame_dir: str = "" + self._frame_count: int = 0 + self._last_ts: float = 0.0 + + async def start(self, job_id: str, interval_sec: float = 4.0): + safe_id = job_id.replace("/", "_").replace("\\", "_") + self._frame_dir = os.path.join(self._dir, safe_id, "frames") + os.makedirs(self._frame_dir, exist_ok=True) + self._frame_count = 0 + self._last_ts = 0.0 + self._task = asyncio.create_task(self._capture_loop(interval_sec)) + log.info(f"Timelapse-Aufnahme gestartet: {self._frame_dir} @ {interval_sec}s Intervall") + + async def stop(self, output_fps: int = 24) -> "str | None": + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + if self._frame_count == 0: + log.warning("Timelapse: keine Frames aufgenommen") + return None + + job_dir = os.path.dirname(self._frame_dir) + output_path = job_dir.rstrip("/\\") + ".mp4" + ok = await self._encode(output_path, output_fps) + + import shutil + try: + shutil.rmtree(job_dir) + except Exception as e: + log.warning(f"Timelapse: Frames-Verzeichnis löschen fehlgeschlagen: {e}") + + return output_path if ok else None + + async def _capture_loop(self, interval_sec: float): + while True: + try: + ts = self._camera.latest_jpeg_ts + frame = self._camera.latest_jpeg + if ts > self._last_ts and frame: + path = os.path.join(self._frame_dir, f"frame_{self._frame_count:05d}.jpg") + with open(path, "wb") as f: + f.write(frame) + self._frame_count += 1 + self._last_ts = ts + except Exception as e: + log.warning(f"Timelapse capture-Fehler: {e}") + await asyncio.sleep(interval_sec) + + async def _encode(self, output_path: str, fps: int) -> bool: + try: + ffmpeg = _find_ffmpeg() + pattern = os.path.join(self._frame_dir, "frame_%05d.jpg") + cmd = [ + ffmpeg, "-y", "-loglevel", "error", + "-framerate", str(fps), + "-i", pattern, + "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + output_path, + ] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + log.error(f"Timelapse encode fehlgeschlagen (rc={proc.returncode}): " + f"{stderr.decode(errors='replace')[:300]}") + return False + log.info(f"Timelapse kodiert: {output_path} ({self._frame_count} Frames @ {fps}fps)") + return True + except Exception as e: + log.error(f"Timelapse encode Ausnahme: {e}") + return False + + class KobraXBridge: def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None): self.client = client @@ -796,9 +948,15 @@ class KobraXBridge: self._last_uploaded_file: str = "" self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir + self._timelapse_dir: str = os.path.join(args.data_dir, "timelapses") + os.makedirs(self._timelapse_dir, exist_ok=True) self._current_job_id: str = "" self._camera_autostarted: bool = False + self._timelapse_spool: "TimelapseSpool | None" = None + self._current_timelapse_id: str = "" + self._timelapse_autostarted: bool = False self.camera_cache: CameraCache = CameraCache() + self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() self._thumbnail_b64: str = "" self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config() @@ -924,6 +1082,18 @@ class KobraXBridge: elif kobra_state in ("free", "finished", "stoped", "canceled"): self._camera_autostarted = False + # Timelapse: Druckstart + if kobra_state == "printing" and not self._timelapse_autostarted: + if getattr(self._args, "timelapse_local", 0): + self._timelapse_autostarted = True + asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop) + elif kobra_state in ("finished", "stoped", "canceled"): + self._timelapse_autostarted = False + if self._timelapse_spool: + asyncio.run_coroutine_threadsafe( + self._stop_timelapse(success=(kobra_state == "finished")), self._loop + ) + # Job-History: Druckstart erkennen if kobra_state == "printing" and not self._current_job_id: filename = d.get("filename", self._state.get("filename", "")) @@ -1010,6 +1180,13 @@ class KobraXBridge: log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") elif kobra_state in ("free", "finished", "stoped", "canceled"): self._camera_autostarted = False + # Timelapse-Autostart auch hier (Guard verhindert Doppel-Start mit _on_print). + if kobra_state == "printing" and not self._timelapse_autostarted: + if getattr(self._args, "timelapse_local", 0): + self._timelapse_autostarted = True + asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop) + elif kobra_state in ("free", "finished", "stoped", "canceled"): + self._timelapse_autostarted = False if project: if "filename" in project: self._state["filename"] = project["filename"] @@ -2225,7 +2402,7 @@ class KobraXBridge: "flow_calibration": flow_calibration, "dry_mode": 0, "ai_settings": {"status": 0, "count": 0, "type": 1}, - "timelapse": {"status": 0, "count": 0, "type": 64}, + "timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64}, "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, "model_objects_skip_parts": excluded_objects, }, @@ -2643,7 +2820,7 @@ class KobraXBridge: "flow_calibration": flow_calibration, "dry_mode": 0, "ai_settings": {"status": 0, "count": 0, "type": 1}, - "timelapse": {"status": 0, "count": 0, "type": 64}, + "timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64}, "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, "model_objects_skip_parts": [], }, @@ -2737,7 +2914,7 @@ class KobraXBridge: "flow_calibration": flow_calibration, "dry_mode": 0, "ai_settings": {"status": 0, "count": 0, "type": 0}, - "timelapse": {"status": 0, "count": 0, "type": 0}, + "timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 0}, "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, "model_objects_skip_parts": excluded_objects, }, @@ -3509,6 +3686,97 @@ class KobraXBridge: self._ams_loaded_slot = global_loaded return self._ams_slots + # ─── Timelapse ─────────────────────────────────────────────────────────── + + async def _start_timelapse(self): + camera_url = self._state.get("camera_url", "") + if not camera_url: + log.info("Timelapse: kein Kamera-URL bekannt — übersprungen") + return + await self.camera_cache.ensure_running() + interval = float(getattr(self._args, "timelapse_interval_sec", 4)) + spool = TimelapseSpool(self.camera_cache, self._timelapse_dir) + job_id = self._current_job_id or str(uuid.uuid4()) + safe_id = job_id.replace("/", "_").replace("\\", "_") + mp4_path = os.path.join(self._timelapse_dir, safe_id + ".mp4") + tid = self._store.create_timelapse( + job_id=job_id, + printer_id=self._printer_id, + path=mp4_path, + fps=24, + ) + self._timelapse_spool = spool + self._current_timelapse_id = tid + await spool.start(job_id, interval_sec=interval) + log.info(f"Timelapse ID={tid} für Job {job_id} gestartet") + + async def _stop_timelapse(self, success: bool = True): + spool = self._timelapse_spool + if not spool: + return + self._timelapse_spool = None + tid = self._current_timelapse_id + self._current_timelapse_id = "" + frame_count = spool._frame_count + self._store.update_timelapse(tid, "processing", frame_count=frame_count) + mp4_path = await spool.stop(output_fps=24) + if mp4_path: + interval = float(getattr(self._args, "timelapse_interval_sec", 4)) + duration_sec = int(frame_count * interval) + self._store.update_timelapse(tid, "completed", frame_count=frame_count, + duration_sec=duration_sec) + log.info(f"Timelapse {tid} abgeschlossen: {mp4_path}") + else: + self._store.update_timelapse(tid, "failed", frame_count=frame_count) + log.warning(f"Timelapse {tid} fehlgeschlagen") + + async def handle_kx_timelapses(self, request): + rows = self._store.list_timelapses() + # Laufende Aufnahme in der DB-Ansicht sichtbar machen + if self._current_timelapse_id: + spool = self._timelapse_spool + for r in rows: + if r["id"] == self._current_timelapse_id and spool: + r["frame_count"] = spool._frame_count + return self._json_cors(rows) + + async def handle_kx_timelapse_download(self, request): + tid = request.match_info["tid"] + rec = self._store.get_timelapse(tid) + if not rec or not os.path.isfile(rec.get("path", "")): + return web.Response(status=404) + filename = rec.get("filename") or os.path.basename(rec["path"]) + return web.FileResponse( + rec["path"], + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + async def handle_kx_timelapse_thumb(self, request): + tid = request.match_info["tid"] + rec = self._store.get_timelapse(tid) + if not rec: + return web.Response(status=404) + job_id = rec.get("job_id", "") + safe_id = job_id.replace("/", "_").replace("\\", "_") + frame_dir = os.path.join(self._timelapse_dir, safe_id, "frames") + try: + if os.path.isdir(frame_dir): + frames = sorted(f for f in os.listdir(frame_dir) if f.endswith(".jpg")) + if frames: + with open(os.path.join(frame_dir, frames[0]), "rb") as f: + data = f.read() + return web.Response(body=data, content_type="image/jpeg") + except Exception: + pass + return web.Response(status=404) + + async def handle_kx_timelapse_delete(self, request): + tid = request.match_info["tid"] + ok = self._store.delete_timelapse(tid) + if not ok: + return self._json_cors({"error": "not found"}, status=404) + return self._json_cors({"status": "deleted"}) + # ─── Settings ──────────────────────────────────────────────────────────── def _find_config_path(self) -> pathlib.Path: @@ -3538,6 +3806,9 @@ class KobraXBridge: "flow_calibration": getattr(self._args, "flow_calibration", 0), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "timelapse_local": getattr(self._args, "timelapse_local", 0), + "timelapse_interval_sec": getattr(self._args, "timelapse_interval_sec", 4), + "timelapse_printer": getattr(self._args, "timelapse_printer", 0), "ace_dry_presets": self._ace_dry_presets, }) @@ -3570,6 +3841,9 @@ class KobraXBridge: cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 0)))))) cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) + cfg.set("print", "timelapse_local", str(int(bool(data.get("timelapse_local", getattr(self._args, "timelapse_local", 0)))))) + cfg.set("print", "timelapse_interval_sec", str(max(1, int(data.get("timelapse_interval_sec", getattr(self._args, "timelapse_interval_sec", 4)))))) + cfg.set("print", "timelapse_printer", str(int(bool(data.get("timelapse_printer", getattr(self._args, "timelapse_printer", 0)))))) if not cfg.has_option("bridge", "poll_interval"): cfg.set("bridge", "poll_interval", "3") printer_name = str(data.get("printer_name", "")).strip() @@ -4450,6 +4724,10 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles) r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile) r.add_get("/kx/history", bridge.handle_kx_history) + r.add_get("/kx/timelapses", bridge.handle_kx_timelapses) + r.add_get("/kx/timelapse/{tid}/download", bridge.handle_kx_timelapse_download) + r.add_get("/kx/timelapse/{tid}/thumb", bridge.handle_kx_timelapse_thumb) + r.add_delete("/kx/timelapse/{tid}", bridge.handle_kx_timelapse_delete) r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset) r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects) r.add_post("/kx/skip", bridge.handle_kx_skip) @@ -4617,6 +4895,9 @@ def main(): parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION) parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING) + parser.add_argument("--timelapse-local", type=int, default=env_loader.TIMELAPSE_LOCAL) + parser.add_argument("--timelapse-interval-sec", type=int, default=env_loader.TIMELAPSE_INTERVAL_SEC) + parser.add_argument("--timelapse-printer", type=int, default=env_loader.TIMELAPSE_PRINTER) parser.add_argument("--host", default="0.0.0.0", help="Bind-Adresse für den Bridge-Server") diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 1814853..e9ec35d 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -327,6 +327,14 @@ function applyLang(){ setText('lbl-flow-calibration',T.settings_flow_calibration); setText('lbl-camera-on-print',T.settings_camera_on_print); setText('lbl-web-upload-warning',T.settings_web_upload_warning); + setText('lbl-timelapse-local',T.settings_timelapse_local); + setText('lbl-timelapse-interval',T.settings_timelapse_interval); + setText('lbl-timelapse-printer',T.settings_timelapse_printer); + setText('lbl-layout',T.settings_layout); + setText('opt-layout-1col',T.settings_layout_1col); + setText('opt-layout-2col',T.settings_layout_2col); + setText('store-tab-files-label',T.store_tab_files||'Dateien'); + setText('store-tab-tl-label',T.timelapse_tab); setText('lbl-update-check',T.update_check); setText('lbl-update-apply',T.update_apply); @@ -896,6 +904,10 @@ function openSettings(){ var fc=document.getElementById('s-flow-calibration');if(fc)fc.checked=!!d.flow_calibration; var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); + var tlLocal=document.getElementById('s-timelapse-local');if(tlLocal){tlLocal.checked=!!d.timelapse_local;_toggleTlInterval();} + var tlInt=document.getElementById('s-timelapse-interval');if(tlInt)tlInt.value=d.timelapse_interval_sec||4; + var tlPrinter=document.getElementById('s-timelapse-printer');if(tlPrinter)tlPrinter.checked=!!d.timelapse_printer; + var lay=document.getElementById('s-layout');if(lay)lay.value=localStorage.getItem('layout')||'1col'; }); var v=localStorage.getItem('pollInterval')||'2000'; document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); @@ -912,6 +924,85 @@ function closeSettings(){ document.getElementById('settings-modal').classList.remove('open'); } +// ── Layout ── +function applyLayout(mode){ + var panel=document.getElementById('panel-dashboard'); + if(!panel)return; + var fullWidth=['d-cam-card','d-progress-card','d-temps-card','d-ams-card']; + if(mode==='2col'){ + panel.classList.add('layout-2col'); + fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='auto';}); + }else{ + panel.classList.remove('layout-2col'); + fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='1/-1';}); + } + localStorage.setItem('layout',mode); +} + +function _toggleTlInterval(){ + var cb=document.getElementById('s-timelapse-local'); + var row=document.getElementById('timelapse-interval-row'); + if(row)row.style.display=(cb&&cb.checked)?'':'none'; +} + +// ── Store tabs ── +var _currentStoreTab='files'; +var _timelapseData=[]; + +function switchStoreTab(tab){ + _currentStoreTab=tab; + var filesSection=document.getElementById('store-files-section'); + var tlSection=document.getElementById('store-timelapses-section'); + var tabFiles=document.getElementById('store-tab-files'); + var tabTl=document.getElementById('store-tab-timelapses'); + if(filesSection)filesSection.style.display=tab==='files'?'':'none'; + if(tlSection)tlSection.style.display=tab==='timelapses'?'':'none'; + if(tabFiles){tabFiles.classList.toggle('active',tab==='files');} + if(tabTl){tabTl.classList.toggle('active',tab==='timelapses');} + if(tab==='timelapses')loadTimelapses(); +} + +function refreshStoreTab(){ + if(_currentStoreTab==='timelapses')loadTimelapses();else loadStore(); +} + +function loadTimelapses(){ + fetch(_apiUrl('/kx/timelapses')).then(function(r){return r.json();}).then(function(d){ + _timelapseData=Array.isArray(d)?d:[]; + renderTimelapses(); + }).catch(function(e){clog('Timelapse-Fehler: '+e,'msg-err');}); +} + +function renderTimelapses(){ + var list=document.getElementById('timelapse-list'); + if(!list)return; + if(!_timelapseData.length){ + list.innerHTML='
'+(T.timelapse_empty||'Keine Timelapses vorhanden')+'
'; + return; + } + list.innerHTML=_timelapseData.map(function(t){ + var statusCls=t.status==='completed'?'tl-status-ok':t.status==='failed'?'tl-status-err':'tl-status-busy'; + var statusTxt=t.status==='recording'?(T.timelapse_recording||'Aufnahme…'):t.status==='processing'?(T.timelapse_processing||'Kodierung…'):t.status; + var dur=t.duration_sec>0?Math.floor(t.duration_sec/60)+'m ':' '; + var dlBtn=t.status==='completed'?'⬇ Download':''; + return '
'+ + '
🎬
'+ + '
'+ + '
'+t.filename+'
'+ + '
'+statusTxt+'
'+ + '
'+t.frame_count+' frames • '+dur+t.created_at.slice(0,10)+'
'+ + '
'+ + dlBtn+ + ''+ + '
'; + }).join(''); +} + +function deleteTimelapse(id){ + if(!confirm(T.confirm_delete_timelapse||'Dieses Timelapse löschen?'))return; + fetch(_apiUrl('/kx/timelapse/'+id),{method:'DELETE'}).then(function(){loadTimelapses();}); +} + // ── AMS Slot Edit ── var _slotEditIndex=-1; var _slotEditLoaded=false; @@ -1155,7 +1246,11 @@ function saveSettings(){ flow_calibration: (document.getElementById('s-flow-calibration')||{}).checked?1:0, camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, web_upload_warning:webUploadWarning, + timelapse_local: (document.getElementById('s-timelapse-local')||{}).checked?1:0, + timelapse_interval_sec: parseInt((document.getElementById('s-timelapse-interval')||{value:'4'}).value)||4, + timelapse_printer: (document.getElementById('s-timelapse-printer')||{}).checked?1:0, }).then(function(){ + applyLayout((document.getElementById('s-layout')||{value:'1col'}).value); btn.textContent=T.update_restarting; setTimeout(function(){ btn.disabled=false; @@ -1234,6 +1329,7 @@ var pollTimer; }); }).catch(function(){}); poll();pollTimer=setInterval(poll,ms); + applyLayout(localStorage.getItem('layout')||'1col'); })(); // ── Print actions ── diff --git a/web/themes/default/index.html b/web/themes/default/index.html index b6f5bbf..27d87e7 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -118,6 +118,29 @@ + + + + + +
+ +
@@ -201,7 +224,7 @@
-
+
📷 Kamera
@@ -225,7 +248,7 @@
-
+
Fortschritt
0%
@@ -259,7 +282,7 @@
-
+
Temperaturen
@@ -409,8 +432,15 @@
🗂 Datei-Browser - +
+ + + +
+ + +
@@ -441,6 +471,12 @@
+
+ + +
diff --git a/web/themes/default/style.css b/web/themes/default/style.css index a2a4572..5d1a625 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -313,3 +313,25 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0; .modal-box{padding:16px;border-radius:10px} .poll-btns{gap:6px} } + +/* ── 2-Column Dashboard Layout ── */ +#panel-dashboard.layout-2col .grid{grid-template-columns:1fr 1fr} +@media(max-width:768px){ + #panel-dashboard.layout-2col .grid{grid-template-columns:1fr} +} + +/* ── Store tabs ── */ +.store-tab-btn{padding:6px 16px;border-radius:6px;border:1px solid var(--border); + background:var(--raised);color:var(--txt2);cursor:pointer;font-size:13px;font-weight:600;transition:.12s} +.store-tab-btn.active{background:var(--accent);border-color:var(--accent);color:#000} + +/* ── Timelapse list ── */ +.tl-row{display:flex;align-items:center;gap:12px;padding:10px; + border:1px solid var(--border);border-radius:8px;margin-bottom:8px} +.tl-icon{font-size:28px;color:var(--txt2);flex-shrink:0} +.tl-info{flex:1;min-width:0} +.tl-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.tl-meta{font-size:11px;color:var(--txt2);margin-top:2px} +.tl-status-ok{color:var(--ok)} +.tl-status-err{color:var(--err)} +.tl-status-busy{color:var(--warn)} diff --git a/web/translations/de.json b/web/translations/de.json index 3211fa1..db1b699 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -144,6 +144,18 @@ "settings_flow_calibration": "Flow-Kalibrierung vor Druck", "settings_camera_on_print": "Kamera bei Druckstart einschalten", "settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen", + "settings_timelapse_local": "Lokales Timelapse während Druck", + "settings_timelapse_interval": "Aufnahme-Intervall (Sekunden)", + "settings_timelapse_printer": "Drucker-Timelapse aktivieren (experimentell)", + "settings_layout": "Dashboard-Layout", + "settings_layout_1col": "1 Spalte", + "settings_layout_2col": "2 Spalten", + "store_tab_files": "Dateien", + "timelapse_tab": "Timelapses", + "timelapse_empty": "Noch keine Timelapses vorhanden", + "timelapse_recording": "Aufnahme läuft…", + "timelapse_processing": "Kodierung läuft…", + "confirm_delete_timelapse": "Dieses Timelapse löschen?", "update_check": "Auf Updates prüfen", "update_checking": "Prüfe...", "update_available": "verfügbar", diff --git a/web/translations/en.json b/web/translations/en.json index 3b24340..b2a92cf 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -144,6 +144,18 @@ "settings_flow_calibration": "Flow Calibration before print", "settings_camera_on_print": "Turn camera on at print start", "settings_web_upload_warning": "Show warning when printing web uploads", + "settings_timelapse_local": "Local timelapse during print", + "settings_timelapse_interval": "Capture interval (seconds)", + "settings_timelapse_printer": "Enable printer timelapse (experimental)", + "settings_layout": "Dashboard layout", + "settings_layout_1col": "1 column", + "settings_layout_2col": "2 columns", + "store_tab_files": "Files", + "timelapse_tab": "Timelapses", + "timelapse_empty": "No timelapses yet", + "timelapse_recording": "Recording…", + "timelapse_processing": "Encoding…", + "confirm_delete_timelapse": "Delete this timelapse?", "update_check": "Check for Updates", "update_checking": "Checking...", "update_available": "available", diff --git a/web/translations/es.json b/web/translations/es.json index f27aa8e..9ed3334 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -144,6 +144,18 @@ "settings_flow_calibration": "Calibración de flujo antes de imprimir", "settings_camera_on_print": "Encender cámara al iniciar impresión", "settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web", + "settings_timelapse_local": "Timelapse local durante la impresión", + "settings_timelapse_interval": "Intervalo de captura (segundos)", + "settings_timelapse_printer": "Activar timelapse de impresora (experimental)", + "settings_layout": "Diseño del panel", + "settings_layout_1col": "1 columna", + "settings_layout_2col": "2 columnas", + "store_tab_files": "Archivos", + "timelapse_tab": "Timelapses", + "timelapse_empty": "Aún no hay timelapses", + "timelapse_recording": "Grabando…", + "timelapse_processing": "Codificando…", + "confirm_delete_timelapse": "¿Eliminar este timelapse?", "update_check": "Buscar actualizaciones", "update_checking": "Comprobando...", "update_available": "disponible", diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index 9e2c648..5180e26 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -144,6 +144,18 @@ "settings_flow_calibration": "打印前流量校准", "settings_camera_on_print": "打印开始时开启相机", "settings_web_upload_warning": "打印网页上传文件时显示警告", + "settings_timelapse_local": "打印期间本地延时摄影", + "settings_timelapse_interval": "拍摄间隔(秒)", + "settings_timelapse_printer": "启用打印机延时摄影(实验性)", + "settings_layout": "仪表板布局", + "settings_layout_1col": "单列", + "settings_layout_2col": "双列", + "store_tab_files": "文件", + "timelapse_tab": "延时视频", + "timelapse_empty": "暂无延时视频", + "timelapse_recording": "录制中…", + "timelapse_processing": "编码中…", + "confirm_delete_timelapse": "删除此延时视频?", "update_check": "检查更新", "update_checking": "检查中...", "update_available": "可用",