From ee5997ef79395793c34ee71658a277f6088fc08a Mon Sep 17 00:00:00 2001 From: Customize5773 Date: Tue, 14 Jul 2026 00:41:21 +0700 Subject: [PATCH 1/4] Joystick Manual Control (GUI > MAVLink) --- README-WORK.md | 53 +++++++++++++++++++++++++ autonomy/PR-AUTONOMY.md | 9 +++++ manual_control.py | 58 +++++++++++++++++++++++++++ public/js/app.js | 43 +++++++++++++++++++- rov_agent.py | 87 ++++++++++++++++++++++++++++++++++++++++- server/server.js | 16 ++++++++ test_manual_control.py | 54 +++++++++++++++++++++++++ 7 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 manual_control.py create mode 100644 test_manual_control.py diff --git a/README-WORK.md b/README-WORK.md index b084c49..5f5c127 100644 --- a/README-WORK.md +++ b/README-WORK.md @@ -198,3 +198,56 @@ permukaan. Bernilai **40% jika full-autonomous**, hanya **10% jika dilakukan rem - **Gripper:** tombol **OPEN/CLOSE** sudah ada di halaman Control (juga keyboard **H**/**G**) dan mengirim command `gripper` — dipakai Tahap 2, 3 & 5. - **Replay camera & trajectory** (fitur opsional KKI) belum diimplementasikan. + +## 7. Joystick manual control (Gamepad → MANUAL_CONTROL) + +Kontrol manual ROV memakai joystick fisik yang dicolok ke komputer operator (browser), +melalui jalur existing: **Browser → WebSocket → Node.js server → UDP → Raspberry Pi → Pixhawk**. + +**Cara pakai:** +1. Colok joystick ke laptop, buka dashboard, tekan satu tombol joystick agar terdeteksi + (`gamepadconnected`). +2. Di panel **Controller**, pilih tab **Gamepad** (default Keyboard). Badge menampilkan status. +3. Pastikan mode kontrol = **MANUAL** (toggle Manual/Autonomous di header) — joystick **tidak** + berefek saat Autonomous. +4. Halaman **Joystick** menyediakan mapping axis/tombol (disimpan ke `server/config/joystick-profile.json`). + +**Mapping axis (GUI persen −100..100 → MANUAL_CONTROL):** + +| Axis GUI | Gerak | Field MANUAL_CONTROL | Rentang | +|----------|------------------|----------------------|----------------| +| surge | maju/mundur | `x` | −1000..1000 | +| sway | lateral kiri/kanan | `y` | −1000..1000 | +| yaw | rotasi | `r` | −1000..1000 | +| heave | throttle naik/turun | `z` | 0..1000 (netral **500**) | + +- **Deadzone** & remap di browser (`GP_DEADZONE = 0.12`, `public/js/app.js`). +- **Throttle pengiriman ~15 Hz**: axis di-resend berkala walau ditahan konstan, supaya Pi + menerima MANUAL_CONTROL berkelanjutan (tidak masuk fail-safe timeout). +- **Validasi ulang di server** (`server/server.js`): axis di-clamp ke −100..100 sebelum + diteruskan (tidak percaya input klien). +- **Encoding MANUAL_CONTROL** di sisi Pi (`rov_agent.py` + `manual_control.py`, via `pymavlink` + `manual_control_send`). Node server **tidak** meng-encode MAVLink — ia hanya meneruskan JSON, + konsisten dengan pola command lain (arm/light/stop). + +**Kenapa MANUAL_CONTROL, bukan RC_CHANNELS_OVERRIDE:** MANUAL_CONTROL adalah cara standar ArduSub +menerima kontrol manual dari ground station (4 sumbu + bitmask tombol). Tidak menimpa channel RC +fisik, jadi aman berdampingan dengan konfigurasi channel/servo di Pixhawk (scope Devanka). + +**Safety / fallback:** +- **Joystick disconnect** (`gamepaddisconnected`) → axis dinetralkan (x=y=r=0, z=500). +- **E-Stop / Spasi** → joystick **terkunci** sampai operator ARM ulang; tidak bisa override E-Stop. +- **Mode Autonomous** → joystick otomatis nonaktif (otoritas GUI vs FSM, mirip prinsip gripper). +- **Fail-safe Pi**: jika tak ada axis baru > 0.5 s, Pi mengirim satu perintah netral lalu berhenti + (command terakhir tidak "nyangkut", tidak mengganggu mode autonomous). + +**Testing:** +- Unit test mapping (pure function, tanpa hardware): `python3 -m unittest test_manual_control -v`. +- Manual test verifikasi command sampai UDP: + 1. `cd server && node server.js --sim` (atau `hydroship` di launch.json). + 2. Buka dashboard, pilih Gamepad + mode Manual, gerakkan stick. + 3. Amati log server `[CMD] surge = ... -> :14550` (nilai sudah ter-clamp −100..100). + 4. Di Pi, jalankan `rov_agent.py`; amati log `[MANUAL]` dan MANUAL_CONTROL terkirim ke Pixhawk. + +**Tombol joystick:** untuk task ini `buttons` MANUAL_CONTROL masih **placeholder = 0** (TODO). +Aksi tombol (arm/mode/mount/light) sudah ditangani terpisah lewat command GUI existing. diff --git a/autonomy/PR-AUTONOMY.md b/autonomy/PR-AUTONOMY.md index b0dd4cf..cc6df25 100644 --- a/autonomy/PR-AUTONOMY.md +++ b/autonomy/PR-AUTONOMY.md @@ -120,6 +120,15 @@ Legenda: ✅ selesai · ⏳ bisa dikerjakan sekarang (tak butuh hardware) · **Fase 4 (butuh setup penuh):** - [ ] Rehearsal 3× run + drill fallback; isi scoresheet di `PERSIAPAN_FASE2-4.md`. +**Joystick manual control (GUI, baru diimplementasikan — 2026-07-14):** +- [x] Capture Gamepad API + deadzone + mapping axis (browser, sudah ada sebelumnya). +- [x] Encoding **MANUAL_CONTROL** di Pi (`rov_agent.py` + `manual_control.py`, `pymavlink`); + Node server hanya forward JSON + clamp axis (−100..100). +- [x] Gating otoritas: joystick nonaktif saat mode Autonomous & terkunci saat E-Stop. +- [x] Fail-safe: netral saat disconnect / idle > 0.5 s. Unit test `test_manual_control.py`. +- [ ] **Butuh keputusan Rasya / uji hardware:** verifikasi tanda & skala sumbu (surge/sway/yaw/heave) + cocok dengan orientasi thruster di kolam; bitmask tombol masih placeholder. + **Peningkatan opsional (nice-to-have):** - [ ] Resolusi/preprocessing `VisionPipeline` (bukan hanya tool webcam) bila deteksi di Pi kurang. - [ ] Squaring yaw PBVS (QR-02) bila terbukti stabil. diff --git a/manual_control.py b/manual_control.py new file mode 100644 index 0000000..308256d --- /dev/null +++ b/manual_control.py @@ -0,0 +1,58 @@ +"""Pemetaan MURNI axis joystick (persen -100..100) -> MANUAL_CONTROL MAVLink. + +Dipisah dari rov_agent.py supaya logika mapping bisa di-unit-test tanpa +bergantung pada pymavlink / hardware / Gamepad API. + +Kenapa MANUAL_CONTROL (bukan RC_CHANNELS_OVERRIDE)? + MANUAL_CONTROL adalah cara standar ArduSub menerima kontrol manual dari + ground station: empat sumbu (x=maju/mundur, y=lateral, z=throttle, r=yaw) + plus bitmask tombol. Ini tidak menimpa channel RC fisik dan lebih aman + dipakai berdampingan dengan konfigurasi channel/servo di sisi Pixhawk. + +Rentang MANUAL_CONTROL: + x, y, r : -1000 .. 1000 (netral 0) + z : 0 .. 1000 (netral 500) +""" + + +def _clamp(v, lo, hi): + return lo if v < lo else hi if v > hi else v + + +def _to_float(v): + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + +def axis_percent_to_manual(p): + """Sumbu bipolar: persen -100..100 -> -1000..1000 (netral 0).""" + return int(round(_clamp(_to_float(p), -100.0, 100.0) * 10.0)) + + +def throttle_percent_to_manual(p): + """Throttle: persen -100..100 -> 0..1000 (netral 500).""" + return int(round(_clamp(500.0 + _clamp(_to_float(p), -100.0, 100.0) * 5.0, 0.0, 1000.0))) + + +def axes_to_manual_control(surge=0, sway=0, yaw=0, heave=0, buttons=0): + """Terjemahkan axis GUI (persen) ke field MANUAL_CONTROL. + + surge -> x (maju/mundur) + sway -> y (lateral kiri/kanan) + heave -> z (throttle naik/turun) + yaw -> r (rotasi) + buttons: bitmask tombol (placeholder/TODO; belum dipetakan ke tombol GUI). + """ + return { + "x": axis_percent_to_manual(surge), + "y": axis_percent_to_manual(sway), + "z": throttle_percent_to_manual(heave), + "r": axis_percent_to_manual(yaw), + "buttons": int(buttons) & 0xFFFF, + } + + +# Perintah netral / fail-safe: diam di tempat, throttle di tengah. +NEUTRAL = axes_to_manual_control(0, 0, 0, 0) diff --git a/public/js/app.js b/public/js/app.js index c4262de..020930f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -452,9 +452,17 @@ window.addEventListener("hydroship:camera-url", applyControlCamera); /* kontrol UI */ els.btnLight.onclick = () => { const v = !state.light; reflectLight(v); markPending("light", v); sendCmd("light", v); }; -els.btnArm.onclick = () => { const v = !state.armed; reflectArm(v); markPending("arm", v); sendCmd("arm", v); }; +els.btnArm.onclick = () => { + const v = !state.armed; + // arming ulang melepas kunci E-Stop sehingga joystick boleh aktif lagi + if (v) estopLatched = false; + reflectArm(v); markPending("arm", v); sendCmd("arm", v); +}; els.btnStop.onclick = () => { + // E-Stop mengunci joystick: tidak boleh meng-override sampai operator arm ulang + estopLatched = true; sendCmd("stop", true); reflectArm(false); markPending("arm", false); + neutralizeGamepadAxes(); ["surge", "sway", "yaw", "heave"].forEach((a) => setAxis(a, 0)); log("⏹ STOP — semua thruster netral", "err"); }; @@ -710,6 +718,16 @@ function logGamepadStatus() { const gpLast = { surge: 0, sway: 0, yaw: 0, heave: 0 }; +/* E-Stop mengunci joystick sampai operator arm ulang (lihat btnStop/btnArm). */ +let estopLatched = false; + +/* Throttle pengiriman axis ke server ~15 Hz. Meski axis ditahan konstan, + kita tetap resend supaya Pi menerima MANUAL_CONTROL berkelanjutan dan tidak + masuk fail-safe timeout. */ +const GP_SEND_HZ = 15; +const GP_SEND_INTERVAL = 1000 / GP_SEND_HZ; +let gpLastSent = 0; + // status tombol fisik frame sebelumnya const gpBtnPrev = {}; @@ -901,6 +919,16 @@ function pollGamepad() { if (!joystickState.connected) return; if (!joystickState.enabled) return; + /* Otoritas GUI vs FSM (mirip prinsip gripper): joystick HANYA boleh + menggerakkan ROV saat mode kontrol = Manual dan E-Stop tidak aktif. + Saat autonomous / E-Stop, pastikan axis dinetralkan sekali lalu diam. */ + if (controlMode !== "manual" || estopLatched) { + if (gpLast.surge || gpLast.sway || gpLast.yaw || gpLast.heave) { + neutralizeGamepadAxes(); + } + return; + } + /* ================= AXIS ================= */ const next = { surge: axisToPercent(joystickState.mapped.surge), @@ -909,11 +937,22 @@ function pollGamepad() { heave: axisToPercent(joystickState.mapped.heave), }; + let changed = false; for (const a of ["surge", "sway", "yaw", "heave"]) { if (next[a] !== gpLast[a]) { gpLast[a] = next[a]; setAxis(a, next[a], true); - sendCmd(a, next[a], true); + changed = true; + } + } + + // Kirim saat berubah, ATAU secara periodik (~15 Hz) walau axis ditahan, + // agar MANUAL_CONTROL di Pi terus mengalir dan tidak masuk fail-safe. + const nowT = performance.now(); + if (changed || nowT - gpLastSent >= GP_SEND_INTERVAL) { + gpLastSent = nowT; + for (const a of ["surge", "sway", "yaw", "heave"]) { + sendCmd(a, gpLast[a], true); } } diff --git a/rov_agent.py b/rov_agent.py index 6495770..7d75831 100644 --- a/rov_agent.py +++ b/rov_agent.py @@ -5,6 +5,8 @@ import math from pymavlink import mavutil +from manual_control import axes_to_manual_control, NEUTRAL + # ========================= # Konfigurasi jaringan # ========================= @@ -43,6 +45,20 @@ master = None +# ========================= +# Kontrol manual (joystick GUI -> MANUAL_CONTROL) +# ========================= +# Nilai axis dalam persen -100..100 seperti dikirim dashboard (surge/sway/yaw/heave). +# Sebuah thread terpisah mengubahnya jadi MANUAL_CONTROL dan mengirim ~15 Hz. +manual_axes = {"surge": 0.0, "sway": 0.0, "yaw": 0.0, "heave": 0.0} +manual_lock = threading.Lock() +# last_ts: kapan terakhir menerima perintah axis manual (untuk fail-safe timeout). +# neutral_sent: sudah mengirim satu perintah netral setelah idle? (hindari spam). +manual_ctx = {"last_ts": 0.0, "neutral_sent": True} + +MANUAL_SEND_HZ = 15 # laju kirim MANUAL_CONTROL ke Pixhawk +MANUAL_TIMEOUT = 0.5 # detik; kalau tak ada axis baru -> netralkan (fail-safe) + # ========================= # Utility # ======================== @@ -104,10 +120,29 @@ def command_listener(): else: print(f"[MAV] mode '{mode}' tidak ada di mode_mapping()") + elif name in ("surge", "sway", "yaw", "heave"): + # Axis kontrol manual dari dashboard (persen -100..100). + # Server sudah clamp, tapi tetap defensif di sisi Pi. + try: + v = float(value) + except (TypeError, ValueError): + v = 0.0 + v = max(-100.0, min(100.0, v)) + with manual_lock: + manual_axes[name] = v + manual_ctx["last_ts"] = time.time() + manual_ctx["neutral_sent"] = False + elif name == "stop": - # Failsafe sederhana: disarm + # Failsafe sederhana: disarm + netralkan axis manual. print("[MAV] STOP -> DISARM") master.arducopter_disarm() + with manual_lock: + for k in manual_axes: + manual_axes[k] = 0.0 + # picu satu kali kirim netral (x=y=r=0, z=500) + manual_ctx["last_ts"] = time.time() + manual_ctx["neutral_sent"] = False elif name == "light": # Belum dihubungkan ke hardware lampu, simpan status saja @@ -120,6 +155,53 @@ def command_listener(): except Exception as e: print("[CMD] error executing command:", e) +# ========================= +# Pengirim MANUAL_CONTROL periodik ke Pixhawk +# ========================= +def manual_control_sender(): + """Kirim MANUAL_CONTROL ~15 Hz selama ada aktivitas axis manual. + + Fail-safe: kalau tak ada axis baru selama MANUAL_TIMEOUT (mis. joystick + dicabut, E-Stop, atau mode Autonomous mematikan pengiriman di GUI), kirim + SATU perintah netral (x=y=r=0, z=500) lalu berhenti mengirim sampai ada + perintah manual lagi — sehingga command terakhir tidak "nyangkut" dan kita + tidak mengganggu mode autonomous dengan MANUAL_CONTROL terus-menerus. + """ + period = 1.0 / MANUAL_SEND_HZ + print(f"[MANUAL] sender aktif @ {MANUAL_SEND_HZ} Hz (timeout {MANUAL_TIMEOUT}s)") + while True: + time.sleep(period) + if master is None: + continue + + now = time.time() + with manual_lock: + active = (now - manual_ctx["last_ts"]) <= MANUAL_TIMEOUT + axes = dict(manual_axes) + neutral_sent = manual_ctx["neutral_sent"] + + try: + if active: + mc = axes_to_manual_control( + axes["surge"], axes["sway"], axes["yaw"], axes["heave"] + ) + master.mav.manual_control_send( + master.target_system, + mc["x"], mc["y"], mc["z"], mc["r"], mc["buttons"], + ) + elif not neutral_sent: + mc = NEUTRAL + master.mav.manual_control_send( + master.target_system, + mc["x"], mc["y"], mc["z"], mc["r"], mc["buttons"], + ) + with manual_lock: + manual_ctx["neutral_sent"] = True + print("[MANUAL] idle -> kirim netral (fail-safe)") + except Exception as e: + print("[MANUAL] send error:", e) + + # ========================= # Main koneksi Pixhawk # ========================= @@ -149,6 +231,9 @@ def main(): # Thread listener command threading.Thread(target=command_listener, daemon=True).start() + # Thread pengirim MANUAL_CONTROL periodik (kontrol manual joystick/keyboard) + threading.Thread(target=manual_control_sender, daemon=True).start() + last_send = 0 while True: diff --git a/server/server.js b/server/server.js index 2c034ce..bd00509 100644 --- a/server/server.js +++ b/server/server.js @@ -26,6 +26,15 @@ const SIM = process.argv.includes("--sim"); const PUBLIC = path.join(__dirname, "..", "public"); +/* Axis kontrol manual (persen -100..100). Divalidasi ulang sebelum diteruskan + ke Pi supaya input klien tidak bisa mengirim nilai di luar rentang. */ +const MOTION_AXES = new Set(["surge", "sway", "yaw", "heave"]); +function clampPercent(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(-100, Math.min(100, Math.round(n))); +} + /* ======================= JOYSTICK CONFIG FILE ======================= */ const CONFIG_DIR = path.join(__dirname, "config"); const JOY_CFG_FILE = path.join(CONFIG_DIR, "joystick-profile.json"); @@ -329,6 +338,13 @@ wss.on("connection", (ws, req) => { // ================= COMMAND KE ROV ================= if (msg.type === "cmd") { + // Jangan percaya input klien mentah-mentah: clamp axis kontrol manual + // (surge/sway/yaw/heave) ke rentang persen valid -100..100 sebelum + // diteruskan. Pi yang mengubahnya ke MANUAL_CONTROL (-1000..1000 / 0..1000). + if (MOTION_AXES.has(msg.name)) { + msg.value = clampPercent(msg.value); + } + // di mode SIM, pantulkan status perintah agar tombol header berefek nyata if (SIM) applySimCommand(msg.name, msg.value); diff --git a/test_manual_control.py b/test_manual_control.py new file mode 100644 index 0000000..97382c4 --- /dev/null +++ b/test_manual_control.py @@ -0,0 +1,54 @@ +"""Unit test untuk mapping axis joystick -> MANUAL_CONTROL. + +Jalankan: + python3 -m unittest test_manual_control -v +""" +import unittest + +from manual_control import ( + NEUTRAL, + axes_to_manual_control, + axis_percent_to_manual, + throttle_percent_to_manual, +) + + +class TestManualControl(unittest.TestCase): + def test_neutral(self): + self.assertEqual(NEUTRAL, {"x": 0, "y": 0, "z": 500, "r": 0, "buttons": 0}) + + def test_axis_full_range(self): + self.assertEqual(axis_percent_to_manual(100), 1000) + self.assertEqual(axis_percent_to_manual(-100), -1000) + self.assertEqual(axis_percent_to_manual(0), 0) + + def test_throttle_range(self): + self.assertEqual(throttle_percent_to_manual(100), 1000) + self.assertEqual(throttle_percent_to_manual(-100), 0) + self.assertEqual(throttle_percent_to_manual(0), 500) + + def test_clamp_over_range(self): + # server harusnya sudah clamp, tapi mapping tetap defensif + self.assertEqual(axis_percent_to_manual(999), 1000) + self.assertEqual(axis_percent_to_manual(-999), -1000) + self.assertEqual(throttle_percent_to_manual(999), 1000) + self.assertEqual(throttle_percent_to_manual(-999), 0) + + def test_invalid_input_defaults_to_neutral(self): + self.assertEqual(axis_percent_to_manual(None), 0) + self.assertEqual(axis_percent_to_manual("abc"), 0) + self.assertEqual(throttle_percent_to_manual(None), 500) + + def test_axis_assignment(self): + r = axes_to_manual_control(surge=10, sway=20, yaw=30, heave=40) + self.assertEqual(r["x"], 100) # surge -> x + self.assertEqual(r["y"], 200) # sway -> y + self.assertEqual(r["r"], 300) # yaw -> r + self.assertEqual(r["z"], 700) # heave -> z (500 + 40*5) + + def test_buttons_masked_to_16bit(self): + self.assertEqual(axes_to_manual_control(buttons=0x1FFFF)["buttons"], 0xFFFF) + + +if __name__ == "__main__": + unittest.main() From 3e8dd1b6d1941e4934d11138124845a837313cc5 Mon Sep 17 00:00:00 2001 From: Customize5773 Date: Tue, 14 Jul 2026 01:35:51 +0700 Subject: [PATCH 2/4] feat: add session recording manager for REPLAY feature - Implemented recording.js to manage mission session recordings, including trajectory and video streams. - Added functionality to record telemetry and commands, store them in structured files, and manage session metadata. - Introduced a .gitignore file to prevent committing session data. - Created unit tests for the recording manager, covering session start/stop logic, telemetry logging, command tapping, and session listing/access. --- README-WORK.md | 87 +++++- public/index.html | 6 + public/js/app.js | 12 +- public/js/core.js | 7 +- public/js/pages/replay-scene.js | 181 +++++++++++++ public/js/pages/replay.js | 459 ++++++++++++++++++++++++++++++++ server/package.json | 3 +- server/recording.js | 450 +++++++++++++++++++++++++++++++ server/recordings/.gitignore | 3 + server/server.js | 101 +++++++ server/test/recording.test.js | 174 ++++++++++++ 11 files changed, 1478 insertions(+), 5 deletions(-) create mode 100644 public/js/pages/replay-scene.js create mode 100644 public/js/pages/replay.js create mode 100644 server/recording.js create mode 100644 server/recordings/.gitignore create mode 100644 server/test/recording.test.js diff --git a/README-WORK.md b/README-WORK.md index 5f5c127..1377f8e 100644 --- a/README-WORK.md +++ b/README-WORK.md @@ -197,7 +197,8 @@ permukaan. Bernilai **40% jika full-autonomous**, hanya **10% jika dilakukan rem di panel QR. `jsQR` sudah di-vendor (`public/vendor/jsqr.min.js`) agar jalan offline. - **Gripper:** tombol **OPEN/CLOSE** sudah ada di halaman Control (juga keyboard **H**/**G**) dan mengirim command `gripper` — dipakai Tahap 2, 3 & 5. -- **Replay camera & trajectory** (fitur opsional KKI) belum diimplementasikan. +- **Replay camera & trajectory** (fitur **nilai tambah**, di luar komponen wajib + §4.7.3) **sudah diimplementasikan** — lihat §8. ## 7. Joystick manual control (Gamepad → MANUAL_CONTROL) @@ -251,3 +252,87 @@ fisik, jadi aman berdampingan dengan konfigurasi channel/servo di Pixhawk (scope **Tombol joystick:** untuk task ini `buttons` MANUAL_CONTROL masih **placeholder = 0** (TODO). Aksi tombol (arm/mode/mount/light) sudah ditangani terpisah lewat command GUI existing. + +## 8. Replay Camera & Trajectory (nilai tambah, bukan §4.7.3 wajib) + +Fitur untuk **merekam** satu run misi lalu **memutar ulang** video 2 kamera + posisi +ROV di scene 3D **secara tersinkron**. Berguna sebagai bukti & bahan analisis pasca-run. +Ini **nilai tambah** (opsional), terpisah penuh dari jalur kontrol live — tidak pernah +bisa mengirim perintah ke ROV. + +### Cara pakai +1. Buka halaman **Replay** (sidebar). Pastikan URL kamera BOTTOM/WALL sudah diisi di + **Setup → Camera Stream** bila ingin ikut merekam video (opsional; trajectory tetap + terekam walau tanpa kamera). +2. Tekan **● Start Recording** tepat sebelum/di awal run misi. Badge berubah **REC ●**. + Rekaman berjalan **sepenuhnya di server** — tak masalah halaman mana yang dibuka. +3. Jalankan misi seperti biasa (Manual/Autonomous). Server mencatat tiap sampel + telemetry + command gerak (surge/sway) dan men-tap frame kedua kamera. +4. Tekan **■ Stop Recording** di akhir run. Sesi baru muncul di daftar **SESI TERSIMPAN**. +5. Klik sesi → video + lintasan termuat. Pakai **scrubber/timeline** (play/pause/seek): + video kedua kamera **dan** posisi ROV di scene 3D bergerak bersama sesuai timestamp. + +### Command / message baru (tidak mengubah command existing §3) +| Message WS | Arah | Fungsi | +|---|---|---| +| `record_start` | GUI→server | mulai rekam (kirim daftar `cameras:[{role,url}]`) | +| `record_stop` | GUI→server | hentikan rekam | +| `record_status` | server→GUI | status rekam (broadcast + saat connect) | + +> Ini **message type tersendiri**, sengaja **bukan** `type:"cmd"`, sehingga **tidak +> pernah** diteruskan ke UDP/ROV. Mode Replay tak punya jalur ke kontrol ROV. + +### Playback API (HTTP — konsisten dgn static server & proxy `/cam` existing) +| Endpoint | Fungsi | +|---|---| +| `GET /api/recordings` | daftar sesi (id, tanggal, durasi, ukuran, kamera) | +| `GET /recordings//meta.json` | metadata sesi (termasuk `session_start_time`) | +| `GET /recordings//trajectory.jsonl` | log telemetry berstempel waktu | +| `GET /recordings//commands.jsonl` | log command gerak berstempel waktu | +| `GET /replay/frame?session=&cam=&i=` | 1 frame JPEG dari mjpeg | + +Data replay bersifat historis/akses-acak → HTTP (bukan WebSocket) dipilih agar sejalan +dengan pola server yang sudah meng-serve file & mem-proxy kamera lewat HTTP; WS tetap +khusus push live sehingga live & replay tak bercampur. + +### Penyimpanan (`server/recordings//`) +``` +meta.json session_start_time (acuan sync), durasi, ukuran, jumlah sampel +trajectory.jsonl {t, heading, depth, roll, pitch} (append sinkron → durable) +commands.jsonl {t, name, value} (hanya surge/sway/yaw/heave/control_mode/set_surface) +bottom.mjpeg / wall.mjpeg frame JPEG mentah disambung +bottom.index.jsonl / wall.index.jsonl {t, off, len} per frame → seek per timestamp +``` +Folder `server/recordings/` **tidak di-commit** (ada `.gitignore` di dalamnya). + +### Keputusan teknis +- **Video = frame store (JPEG + index), bukan .webm.** Stream kamera aktual = **MJPEG** + (mjpg-streamer `?action=stream`). ffmpeg tidak tersedia di server, jadi encoding webm + butuh dependency berat + kurang presisi sinkron. Frame store: **nol dependency baru**, + sinkron **frame-accurate** (browser tinggal tukar `.src` per waktu scrubber). +- **Posisi x,y direkonstruksi di browser saat replay.** Server hanya punya heading/depth + (x,y adalah dead-reckoning di `mission.js`). Maka server merekam telemetry **+** command + surge/sway; halaman Replay merekonstruksi lintasan dengan integrator **identik** + `mission.js` (`VEL_SCALE`, `DEPTH_SCALE`, konvensi heading) → lintasan replay sepadan live. +- **Sinkronisasi** memakai satu clock server: `session_start_time` (meta.json) + timestamp + tiap sampel/frame. Scrubber menghitung `tc = session_start_time + posisi_slider`, lalu + memilih pose 3D & frame video dengan `t <= tc` (binary search). + +### Batas durasi/ukuran +Auto-stop di **`MAX_RECORD_MIN` menit (default 15)**, configurable via env: +`MAX_RECORD_MIN=10 node server.js`. Run KKI ~maks 20 menit (5 siap+10 misi+5 evakuasi), +15 menit cukup untuk fase misi+evakuasi. Saat auto-stop, server memberi warning ke log & +event dashboard. + +### Testing +- **Unit test server** (start/stop, tulis trajectory, tap command, listing, guard path, + baca frame): `cd server && npm test` (tanpa framework/dependency tambahan). +- **Manual test sinkron video+trajectory** (sinkronisasi visual sulit di-assert otomatis): + 1. `cd server && node server.js --sim` (atau `hydroship` di launch.json). + 2. Buka dashboard → halaman **Replay** → **Start Recording**. + 3. Di halaman **Control**, gerakkan ROV (W/S/A/D) beberapa detik agar trajectory bergerak. + Untuk video nyata, isi URL kamera dan pastikan feed tampil di halaman Camera. + 4. **Stop Recording** → klik sesi di daftar → tekan **play**. + 5. **Verifikasi:** garis lintasan & marker ROV bergerak; DENGAN kamera nyata, frame kedua + video maju seiring posisi ROV pada timestamp yang sama (geser scrubber untuk cek titik + tertentu). Bandingkan momen kunci (mis. saat gripper menutup) di video vs posisi 3D. diff --git a/public/index.html b/public/index.html index 988d0c7..b0dfacf 100644 --- a/public/index.html +++ b/public/index.html @@ -47,6 +47,9 @@ Joystick + + Replay + @@ -261,6 +264,9 @@ + + + diff --git a/public/js/app.js b/public/js/app.js index 020930f..c13d998 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -4,6 +4,7 @@ import { setServices, pilotAxes, snapshotImage, createRecorder, makeFullscreen, import { telemetryPage } from "./pages/telemetry.js"; import { missionPage } from "./pages/mission.js"; import { cameraPage } from "./pages/camera.js"; +import { replayPage } from "./pages/replay.js"; import { setupPage, loadSetup } from "./pages/setup.js"; import { joystickPage,handleJoystickConfigMessage} from "./pages/joystick.js"; import { joystickState,updateJoystickStateFromGamepad,getActiveButtonLayerName,} from "./joystick-state.js"; @@ -42,6 +43,7 @@ const pages = { telemetry: $("page-telemetry"), setup: $("page-setup"), joystick: $("page-joystick"), + replay: $("page-replay"), }; const navLinks = document.querySelectorAll(".sidebar__link"); @@ -53,6 +55,7 @@ const pageModules = { telemetry: telemetryPage, setup: setupPage, joystick: joystickPage, + replay: replayPage, }; const initedModules = new Set(); let activeModule = null; @@ -351,6 +354,11 @@ function connect() { handleJoystickConfigMessage(msg.data); log("Joystick config diterima dari server", "ok"); } + else if (msg.type === "record_status") { + // status rekaman server → halaman Replay (jika sudah di-init). Aman walau + // halaman Replay belum pernah dibuka. + try { if (replayPage.onRecordStatus) replayPage.onRecordStatus(msg.data); } catch (e) {} + } }; } @@ -367,8 +375,8 @@ function sendCmd(name, value, quiet = false) { if (!quiet) log(`CMD ${name} = ${value}`); } -// sediakan log & sendCmd untuk modul halaman -setServices({ log, sendCmd }); +// sediakan log, sendCmd & send (WS mentah) untuk modul halaman +setServices({ log, sendCmd, send }); function setLatency(ms) { els.lat.textContent = Math.round(ms); } // ping berkala untuk ukur latency diff --git a/public/js/core.js b/public/js/core.js index 7d2ccb7..8fcc14e 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -3,12 +3,17 @@ export const pilotAxes = { surge: 0, sway: 0, yaw: 0, heave: 0 }; /* layanan yang disuntik app.js */ let _log = (m) => console.log("[log]", m); let _sendCmd = (n, v) => console.log("[cmd]", n, v); -export function setServices({ log, sendCmd }) { +let _send = (obj) => console.log("[ws]", obj); +export function setServices({ log, sendCmd, send }) { if (log) _log = log; if (sendCmd) _sendCmd = sendCmd; + if (send) _send = send; } export function log(msg, level = "") { _log(msg, level); } export function sendCmd(name, value) { _sendCmd(name, value); } +/* kirim pesan WebSocket mentah (mis. {type:"record_start"}). Berbeda dari sendCmd: + TIDAK dibungkus type:"cmd", jadi tidak pernah diteruskan ke UDP/ROV. */ +export function wsSend(obj) { _send(obj); } /* unduh frame saat ini sebagai PNG. return false jika tak ada frame. */ export function snapshotImage(img, prefix = "hydroship_snapshot") { diff --git a/public/js/pages/replay-scene.js b/public/js/pages/replay-scene.js new file mode 100644 index 0000000..9dc0eec --- /dev/null +++ b/public/js/pages/replay-scene.js @@ -0,0 +1,181 @@ +// replay-scene.js — Renderer trajectory 3D untuk halaman Replay. +// +// SENGAJA merupakan modul terpisah (bukan mengimpor missionPage) supaya state +// live (mission.js: recording, dead-reckoning realtime) dan state replay tidak +// pernah bercampur — sesuai syarat "pisahkan state live vs replay". Desain scene +// (grid dasar, garis lintasan sonar, marker ROV, garis kedalaman, marker S/E, +// muat model FBX) diadaptasi dari mission.js `_buildScene` agar tampil identik, +// tanpa menyentuh/menyisipkan risiko regresi ke kode live Mission. +// +// Berbeda dari mission.js, modul ini TIDAK mengintegrasi posisi sendiri. Ia +// hanya menerima pose historis (setPose) dan seluruh lintasan (setPath) yang +// sudah direkonstruksi oleh replay.js dari log rekaman. + +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; +import { loadModelOnce, fitAndCenter, orient } from "../model.js"; +import { CONFIG } from "../config.js"; +import { log } from "../core.js"; + +const SONAR = 0x14d8ff; +const MAX_POINTS = 6000; // cukup untuk 1 run misi pada laju sampel telemetry + +export function createTrajectoryScene(container) { + const scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x05121a, 0.022); + + const w = container.clientWidth || 600, h = container.clientHeight || 400; + const camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 500); + camera.position.set(8, 9, 12); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(w, h); + container.appendChild(renderer.domElement); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + + scene.add(new THREE.HemisphereLight(0x9fdfff, 0x06121a, 1.0)); + const key = new THREE.DirectionalLight(0xffffff, 0.9); + key.position.set(5, 10, 4); + scene.add(key); + + const grid = new THREE.PolarGridHelper(30, 12, 8, 64, 0x1c3a45, 0x12252e); + scene.add(grid); + const sq = new THREE.GridHelper(60, 30, 0x16303c, 0x102029); + sq.position.y = -0.01; + scene.add(sq); + + // garis lintasan (seluruh path dimuat sekali; berapa yang tampak diatur setProgress) + const points = new Float32Array(MAX_POINTS * 3); + const pathGeo = new THREE.BufferGeometry(); + pathGeo.setAttribute("position", new THREE.BufferAttribute(points, 3)); + pathGeo.setDrawRange(0, 0); + const path = new THREE.Line(pathGeo, new THREE.LineBasicMaterial({ color: SONAR, transparent: true, opacity: 0.9 })); + scene.add(path); + + // marker ROV + const rov = new THREE.Group(); + const body = new THREE.Mesh( + new THREE.BoxGeometry(0.5, 0.28, 0.7), + new THREE.MeshStandardMaterial({ color: 0xcfd9e0, roughness: 0.5 }) + ); + rov.add(body); + const bow = new THREE.Mesh( + new THREE.ConeGeometry(0.16, 0.4, 16), + new THREE.MeshStandardMaterial({ color: SONAR, emissive: SONAR, emissiveIntensity: 0.7 }) + ); + bow.rotation.x = Math.PI / 2; + bow.position.z = 0.5; + rov.add(bow); + rov.rotation.order = "YXZ"; + scene.add(rov); + + // garis kedalaman dari permukaan ke ROV + const dropGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]); + const drop = new THREE.Line(dropGeo, new THREE.LineDashedMaterial({ color: SONAR, dashSize: 0.2, gapSize: 0.15, transparent: true, opacity: 0.4 })); + scene.add(drop); + + // marker Start / End + const sMark = marker("S", "#37d392"); + const eMark = marker("E", "#f5a524"); + sMark.visible = false; eMark.visible = false; + scene.add(sMark); scene.add(eMark); + + loadRovModel(rov); + + let count = 0; // jumlah titik path yang dimuat + let raf = null; + + function loop() { + raf = requestAnimationFrame(loop); + controls.update(); + renderer.render(scene, camera); + } + function start() { if (!raf) loop(); } + function stop() { if (raf) { cancelAnimationFrame(raf); raf = null; } } + + function resize() { + const w2 = container.clientWidth, h2 = container.clientHeight; + if (!w2 || !h2) return; + camera.aspect = w2 / h2; camera.updateProjectionMatrix(); + renderer.setSize(w2, h2); + } + + // poses: array {x,y,z,heading,roll,pitch} — muat seluruh lintasan sekali. + function setPath(poses) { + count = Math.min(poses.length, MAX_POINTS); + for (let i = 0; i < count; i++) { + const p = poses[i]; + points[i * 3] = p.x; + points[i * 3 + 1] = p.y; + points[i * 3 + 2] = p.z; + } + pathGeo.setDrawRange(0, 0); + pathGeo.attributes.position.needsUpdate = true; + pathGeo.computeBoundingSphere(); + if (count > 0) { + sMark.visible = true; + sMark.position.set(poses[0].x, poses[0].y + 0.6, poses[0].z); + eMark.visible = true; + eMark.position.set(poses[count - 1].x, poses[count - 1].y + 0.6, poses[count - 1].z); + } else { + sMark.visible = false; eMark.visible = false; + } + } + + // tampilkan lintasan sampai n titik (jejak progresif mengikuti scrubber) + function setProgress(n) { + pathGeo.setDrawRange(0, Math.max(0, Math.min(n, count))); + } + + function setPose(p) { + if (!p) return; + rov.position.set(p.x, p.y, p.z); + rov.rotation.y = -THREE.MathUtils.degToRad(p.heading || 0); + rov.rotation.x = THREE.MathUtils.degToRad(p.pitch || 0); + rov.rotation.z = THREE.MathUtils.degToRad(-(p.roll || 0)); + dropGeo.setFromPoints([new THREE.Vector3(p.x, 0, p.z), new THREE.Vector3(p.x, p.y, p.z)]); + drop.computeLineDistances(); + } + + function dispose() { + stop(); + try { renderer.dispose(); } catch (e) {} + if (renderer.domElement && renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + } + + return { start, stop, resize, setPath, setProgress, setPose, dispose, get pointCount() { return count; } }; +} + +function marker(text, color) { + const c = document.createElement("canvas"); + c.width = c.height = 128; + const x = c.getContext("2d"); + x.fillStyle = color; + x.beginPath(); x.arc(64, 64, 52, 0, Math.PI * 2); x.fill(); + x.fillStyle = "#04121e"; + x.font = "bold 72px 'Chakra Petch', sans-serif"; + x.textAlign = "center"; x.textBaseline = "middle"; + x.fillText(text, 64, 70); + const tex = new THREE.CanvasTexture(c); + const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false })); + spr.scale.set(1.1, 1.1, 1.1); + return spr; +} + +function loadRovModel(rovGroup) { + const url = CONFIG.MODEL_URL; + if (!url) return; + loadModelOnce(url).then((base) => { + const model = base.clone(true); + orient(model, url, true); + fitAndCenter(model, 1.0); + while (rovGroup.children.length) rovGroup.remove(rovGroup.children[0]); + rovGroup.add(model); + log("Replay: model ROV dimuat", "ok"); + }).catch(() => log("Replay: gagal muat model ROV, memakai marker built-in", "warn")); +} diff --git a/public/js/pages/replay.js b/public/js/pages/replay.js new file mode 100644 index 0000000..9736d3b --- /dev/null +++ b/public/js/pages/replay.js @@ -0,0 +1,459 @@ +// replay.js — Halaman REPLAY (fitur nilai tambah KKI 2026, §4.7 opsional). +// +// Dua fungsi: +// 1. REKAM sesi : tombol Start/Stop mengirim {type:"record_start"/"record_stop"} +// via WebSocket mentah (wsSend) — BUKAN command "cmd", jadi tak +// pernah diteruskan ke ROV. Rekaman berjalan sepenuhnya di server. +// 2. PUTAR ULANG : pilih sesi tersimpan, lalu scrubber timeline menggerakkan +// video 2 kamera DAN posisi ROV di scene 3D SECARA BERSAMAAN, +// memakai session_start_time sebagai acuan waktu bersama. +// +// ISOLASI (syarat task): halaman ini punya state sendiri dan TIDAK memakai jalur +// kontrol live. Satu-satunya pesan keluar adalah record_start/record_stop. Tidak +// ada kode di sini yang bisa mengirim surge/sway/arm/mode ke ROV. +// +// Posisi x,y ROV tidak ada di log (server hanya punya heading/depth). Ia +// direkonstruksi di sini dengan integrator dead-reckoning yang SAMA seperti +// mission.js (VEL_SCALE, DEPTH_SCALE, konvensi heading), dari log trajectory + +// command surge/sway — sehingga lintasan replay sepadan dengan tampilan live. + +import { CONFIG } from "../config.js"; +import { log, num, wsSend } from "../core.js"; +import { createTrajectoryScene } from "./replay-scene.js"; + +// Konstanta dead-reckoning — HARUS sama dengan mission.js agar konsisten. +const VEL_SCALE = 0.02; +const DEPTH_SCALE = 0.5; + +export const replayPage = { + visible: false, + scene: null, + els: {}, + recording: false, + session: null, // sesi yang sedang diputar (data + timeline) + playing: false, + playT: 0, // waktu putar relatif (ms sejak session_start_time) + lastFrameTs: 0, // performance.now() terakhir loop putar + raf: null, + + init(root) { + root.innerHTML = ` +
+
+
+ RECORD & REPLAY +

Replay Sesi Misi

+
+
+ + IDLE +
+
+ +
+ + +
+
+
+
Pilih sesi untuk memuat lintasan & video
+
+ +
+
+ + 00:00.0 / 00:00.0 + +
+ +
+
+
+
`; + + this._injectStyle(); + + this.els.recBtn = root.querySelector("#rpRecBtn"); + this.els.recState = root.querySelector("#rpRecState"); + this.els.list = root.querySelector("#rpList"); + this.els.cams = root.querySelector("#rpCams"); + this.els.sceneHost = root.querySelector("#rpScene"); + this.els.sceneHint = root.querySelector("#rpSceneHint"); + this.els.play = root.querySelector("#rpPlay"); + this.els.scrub = root.querySelector("#rpScrub"); + this.els.time = root.querySelector("#rpTime"); + this.els.meta = root.querySelector("#rpMeta"); + + this.els.recBtn.onclick = () => this._toggleRecord(); + root.querySelector("#rpRefresh").onclick = () => this._loadSessions(); + this.els.play.onclick = () => this._togglePlay(); + this.els.scrub.oninput = () => { + this._pause(); + this.playT = Number(this.els.scrub.value); + this._applyTime(this.playT); + }; + + // scene 3D dibangun sekali (kontainer sudah punya ukuran saat init dipanggil) + this.scene = createTrajectoryScene(this.els.sceneHost); + + this._loadSessions(); + }, + + onShow() { + this.visible = true; + if (this.scene) { this.scene.resize(); this.scene.start(); } + this._loadSessions(); + }, + onHide() { + this.visible = false; + this._pause(); + if (this.scene) this.scene.stop(); + }, + + /* ===================== REKAMAN ===================== */ + + // status rekaman dari server (dipanggil app.js saat pesan record_status tiba) + onRecordStatus(st) { + this.recording = !!(st && st.recording); + if (!this.els.recState) return; + if (this.recording) { + const mins = st.max_record_ms ? Math.round(st.max_record_ms / 60000) : null; + this.els.recState.textContent = "REC ●"; + this.els.recState.classList.add("badge--active"); + this.els.recBtn.textContent = "■ Stop Recording"; + this.els.recBtn.classList.remove("chip--go"); + this.els.meta.textContent = mins ? `Merekam… (auto-stop ${mins} mnt)` : "Merekam…"; + } else { + this.els.recState.textContent = "IDLE"; + this.els.recState.classList.remove("badge--active"); + this.els.recBtn.textContent = "● Start Recording"; + this.els.recBtn.classList.add("chip--go"); + // segarkan daftar sesi begitu rekaman selesai (muncul entri baru) + this._loadSessions(); + } + }, + + _toggleRecord() { + if (this.recording) { + wsSend({ type: "record_stop" }); + log("Replay: minta stop rekaman", "warn"); + return; + } + // kirim URL kamera saat ini agar server bisa men-tap stream MJPEG-nya + const cameras = (CONFIG.CAMERAS || []) + .filter((c) => c && c.url) + .map((c) => ({ role: String(c.role || c.id || "").toLowerCase(), url: c.url })); + if (!cameras.length) { + log("Replay: tidak ada URL kamera — merekam trajectory saja (tanpa video)", "warn"); + } + wsSend({ type: "record_start", cameras, label: CONFIG.TEAM_NAME || "" }); + log("Replay: minta mulai rekaman", "ok"); + }, + + /* ===================== DAFTAR SESI ===================== */ + + async _loadSessions() { + let list = []; + try { + const res = await fetch("/api/recordings", { cache: "no-store" }); + list = await res.json(); + } catch (e) { + this.els.list.innerHTML = `
  • Gagal memuat daftar sesi
  • `; + return; + } + if (!Array.isArray(list) || !list.length) { + this.els.list.innerHTML = `
  • Belum ada rekaman. Tekan Start Recording saat menjalankan misi.
  • `; + return; + } + this.els.list.innerHTML = ""; + for (const s of list) { + const li = document.createElement("li"); + li.className = "replay__item"; + const dur = fmtClock(s.duration_ms || 0); + const size = fmtBytes(s.bytes || 0); + const cams = (s.cameras || []).join("+") || "—"; + const rec = s.status === "recording" ? ` REC` : ""; + li.innerHTML = ` +
    ${s.id}${rec}
    +
    ${dur} · ${cams} · ${size} · ${s.trajectory_samples || 0} smpl
    `; + li.onclick = () => { + this.els.list.querySelectorAll(".replay__item").forEach((x) => x.classList.remove("is-active")); + li.classList.add("is-active"); + this._loadSession(s.id); + }; + this.els.list.appendChild(li); + } + }, + + /* ===================== MUAT & PUTAR SATU SESI ===================== */ + + async _loadSession(id) { + this._pause(); + this.els.sceneHint.style.display = "none"; + this.els.meta.textContent = "Memuat sesi…"; + try { + const meta = await fetchJSON(`/recordings/${id}/meta.json`); + const traj = await fetchJSONL(`/recordings/${id}/trajectory.jsonl`); + const cmds = await fetchJSONL(`/recordings/${id}/commands.jsonl`).catch(() => []); + + // index frame video per kamera yang direkam + const camFrames = {}; + for (const cam of (meta.cameras || [])) { + if (!cam.frames) continue; + const idx = await fetchJSONL(`/recordings/${id}/${cam.role}.index.jsonl`).catch(() => []); + if (idx.length) camFrames[cam.role] = idx; + } + + const poses = reconstructTrajectory(traj, cmds); + const t0 = meta.session_start_time || (traj[0] && traj[0].t) || Date.now(); + const duration = Math.max(meta.duration_ms || 0, lastT(poses, camFrames) - t0, 0); + + this.session = { id, meta, t0, duration, poses, camFrames }; + this.scene.setPath(poses); + this._buildCamViews(camFrames); + + // siapkan scrubber + this.els.scrub.min = 0; + this.els.scrub.max = Math.max(1, Math.round(duration)); + this.els.scrub.step = 1; + this.els.scrub.value = 0; + this.els.scrub.disabled = false; + this.els.play.disabled = false; + this.playT = 0; + this._applyTime(0); + this.els.meta.textContent = + `${(meta.cameras || []).length} kamera · ${poses.length} pose · ${fmtBytes((meta.cameras || []).reduce((a, c) => a + (c.bytes || 0), 0))}`; + log(`Replay: sesi ${id} dimuat (${poses.length} pose)`, "ok"); + } catch (e) { + this.els.meta.textContent = "Gagal memuat sesi"; + log(`Replay: gagal memuat sesi ${id} — ${e.message}`, "err"); + } + }, + + _buildCamViews(camFrames) { + this.els.cams.innerHTML = ""; + this._camImgs = {}; + const roles = Object.keys(camFrames); + if (!roles.length) { + this.els.cams.innerHTML = `
    Sesi ini tanpa rekaman video
    `; + return; + } + for (const role of roles) { + const cell = document.createElement("div"); + cell.className = "camcell replay__camcell"; + cell.innerHTML = ` +
    ${role.toUpperCase()} REPLAY
    +
    ${role}
    `; + this.els.cams.appendChild(cell); + this._camImgs[role] = { img: cell.querySelector("img"), lastIdx: -1 }; + } + }, + + /* ===================== TRANSPORT ===================== */ + + _togglePlay() { + if (this.playing) this._pause(); + else this._play(); + }, + _play() { + if (!this.session) return; + if (this.playT >= this.session.duration) this.playT = 0; // ulang dari awal + this.playing = true; + this.els.play.textContent = "❚❚"; + this.lastFrameTs = performance.now(); + if (!this.raf) this._loop(); + }, + _pause() { + this.playing = false; + if (this.els.play) this.els.play.textContent = "▶"; + if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } + }, + + _loop() { + this.raf = requestAnimationFrame(() => this._loop()); + if (!this.playing || !this.session) return; + const now = performance.now(); + const dt = now - this.lastFrameTs; + this.lastFrameTs = now; + this.playT += dt; + if (this.playT >= this.session.duration) { + this.playT = this.session.duration; + this._applyTime(this.playT); + this._pause(); + return; + } + this.els.scrub.value = Math.round(this.playT); + this._applyTime(this.playT); + }, + + // Inti sinkronisasi: satu waktu tc menggerakkan scene 3D DAN video bersama. + _applyTime(relMs) { + if (!this.session) return; + const tc = this.session.t0 + relMs; + + // --- posisi ROV di scene 3D --- + const poses = this.session.poses; + if (poses.length) { + const i = lastIndexLE(poses, tc); + if (i >= 0) { this.scene.setPose(poses[i]); this.scene.setProgress(i + 1); } + } + + // --- frame video tiap kamera pada timestamp yang sama --- + for (const role of Object.keys(this._camImgs || {})) { + const frames = this.session.camFrames[role]; + const view = this._camImgs[role]; + if (!frames || !view) continue; + const fi = lastIndexLE(frames, tc); + if (fi >= 0 && fi !== view.lastIdx) { + view.lastIdx = fi; + view.img.src = `/replay/frame?session=${encodeURIComponent(this.session.id)}&cam=${role}&i=${fi}`; + } + } + + // --- label waktu --- + this.els.time.textContent = `${fmtClock(relMs)} / ${fmtClock(this.session.duration)}`; + }, + + _injectStyle() { + if (document.getElementById("replay-style")) return; + const st = document.createElement("style"); + st.id = "replay-style"; + st.textContent = ` + .replay { display:flex; flex-direction:column; gap:14px; height:100%; } + .replay__body { display:grid; grid-template-columns: 260px 1fr; gap:14px; flex:1; min-height:0; } + .replay__sessions { display:flex; flex-direction:column; min-height:0; border:1px solid rgba(255,255,255,.08); border-radius:10px; padding:10px; background:rgba(255,255,255,.02); } + .replay__sessions-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; } + .replay__list { list-style:none; margin:0; padding:0; overflow-y:auto; flex:1; } + .replay__item { padding:8px 10px; border-radius:8px; cursor:pointer; border:1px solid transparent; } + .replay__item:hover { background:rgba(255,255,255,.05); } + .replay__item.is-active { border-color:#14d8ff; background:rgba(20,216,255,.08); } + .replay__item-id { font-weight:600; font-size:.86rem; } + .replay__item-sub { font-size:.72rem; opacity:.65; margin-top:2px; } + .replay__rec { color:#ff5a5a; font-weight:700; font-size:.7rem; } + .replay__empty { padding:10px; font-size:.78rem; opacity:.6; } + .replay__stage { display:flex; flex-direction:column; gap:12px; min-height:0; } + .replay__cams { min-height:120px; } + .replay__scene { position:relative; flex:1; min-height:260px; } + .replay__novideo, .replay__camcell { min-height:120px; } + .replay__novideo { display:flex; align-items:center; justify-content:center; opacity:.6; font-size:.8rem; border:1px dashed rgba(255,255,255,.15); border-radius:10px; } + .replay__camcell .camcell__view img { width:100%; height:100%; object-fit:contain; background:#04121e; } + .replay__timeline { display:flex; flex-direction:column; gap:6px; } + .replay__transport { display:flex; align-items:center; gap:12px; } + .replay__time { font-variant-numeric:tabular-nums; font-size:.82rem; opacity:.85; } + .replay__meta { font-size:.74rem; opacity:.6; } + .replay__scrub { width:100%; } + `; + document.head.appendChild(st); + }, +}; + +/* ===================== REKONSTRUKSI TRAJECTORY ===================== */ +// Menghasilkan array pose {t,x,y,z,heading,roll,pitch} dari log telemetry + +// command surge/sway, memakai integrator identik mission.js. +function reconstructTrajectory(traj, cmds) { + const samples = (traj || []).filter((s) => Number.isFinite(s.t)).sort((a, b) => a.t - b.t); + if (!samples.length) return []; + + const surgeCmds = (cmds || []).filter((c) => c.name === "surge").sort((a, b) => a.t - b.t); + const swayCmds = (cmds || []).filter((c) => c.name === "sway").sort((a, b) => a.t - b.t); + + let px = 0, pz = 0; // posisi bidang dasar + let si = 0, wi = 0; // pointer command + let surge = 0, sway = 0; + const poses = []; + + for (let k = 0; k < samples.length; k++) { + const s = samples[k]; + if (k > 0) { + const tPrev = samples[k - 1].t; + // command yang aktif hingga awal interval + while (si < surgeCmds.length && surgeCmds[si].t <= tPrev) surge = numOr0(surgeCmds[si++].value); + while (wi < swayCmds.length && swayCmds[wi].t <= tPrev) sway = numOr0(swayCmds[wi++].value); + + let dt = (s.t - tPrev) / 1000; + if (!(dt > 0)) dt = 0; + if (dt > 0.5) dt = 0.5; // batasi lompatan bila ada jeda telemetry + + const hr = deg2rad(numOr0(samples[k - 1].heading)); + // heading 0 = utara (-Z); timur = +X (konvensi mission.js) + const fx = Math.sin(hr), fz = -Math.cos(hr); + const rx = Math.cos(hr), rz = Math.sin(hr); + px += (fx * surge + rx * sway) * VEL_SCALE * dt; + pz += (fz * surge + rz * sway) * VEL_SCALE * dt; + } + const depth = numOr0(s.depth); + poses.push({ + t: s.t, + x: px, + y: -depth * DEPTH_SCALE, + z: pz, + heading: numOr0(s.heading), + roll: numOr0(s.roll), + pitch: numOr0(s.pitch), + }); + } + return poses; +} + +/* ===================== UTIL ===================== */ +function numOr0(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } +function deg2rad(d) { return d * Math.PI / 180; } + +// indeks terbesar dgn arr[i].t <= t (binary search). -1 bila semua > t. +function lastIndexLE(arr, t) { + let lo = 0, hi = arr.length - 1, res = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (arr[mid].t <= t) { res = mid; lo = mid + 1; } + else hi = mid - 1; + } + return res; +} + +function lastT(poses, camFrames) { + let m = poses.length ? poses[poses.length - 1].t : 0; + for (const role of Object.keys(camFrames || {})) { + const f = camFrames[role]; + if (f && f.length) m = Math.max(m, f[f.length - 1].t); + } + return m; +} + +async function fetchJSON(url) { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) throw new Error(`${r.status} ${url}`); + return r.json(); +} +async function fetchJSONL(url) { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) throw new Error(`${r.status} ${url}`); + const text = await r.text(); + const out = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { out.push(JSON.parse(line)); } catch (e) {} + } + return out; +} + +function fmtClock(ms) { + ms = Math.max(0, ms || 0); + const total = ms / 1000; + const m = Math.floor(total / 60); + const s = Math.floor(total % 60); + const d = Math.floor((ms % 1000) / 100); + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${d}`; +} +function fmtBytes(b) { + if (!b) return "0 B"; + const u = ["B", "KB", "MB", "GB"]; + let i = 0, n = b; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; +} diff --git a/server/package.json b/server/package.json index 820a068..512e519 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,8 @@ "main": "server.js", "scripts": { "start": "node server.js", - "sim": "node server.js --sim" + "sim": "node server.js --sim", + "test": "node test/recording.test.js" }, "dependencies": { "ws": "^8.18.0" diff --git a/server/recording.js b/server/recording.js new file mode 100644 index 0000000..28e43a6 --- /dev/null +++ b/server/recording.js @@ -0,0 +1,450 @@ +// recording.js — Manajer rekaman sesi misi untuk fitur REPLAY (nilai tambah KKI 2026). +// +// Merekam, PER SESI, dua hal yang tersinkron pada satu clock server: +// 1. Trajectory : sampel telemetry (heading, depth, roll, pitch) + command gerak +// (surge/sway/yaw/heave/control_mode/set_surface) — keduanya +// berstempel waktu. Posisi x,y direkonstruksi saat replay di +// browser dengan integrator dead-reckoning yang SAMA seperti +// mission.js (posisi x,y tidak pernah ada di server — ia diturunkan +// di browser dari surge/sway + heading). +// 2. Video : stream MJPEG kamera BOTTOM & WALL ditap frame-per-frame. Tiap +// frame JPEG mentah ditulis apa adanya ke `.mjpeg`, dengan +// index `.index.jsonl` ({t, off, len}) agar bisa di-seek per +// timestamp saat replay. TANPA ffmpeg / dependency encoding berat. +// +// Semua tersimpan di server (laptop operator), bukan di Raspberry Pi: +// server/recordings// +// meta.json metadata sesi (session_start_time, durasi, ukuran, dst) +// trajectory.jsonl {t, heading, depth, roll, pitch} +// commands.jsonl {t, name, value} (hanya command relevan trajectory) +// bottom.mjpeg JPEG BOTTOM disambung +// bottom.index.jsonl {t, off, len} per frame +// wall.mjpeg / wall.index.jsonl +// +// Modul ini SENGAJA terpisah dari jalur command→UDP→ROV: ia hanya "menguping" +// telemetry & command, tidak pernah mengirim apa pun ke ROV. + +const http = require("http"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); + +// Folder penyimpanan (override via env berguna untuk unit test). +const RECORDINGS_DIR = process.env.HYDROSHIP_RECORDINGS_DIR || path.join(__dirname, "recordings"); + +// Batas durasi rekam (menit). Run misi KKI ~ maks 20 menit (5 siap + 10 misi + +// 5 evakuasi, lihat README-WORK §4). Default aman 15 menit; configurable via env. +const MAX_RECORD_MIN = parseFloat(process.env.MAX_RECORD_MIN || "15"); +const MAX_RECORD_MS = Math.max(1, MAX_RECORD_MIN) * 60 * 1000; + +// Peran kamera yang valid untuk direkam (selaras CONFIG.CAMERAS di browser). +const VALID_ROLES = new Set(["bottom", "wall"]); + +// Command yang relevan untuk rekonstruksi trajectory saat replay. Hanya ini yang +// ikut dicatat — sisanya (arm/light/gripper/dll) tak memengaruhi posisi. +const TRAJ_COMMANDS = new Set(["surge", "sway", "yaw", "heave", "control_mode", "set_surface"]); + +// Penanda JPEG: SOI (Start Of Image) & EOI (End Of Image). +const JPEG_SOI = Buffer.from([0xff, 0xd8]); +const JPEG_EOI = Buffer.from([0xff, 0xd9]); +// Jaring pengaman: buang buffer parse bila membengkak tanpa menemукan frame utuh +// (mis. upstream bukan MJPEG). ~8 MB cukup longgar untuk 1 frame 1080p. +const MAX_PARSE_BUFFER = 8 * 1024 * 1024; + +let active = null; // sesi yang sedang direkam (satu pada satu waktu) + +/* ------------------------------------------------------------------ */ +/* util */ +/* ------------------------------------------------------------------ */ + +function ensureRecordingsDir() { + if (!fs.existsSync(RECORDINGS_DIR)) fs.mkdirSync(RECORDINGS_DIR, { recursive: true }); +} + +// id sesi berbasis waktu lokal: 2026-07-14_09-30-05-123 → mudah diurut & dibaca. +// Milidetik disertakan agar dua run dalam detik yang sama tidak bertabrakan id. +function makeSessionId(d = new Date()) { + const p = (n) => String(n).padStart(2, "0"); + return ( + `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` + + `_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}` + + `-${String(d.getMilliseconds()).padStart(3, "0")}` + ); +} + +// Cegah path traversal: hanya izinkan id sesi "polos" (tanpa / .. dsb). +function isSafeId(id) { + return typeof id === "string" && /^[A-Za-z0-9_\-]+$/.test(id) && id.length <= 64; +} + +function isRecording() { + return !!active; +} + +/* ------------------------------------------------------------------ */ +/* MJPEG frame tap */ +/* ------------------------------------------------------------------ */ + +// Buka stream MJPEG satu kamera & tulis tiap frame JPEG ke file + index. +// `cam` adalah objek state kamera pada sesi aktif. +function openCameraTap(cam, allowHost, onWarn) { + let target; + try { + target = new URL(cam.url); + } catch { + onWarn(`URL kamera ${cam.role} tidak valid: ${cam.url}`); + return; + } + if (target.protocol !== "http:" && target.protocol !== "https:") { + onWarn(`Protokol kamera ${cam.role} tidak didukung: ${target.protocol}`); + return; + } + if (typeof allowHost === "function" && !allowHost(target.hostname)) { + onWarn(`Host kamera ${cam.role} tidak diizinkan: ${target.hostname}`); + return; + } + + const mod = target.protocol === "https:" ? https : http; + let parseBuf = Buffer.alloc(0); + + const req = mod.get(cam.url, (res) => { + if (!res.statusCode || res.statusCode >= 400) { + onWarn(`Kamera ${cam.role} status ${res.statusCode}`); + res.resume(); + return; + } + res.on("data", (chunk) => { + parseBuf = parseBuf.length ? Buffer.concat([parseBuf, chunk]) : chunk; + + // Ekstrak setiap JPEG utuh (SOI..EOI) dari buffer berjalan. + while (true) { + const soi = parseBuf.indexOf(JPEG_SOI); + if (soi < 0) { + // belum ada awal frame; jaga buffer tidak membengkak tak terbatas + if (parseBuf.length > MAX_PARSE_BUFFER) parseBuf = Buffer.alloc(0); + break; + } + const eoi = parseBuf.indexOf(JPEG_EOI, soi + 2); + if (eoi < 0) { + // frame belum lengkap; buang byte sebelum SOI, tunggu sisanya + if (soi > 0) parseBuf = parseBuf.slice(soi); + if (parseBuf.length > MAX_PARSE_BUFFER) parseBuf = Buffer.alloc(0); + break; + } + const frame = parseBuf.slice(soi, eoi + 2); + parseBuf = parseBuf.slice(eoi + 2); + writeFrame(cam, frame); + } + }); + res.on("end", () => onWarn(`Stream kamera ${cam.role} berakhir (upstream end)`)); + res.on("error", (e) => onWarn(`Stream kamera ${cam.role} error: ${e.message}`)); + }); + + req.on("error", (e) => onWarn(`Koneksi kamera ${cam.role} gagal: ${e.message}`)); + cam.req = req; +} + +function writeFrame(cam, frame) { + if (!cam.mjpegStream || !cam.indexStream) return; + const t = Date.now(); + const off = cam.bytes; + const len = frame.length; + cam.mjpegStream.write(frame); + cam.indexStream.write(JSON.stringify({ t, off, len }) + "\n"); + cam.bytes += len; + cam.frames += 1; +} + +/* ------------------------------------------------------------------ */ +/* start / stop */ +/* ------------------------------------------------------------------ */ + +// cameras : [{ role: "bottom"|"wall", url: "http://..." }, ...] (boleh kosong) +// opts : { allowHost?, label?, onStatus?, onWarn? } +// return : ringkasan meta sesi. +function startSession(cameras, opts = {}) { + if (active) throw new Error("sesi rekaman sudah berjalan"); + ensureRecordingsDir(); + + const allowHost = opts.allowHost; + const onWarn = typeof opts.onWarn === "function" ? opts.onWarn : () => {}; + const startTime = Date.now(); + const id = makeSessionId(new Date(startTime)); + const dir = path.join(RECORDINGS_DIR, id); + fs.mkdirSync(dir, { recursive: true }); + + const cams = {}; + const camList = Array.isArray(cameras) ? cameras : []; + for (const c of camList) { + const role = String(c && c.role || "").toLowerCase(); + if (!VALID_ROLES.has(role) || !c.url) continue; + if (cams[role]) continue; // hindari duplikat peran + const cam = { + role, + url: c.url, + frames: 0, + bytes: 0, + mjpegStream: fs.createWriteStream(path.join(dir, `${role}.mjpeg`)), + indexStream: fs.createWriteStream(path.join(dir, `${role}.index.jsonl`)), + req: null, + }; + cams[role] = cam; + } + + active = { + id, + dir, + label: typeof opts.label === "string" ? opts.label.slice(0, 120) : "", + startTime, + // Log kecil laju-rendah → append sinkron: durable bila server crash di + // tengah run, dan langsung terbaca (tanpa menunggu buffer stream ter-flush). + trajPath: path.join(dir, "trajectory.jsonl"), + cmdPath: path.join(dir, "commands.jsonl"), + cams, + telemCount: 0, + cmdCount: 0, + timer: null, + onStatus: typeof opts.onStatus === "function" ? opts.onStatus : () => {}, + }; + + // tulis meta awal (status recording) + writeMeta("recording"); + + // buka tap tiap kamera + for (const role of Object.keys(cams)) { + openCameraTap(cams[role], allowHost, onWarn); + } + + // auto-stop di batas durasi (cegah file membengkak tak terkendali) + const onStatus = active.onStatus; + active.timer = setTimeout(() => { + onWarn(`Rekaman mencapai batas ${MAX_RECORD_MIN} menit — auto-stop.`); + const meta = stopSession("max_duration"); + if (meta) { try { onStatus(meta); } catch {} } + }, MAX_RECORD_MS); + + return summary(); +} + +function stopSession(reason = "manual") { + if (!active) return null; + const s = active; + + if (s.timer) clearTimeout(s.timer); + + // tutup semua stream kamera & file + for (const role of Object.keys(s.cams)) { + const cam = s.cams[role]; + try { if (cam.req) cam.req.destroy(); } catch {} + try { cam.mjpegStream.end(); } catch {} + try { cam.indexStream.end(); } catch {} + } + + const stopTime = Date.now(); + s.stopTime = stopTime; + s.stopReason = reason; + const meta = writeMeta("stopped"); + + active = null; + return meta; +} + +/* ------------------------------------------------------------------ */ +/* tap telemetry & command (dipanggil dari server.js) */ +/* ------------------------------------------------------------------ */ + +function onTelemetry(data) { + if (!active || !data) return; + const line = { + t: Date.now(), + heading: numOrNull(data.heading), + depth: numOrNull(data.depth), + roll: numOrNull(data.roll), + pitch: numOrNull(data.pitch), + }; + try { fs.appendFileSync(active.trajPath, JSON.stringify(line) + "\n"); } catch {} + active.telemCount += 1; +} + +function onCommand(name, value) { + if (!active || !TRAJ_COMMANDS.has(name)) return; + try { fs.appendFileSync(active.cmdPath, JSON.stringify({ t: Date.now(), name, value }) + "\n"); } catch {} + active.cmdCount += 1; +} + +function numOrNull(v) { + // null/undefined/"" → null (field telemetry hilang), bukan 0 (Number(null)===0). + if (v === null || v === undefined || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/* ------------------------------------------------------------------ */ +/* metadata & listing */ +/* ------------------------------------------------------------------ */ + +function buildMeta(status) { + const s = active; + const now = Date.now(); + const stopTime = s.stopTime || (status === "stopped" ? now : null); + return { + id: s.id, + label: s.label, + status, + session_start_time: s.startTime, // epoch ms — acuan sync video+trajectory + started_at: new Date(s.startTime).toISOString(), + stopped_at: stopTime ? new Date(stopTime).toISOString() : null, + duration_ms: stopTime ? stopTime - s.startTime : now - s.startTime, + max_record_ms: MAX_RECORD_MS, + stop_reason: s.stopReason || null, + trajectory_samples: s.telemCount, + command_samples: s.cmdCount, + cameras: Object.keys(s.cams).map((role) => ({ + role, + url: s.cams[role].url, + frames: s.cams[role].frames, + bytes: s.cams[role].bytes, + })), + }; +} + +function writeMeta(status) { + const meta = buildMeta(status); + try { + fs.writeFileSync(path.join(active.dir, "meta.json"), JSON.stringify(meta, null, 2)); + } catch {} + return meta; +} + +function summary() { + return active ? buildMeta("recording") : null; +} + +// Status ringkas untuk dikirim ke dashboard. +function status() { + if (!active) return { recording: false }; + return { + recording: true, + id: active.id, + session_start_time: active.startTime, + duration_ms: Date.now() - active.startTime, + max_record_ms: MAX_RECORD_MS, + cameras: Object.keys(active.cams), + }; +} + +// Daftar seluruh sesi rekaman (baca meta.json tiap folder). +function listSessions() { + ensureRecordingsDir(); + let entries; + try { + entries = fs.readdirSync(RECORDINGS_DIR, { withFileTypes: true }); + } catch { + return []; + } + const out = []; + for (const e of entries) { + if (!e.isDirectory() || !isSafeId(e.name)) continue; + const metaPath = path.join(RECORDINGS_DIR, e.name, "meta.json"); + try { + const meta = JSON.parse(fs.readFileSync(metaPath, "utf8")); + const totalBytes = (meta.cameras || []).reduce((a, c) => a + (c.bytes || 0), 0); + out.push({ + id: meta.id || e.name, + label: meta.label || "", + status: meta.status || "unknown", + session_start_time: meta.session_start_time || null, + started_at: meta.started_at || null, + duration_ms: meta.duration_ms || 0, + trajectory_samples: meta.trajectory_samples || 0, + cameras: (meta.cameras || []).map((c) => c.role), + bytes: totalBytes, + }); + } catch { + // folder tanpa meta valid → lewati (mis. rekaman yang crash di tengah) + } + } + // terbaru dulu + out.sort((a, b) => (b.session_start_time || 0) - (a.session_start_time || 0)); + return out; +} + +/* ------------------------------------------------------------------ */ +/* akses file untuk HTTP playback */ +/* ------------------------------------------------------------------ */ + +// Path file dalam folder sesi, dengan guard traversal. null bila tidak aman/ada. +function resolveSessionFile(id, name) { + if (!isSafeId(id)) return null; + if (typeof name !== "string" || name.includes("/") || name.includes("\\") || name.includes("..")) { + return null; + } + const p = path.join(RECORDINGS_DIR, id, name); + if (!p.startsWith(RECORDINGS_DIR + path.sep)) return null; + return p; +} + +// Cache index frame per (id/role) agar seek saat scrubbing tidak baca-ulang file. +const _indexCache = new Map(); +function loadFrameIndex(id, role) { + if (!isSafeId(id) || !VALID_ROLES.has(role)) return null; + const key = `${id}/${role}`; + const file = path.join(RECORDINGS_DIR, id, `${role}.index.jsonl`); + let stat; + try { stat = fs.statSync(file); } catch { return null; } + const cached = _indexCache.get(key); + if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) { + return cached.frames; + } + const frames = []; + try { + const raw = fs.readFileSync(file, "utf8"); + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + const o = JSON.parse(line); + if (Number.isFinite(o.off) && Number.isFinite(o.len)) frames.push(o); + } + } catch { + return null; + } + _indexCache.set(key, { size: stat.size, mtimeMs: stat.mtimeMs, frames }); + return frames; +} + +// Ambil satu frame JPEG (Buffer) dari sesi. cb(err, buf). +function getFrame(id, role, index, cb) { + const frames = loadFrameIndex(id, role); + if (!frames || !frames.length) return cb(new Error("index frame tidak ada")); + let i = Number(index); + if (!Number.isInteger(i)) i = 0; + i = Math.max(0, Math.min(frames.length - 1, i)); + const { off, len } = frames[i]; + const file = path.join(RECORDINGS_DIR, id, `${role}.mjpeg`); + fs.open(file, "r", (err, fd) => { + if (err) return cb(err); + const buf = Buffer.alloc(len); + fs.read(fd, buf, 0, len, off, (e2, read) => { + fs.close(fd, () => {}); + if (e2) return cb(e2); + cb(null, read === len ? buf : buf.slice(0, read)); + }); + }); +} + +module.exports = { + RECORDINGS_DIR, + MAX_RECORD_MS, + MAX_RECORD_MIN, + isRecording, + startSession, + stopSession, + onTelemetry, + onCommand, + status, + listSessions, + resolveSessionFile, + getFrame, + // diekspos untuk unit test + _makeSessionId: makeSessionId, + _isSafeId: isSafeId, +}; diff --git a/server/recordings/.gitignore b/server/recordings/.gitignore new file mode 100644 index 0000000..bf0e8bf --- /dev/null +++ b/server/recordings/.gitignore @@ -0,0 +1,3 @@ +# Sesi rekaman Replay (runtime) — jangan commit isinya. +* +!.gitignore diff --git a/server/server.js b/server/server.js index bd00509..7f96a57 100644 --- a/server/server.js +++ b/server/server.js @@ -17,6 +17,7 @@ const dgram = require("dgram"); const fs = require("fs"); const path = require("path"); const { WebSocketServer } = require("ws"); +const recording = require("./recording"); const WS_PORT = parseInt(process.env.WS_PORT || "8080", 10); const UDP_IN = parseInt(process.env.UDP_IN || "14551", 10); // telemetry dari ROV @@ -180,6 +181,7 @@ const MIME = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".json": "application/json", ".glb": "model/gltf-binary", ".fbx": "application/octet-stream", ".png": "image/png", ".svg": "image/svg+xml", + ".jsonl": "application/x-ndjson", }; // Host tujuan yang boleh di-proxy (umbilical LAN). Cegah open-proxy ke internet. @@ -234,6 +236,52 @@ const httpServer = http.createServer((req, res) => { return; } + // ================= PLAYBACK / REPLAY (HTTP) ================= + // Data replay bersifat historis & akses-acak (daftar sesi, log trajectory, + // frame video per-timestamp) → cocok dengan pola HTTP static/endpoint yang + // sudah ada (server ini memang meng-serve file & mem-proxy /cam via HTTP). + // WebSocket dipakai untuk push live; replay TIDAK lewat WS agar tak bercampur. + + // Daftar sesi rekaman (id, tanggal, durasi, ukuran, kamera). + if (urlPath === "/api/recordings") { + const list = recording.listSessions(); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(list)); + } + + // Satu frame JPEG dari sesi: /replay/frame?session=&cam=&i= + if (urlPath === "/replay/frame") { + const q = new URL(req.url, `http://localhost:${WS_PORT}`).searchParams; + const id = q.get("session"); + const cam = (q.get("cam") || "").toLowerCase(); + const idx = parseInt(q.get("i") || "0", 10); + recording.getFrame(id, cam, idx, (err, buf) => { + if (err || !buf) { res.writeHead(404); return res.end("frame tidak ada"); } + res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "no-store" }); + res.end(buf); + }); + return; + } + + // File data sesi (meta.json / trajectory.jsonl / commands.jsonl / *.index.jsonl): + // /recordings// + if (urlPath.startsWith("/recordings/")) { + const rest = urlPath.slice("/recordings/".length); + const slash = rest.indexOf("/"); + const id = slash < 0 ? rest : rest.slice(0, slash); + const name = slash < 0 ? "" : rest.slice(slash + 1); + const filePath = recording.resolveSessionFile(id, name); + if (!filePath) { res.writeHead(400); return res.end("permintaan tidak valid"); } + return fs.readFile(filePath, (err, data) => { + if (err) { res.writeHead(404); return res.end("Not found"); } + res.writeHead(200, { + "Content-Type": MIME[path.extname(filePath)] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(data); + }); + } + if (urlPath === "/") urlPath = "/index.html"; const filePath = path.join(PUBLIC, path.normalize(urlPath)); @@ -260,12 +308,19 @@ const wss = new WebSocketServer({ server: httpServer }); const clients = new Set(); function broadcast(obj) { + // Tap telemetry untuk rekaman trajectory (bila sesi rekam aktif). Ini hanya + // "menguping" — tidak mengubah/menghambat aliran telemetry ke dashboard. + if (obj && obj.type === "telemetry") recording.onTelemetry(obj.data); const s = JSON.stringify(obj); for (const c of clients) { if (c.readyState === 1) c.send(s); } } +function broadcastRecordStatus() { + broadcast({ type: "record_status", data: recording.status() }); +} + wss.on("connection", (ws, req) => { clients.add(ws); const ip = req.socket.remoteAddress; @@ -284,6 +339,9 @@ wss.on("connection", (ws, req) => { data: joystickConfig, })); + // kirim status rekaman saat ini agar dashboard baru langsung sinkron + ws.send(JSON.stringify({ type: "record_status", data: recording.status() })); + ws.on("message", (raw) => { let msg; try { @@ -336,6 +394,45 @@ wss.on("connection", (ws, req) => { return; } + // ================= RECORD START (fitur Replay) ================= + // Message type khusus — SENGAJA bukan "cmd" sehingga tidak pernah + // diteruskan ke UDP/ROV. Rekaman murni sisi server (telemetry + video tap). + if (msg.type === "record_start") { + if (recording.isRecording()) { + ws.send(JSON.stringify({ type: "event", text: "Rekaman sudah berjalan", level: "warn" })); + broadcastRecordStatus(); + return; + } + try { + const meta = recording.startSession(msg.cameras, { + allowHost: isAllowedCamHost, + label: msg.label, + onWarn: (m) => { console.warn("[REC]", m); broadcast({ type: "event", text: `Rekam: ${m}`, level: "warn" }); }, + onStatus: () => { broadcastRecordStatus(); broadcast({ type: "event", text: "Rekaman auto-stop (batas durasi)", level: "warn" }); }, + }); + console.log(`[REC] sesi dimulai: ${meta.id} (kamera: ${meta.cameras.map((c) => c.role).join(",") || "—"})`); + broadcast({ type: "event", text: `Rekaman sesi dimulai: ${meta.id}`, level: "ok" }); + broadcastRecordStatus(); + } catch (err) { + console.warn("[REC] gagal mulai:", err.message); + ws.send(JSON.stringify({ type: "event", text: `Gagal mulai rekam: ${err.message}`, level: "err" })); + } + return; + } + + // ================= RECORD STOP ================= + if (msg.type === "record_stop") { + if (!recording.isRecording()) { + broadcastRecordStatus(); + return; + } + const meta = recording.stopSession("manual"); + if (meta) console.log(`[REC] sesi berhenti: ${meta.id} (${meta.duration_ms} ms, ${meta.trajectory_samples} sampel)`); + broadcast({ type: "event", text: `Rekaman dihentikan: ${meta ? meta.id : ""}`, level: "ok" }); + broadcastRecordStatus(); + return; + } + // ================= COMMAND KE ROV ================= if (msg.type === "cmd") { // Jangan percaya input klien mentah-mentah: clamp axis kontrol manual @@ -345,6 +442,10 @@ wss.on("connection", (ws, req) => { msg.value = clampPercent(msg.value); } + // Tap command relevan-trajectory untuk rekaman (surge/sway/dll). Hanya + // menguping nilai yang lewat — tidak mengubah routing command ke ROV. + recording.onCommand(msg.name, msg.value); + // di mode SIM, pantulkan status perintah agar tombol header berefek nyata if (SIM) applySimCommand(msg.name, msg.value); diff --git a/server/test/recording.test.js b/server/test/recording.test.js new file mode 100644 index 0000000..923f24b --- /dev/null +++ b/server/test/recording.test.js @@ -0,0 +1,174 @@ +// Unit test manajer rekaman (fitur Replay). Fokus: logika start/stop, penulisan +// trajectory log, tap command, dan listing/akses sesi. Video stream di-mock +// (kami tidak membuka kamera nyata — cukup verifikasi manajemen file/sesi). +// +// Jalankan: cd server && npm test (atau: node test/recording.test.js) +// Tanpa dependency/framework tambahan — runner mini di bawah agar jalan di Node +// lama (server produksi memakai Node yang ada di laptop operator). + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +// Arahkan penyimpanan ke folder sementara SEBELUM require modul (dibaca saat load). +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "hydro-rec-")); +process.env.HYDROSHIP_RECORDINGS_DIR = TMP; + +const recording = require("../recording"); + +/* ---------------- runner mini ---------------- */ +const tests = []; +function test(name, fn) { tests.push({ name, fn }); } +function readJSONL(file) { + return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); +} +function cleanup() { if (recording.isRecording()) recording.stopSession("cleanup"); } + +/* ---------------- kasus uji ---------------- */ + +test("startSession membuat folder + meta.json dengan session_start_time", () => { + const meta = recording.startSession([], { label: "TIM-X" }); + assert.ok(meta.id, "punya id"); + assert.strictEqual(meta.status, "recording"); + assert.strictEqual(meta.label, "TIM-X"); + assert.ok(Number.isFinite(meta.session_start_time), "session_start_time epoch ms"); + + const dir = path.join(TMP, meta.id); + assert.ok(fs.existsSync(dir), "folder sesi dibuat"); + const onDisk = JSON.parse(fs.readFileSync(path.join(dir, "meta.json"), "utf8")); + assert.strictEqual(onDisk.status, "recording"); + assert.strictEqual(onDisk.session_start_time, meta.session_start_time); + recording.stopSession(); +}); + +test("tidak bisa mulai dua sesi sekaligus", () => { + recording.startSession([]); + assert.throws(() => recording.startSession([]), /sudah berjalan/); + recording.stopSession(); + assert.strictEqual(recording.isRecording(), false); +}); + +test("onTelemetry menulis trajectory.jsonl; field ternormalisasi", () => { + const meta = recording.startSession([]); + recording.onTelemetry({ heading: 90, depth: 0.5, roll: 2, pitch: -1 }); + recording.onTelemetry({ heading: "bad", depth: 0.6, roll: null }); + recording.stopSession(); + + const rows = readJSONL(path.join(TMP, meta.id, "trajectory.jsonl")); + assert.strictEqual(rows.length, 2); + assert.strictEqual(rows[0].heading, 90); + assert.strictEqual(rows[0].depth, 0.5); + assert.strictEqual(rows[1].heading, null, "heading non-numerik → null"); + assert.strictEqual(rows[1].roll, null); + assert.ok(rows.every((r) => Number.isFinite(r.t)), "tiap baris berstempel waktu"); +}); + +test("onCommand hanya mencatat command relevan-trajectory", () => { + const meta = recording.startSession([]); + recording.onCommand("surge", 50); + recording.onCommand("sway", -20); + recording.onCommand("arm", true); // TIDAK relevan → diabaikan + recording.onCommand("light", true); // TIDAK relevan → diabaikan + recording.onCommand("control_mode", "manual"); + recording.stopSession(); + + const rows = readJSONL(path.join(TMP, meta.id, "commands.jsonl")); + assert.deepStrictEqual(rows.map((r) => r.name), ["surge", "sway", "control_mode"]); + assert.strictEqual(rows[0].value, 50); +}); + +test("tap diabaikan saat tidak merekam (tanpa error)", () => { + assert.strictEqual(recording.isRecording(), false); + assert.doesNotThrow(() => { + recording.onTelemetry({ heading: 10, depth: 1 }); + recording.onCommand("surge", 30); + }); +}); + +test("stopSession memfinalisasi meta (status stopped, durasi, jumlah sampel)", () => { + const meta = recording.startSession([]); + recording.onTelemetry({ heading: 1, depth: 0.1 }); + recording.onTelemetry({ heading: 2, depth: 0.2 }); + recording.onCommand("surge", 10); + const finalMeta = recording.stopSession("manual"); + + assert.strictEqual(finalMeta.status, "stopped"); + assert.strictEqual(finalMeta.stop_reason, "manual"); + assert.strictEqual(finalMeta.trajectory_samples, 2); + assert.strictEqual(finalMeta.command_samples, 1); + assert.ok(finalMeta.duration_ms >= 0); + + const onDisk = JSON.parse(fs.readFileSync(path.join(TMP, meta.id, "meta.json"), "utf8")); + assert.strictEqual(onDisk.status, "stopped"); +}); + +test("listSessions mengembalikan sesi (terbaru-dulu)", () => { + const a = recording.startSession([]); recording.stopSession(); + const list = recording.listSessions(); + assert.ok(Array.isArray(list)); + assert.ok(list.find((s) => s.id === a.id), "sesi baru ada di daftar"); + for (let i = 1; i < list.length; i++) { + assert.ok((list[i - 1].session_start_time || 0) >= (list[i].session_start_time || 0)); + } +}); + +test("resolveSessionFile menolak path traversal & id tak valid", () => { + assert.strictEqual(recording.resolveSessionFile("../etc", "meta.json"), null); + assert.strictEqual(recording.resolveSessionFile("abc", "../secret"), null); + assert.strictEqual(recording.resolveSessionFile("abc", "sub/file"), null); + const ok = recording.resolveSessionFile("2026-01-01_00-00-00", "meta.json"); + assert.ok(ok && ok.endsWith(path.join("2026-01-01_00-00-00", "meta.json"))); +}); + +test("getFrame membaca slice JPEG dari mjpeg via index", (done) => { + const id = "mockframes"; + const dir = path.join(TMP, id); + fs.mkdirSync(dir, { recursive: true }); + const f0 = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]); // 6 byte + const f1 = Buffer.from([0xff, 0xd8, 0x33, 0xff, 0xd9]); // 5 byte + fs.writeFileSync(path.join(dir, "bottom.mjpeg"), Buffer.concat([f0, f1])); + fs.writeFileSync( + path.join(dir, "bottom.index.jsonl"), + JSON.stringify({ t: 1000, off: 0, len: f0.length }) + "\n" + + JSON.stringify({ t: 1100, off: f0.length, len: f1.length }) + "\n" + ); + + recording.getFrame(id, "bottom", 1, (err, buf) => { + assert.ifError(err); + assert.deepStrictEqual([...buf], [...f1], "frame index 1 cocok"); + recording.getFrame(id, "bottom", 99, (e2, b2) => { // di luar rentang → clamp + assert.ifError(e2); + assert.deepStrictEqual([...b2], [...f1]); + done(); + }); + }); +}); + +/* ---------------- eksekusi berurutan ---------------- */ +(async function run() { + let passed = 0, failed = 0; + for (const { name, fn } of tests) { + try { + if (fn.length >= 1) { + await new Promise((resolve, reject) => { + let settled = false; + const done = (e) => { if (settled) return; settled = true; e ? reject(e) : resolve(); }; + try { fn(done); } catch (e) { done(e); } + }); + } else { + fn(); + } + cleanup(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + cleanup(); + console.error(` ✗ ${name}\n ${e && e.message}`); + failed++; + } + } + try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {} + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})(); From 6f86533b96bd441637ad5db60d3559b5c22d41e3 Mon Sep 17 00:00:00 2001 From: Customize5773 Date: Tue, 14 Jul 2026 01:56:23 +0700 Subject: [PATCH 3/4] feat(camera): add camera switch button and functionality for live feed control --- public/css/style.css | 28 ++++++++++++++++ public/index.html | 7 ++-- public/js/app.js | 77 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index b47da9e..9e9bb98 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -696,6 +696,32 @@ body[data-theme="light"] .theme__icon::after { gap: 6px; pointer-events: none; } +.cam__switch { + position: absolute; + top: 18px; + right: 18px; + z-index: 30; + display: none; + align-items: center; + justify-content: center; + border: 1px solid rgba(20,216,255,.35); + border-radius: 999px; + padding: 8px 12px; + background: rgba(4,23,38,.78); + color: var(--ink); + font-family: "Chakra Petch", sans-serif; + font-size: 11px; + letter-spacing: 1px; + text-transform: uppercase; + cursor: pointer; + transition: all .18s ease; + pointer-events: auto; +} +.cam__switch:hover { + background: rgba(20,216,255,.16); + border-color: rgba(20,216,255,.55); + box-shadow: 0 0 16px rgba(20,216,255,.16); +} .cam__res { background: rgba(4,23,38,.7); color: var(--ink); @@ -900,6 +926,8 @@ body.pseudo-fs-lock { overflow: hidden; } .cam.pseudo-fs #ctrlCamPip, .camcell__view:fullscreen .campip, .camcell__view.pseudo-fs .campip { display: block; } +.cam:fullscreen .cam__switch, +.cam.pseudo-fs .cam__switch { display: inline-flex; } /* PiP pilot di kamera Control: kiri-ATAS supaya tidak menutupi tombol SNAPSHOT/REC/HUD (.cam__bar di kiri-bawah); overlay resolusi ada di kanan-atas. */ #ctrlCamPip { left: 18px; top: 18px; right: auto; bottom: auto; } diff --git a/public/index.html b/public/index.html index b0dfacf..b3b0b95 100644 --- a/public/index.html +++ b/public/index.html @@ -213,6 +213,8 @@ + +
    TIDAK ADA SINYAL
    @@ -223,10 +225,11 @@ - + diff --git a/public/js/app.js b/public/js/app.js index c13d998..1efd871 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -27,6 +27,7 @@ const els = { armLabel: $("armLabel"), btnMode: $("btnMode"), modeLabel: $("modeLabel"), btnMute: $("btnMute"), btnSnap: $("btnSnap"), btnRec: $("btnRec"), btnHud: $("btnHud"), + btnCamSwitch: $("btnCamSwitch"), camStage: $("camStage"), btnCamFull: $("btnCamFull"), camFullLabel: $("camFullLabel"), pilotPanel: $("pilotPanel"), btnPilotFull: $("btnPilotFull"), pilotFullLabel: $("pilotFullLabel"), pilotPipImg: $("pilotPipImg"), pilotPipNo: $("pilotPipNo"), @@ -440,21 +441,63 @@ els.camImg.onload = () => { }; els.camImg.onerror = () => { els.camNoSignal.style.display = "flex"; els.camTag.textContent = "RTSP / MJPEG"; }; +let controlCamIndex = 0; + +function getControlCameraSources() { + const urls = []; + (CONFIG.CAMERAS || []).forEach((cam) => { + if (cam && cam.url) urls.push(cam.url); + }); + if (CONFIG.CAMERA_URL) urls.push(CONFIG.CAMERA_URL); + return urls.filter((url, idx, arr) => url && arr.indexOf(url) === idx); +} + +function syncControlCameraButton() { + if (!els.btnCamSwitch) return; + const sources = getControlCameraSources(); + const canSwitch = sources.length > 1; + els.btnCamSwitch.style.display = canSwitch ? "inline-flex" : "none"; + els.btnCamSwitch.textContent = canSwitch ? `CAM ${controlCamIndex + 1}` : "CAM 1"; +} + // (re)arahkan feed kamera Control ke CONFIG.CAMERA_URL saat ini. Dipanggil di // awal dan setiap URL diubah (Setup/Camera) via event 'hydroship:camera-url'. function applyControlCamera() { - const url = CONFIG.CAMERA_URL; - if (!url) { + const sources = getControlCameraSources(); + if (!sources.length) { els.camImg.removeAttribute("src"); els.camNoSignal.style.display = "flex"; els.camTag.textContent = "RTSP / MJPEG"; if (els.camRes) els.camRes.textContent = "—"; + syncControlCameraButton(); return; } + + const matchIdx = sources.indexOf(CONFIG.CAMERA_URL); + if (matchIdx >= 0) controlCamIndex = matchIdx; + else if (controlCamIndex >= sources.length) controlCamIndex = 0; + + const url = sources[controlCamIndex] || sources[0]; + CONFIG.CAMERA_URL = url; + els.camTag.textContent = `CAM ${controlCamIndex + 1}`; + // bust cache agar re-apply URL sama tetap memicu load ulang; ambil lewat proxy same-origin const bust = url + (url.includes("?") ? "&" : "?") + "_t=" + Date.now(); els.camImg.src = camProxy(bust); + syncControlCameraButton(); +} + +if (els.btnCamSwitch) { + els.btnCamSwitch.onclick = () => { + const sources = getControlCameraSources(); + if (sources.length < 2) return; + controlCamIndex = (controlCamIndex + 1) % sources.length; + CONFIG.CAMERA_URL = sources[controlCamIndex]; + applyControlCamera(); + log(`Kamera kontrol: ${controlCamIndex + 1}`, "ok"); + }; } + applyControlCamera(); window.addEventListener("hydroship:camera-url", applyControlCamera); @@ -539,27 +582,35 @@ const pilotFs = makeFullscreen(els.pilotPanel, { }); els.btnPilotFull.onclick = () => pilotFs.toggle(); -/* mirror viewport pilot/digital-twin ke PiP saat kamera Control fullscreen */ -let pilotMirrorRaf = null; -function pilotMirror(on) { +/* tampilkan feed kamera live di PiP saat kamera Control fullscreen */ +let controlCamPiPRaf = null; +function renderControlCamPiP(on) { const cv = document.getElementById("ctrlCamPipCanvas"); + const no = document.getElementById("ctrlCamPipNo"); + if (!cv) return; + if (on) { - if (!cv || !scene) return; const ctx = cv.getContext("2d"); - const src = scene.renderer.domElement; const loop = () => { - pilotMirrorRaf = requestAnimationFrame(loop); + controlCamPiPRaf = requestAnimationFrame(loop); const w = cv.clientWidth, h = cv.clientHeight; if (!w || !h) return; if (cv.width !== w) cv.width = w; if (cv.height !== h) cv.height = h; - try { ctx.drawImage(src, 0, 0, cv.width, cv.height); } catch (e) {} + ctx.clearRect(0, 0, cv.width, cv.height); + if (els.camImg && els.camImg.complete && els.camImg.naturalWidth) { + try { ctx.drawImage(els.camImg, 0, 0, cv.width, cv.height); } catch (e) {} + if (no) no.style.display = "none"; + } else if (no) { + no.style.display = "flex"; + } }; loop(); - } else if (pilotMirrorRaf) { - cancelAnimationFrame(pilotMirrorRaf); - pilotMirrorRaf = null; + } else if (controlCamPiPRaf) { + cancelAnimationFrame(controlCamPiPRaf); + controlCamPiPRaf = null; } + if (!on && no) no.style.display = "none"; } /* Full Screen toggle untuk LIVE CAMERA di halaman Control */ @@ -567,7 +618,7 @@ const camFs = makeFullscreen(els.camStage, { onToggle: (fs) => { els.camFullLabel.textContent = fs ? "Exit Full" : "Full Screen"; els.btnCamFull.setAttribute("aria-pressed", String(fs)); - pilotMirror(fs); + renderControlCamPiP(fs); }, }); els.btnCamFull.onclick = () => camFs.toggle(); From 5bd7a79136173d94874daf66aafe84c31ef3d966 Mon Sep 17 00:00:00 2001 From: Customize5773 Date: Tue, 14 Jul 2026 02:04:25 +0700 Subject: [PATCH 4/4] Update --- public/css/style.css | 10 ++++++++-- public/index.html | 8 ++++---- public/js/app.js | 15 +++++++++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 9e9bb98..9df211b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -926,8 +926,14 @@ body.pseudo-fs-lock { overflow: hidden; } .cam.pseudo-fs #ctrlCamPip, .camcell__view:fullscreen .campip, .camcell__view.pseudo-fs .campip { display: block; } -.cam:fullscreen .cam__switch, -.cam.pseudo-fs .cam__switch { display: inline-flex; } +/* Tombol switch kamera: hanya saat kamera fullscreen DAN tersedia >1 kamera + (.is-multi diset di app.js). Di luar fullscreen tetap tersembunyi. */ +.cam:fullscreen .cam__switch.is-multi, +.cam.pseudo-fs .cam__switch.is-multi { display: inline-flex; } +/* Saat fullscreen, pojok kanan-atas dipesan untuk tombol switch — geser overlay + resolusi/rec ke bawahnya agar tidak bertumpuk. */ +.cam:fullscreen .cam__overlay, +.cam.pseudo-fs .cam__overlay { top: 62px; } /* PiP pilot di kamera Control: kiri-ATAS supaya tidak menutupi tombol SNAPSHOT/REC/HUD (.cam__bar di kiri-bawah); overlay resolusi ada di kanan-atas. */ #ctrlCamPip { left: 18px; top: 18px; right: auto; bottom: auto; } diff --git a/public/index.html b/public/index.html index b3b0b95..6a8de49 100644 --- a/public/index.html +++ b/public/index.html @@ -225,11 +225,11 @@ - + diff --git a/public/js/app.js b/public/js/app.js index 1efd871..e78d453 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -456,7 +456,10 @@ function syncControlCameraButton() { if (!els.btnCamSwitch) return; const sources = getControlCameraSources(); const canSwitch = sources.length > 1; - els.btnCamSwitch.style.display = canSwitch ? "inline-flex" : "none"; + // Visibilitas dikontrol CSS: tampil HANYA saat fullscreen DAN ada >1 kamera + // (.cam:fullscreen .cam__switch.is-multi). Jangan set display inline agar + // tidak menimpa aturan fullscreen tsb. + els.btnCamSwitch.classList.toggle("is-multi", canSwitch); els.btnCamSwitch.textContent = canSwitch ? `CAM ${controlCamIndex + 1}` : "CAM 1"; } @@ -582,7 +585,9 @@ const pilotFs = makeFullscreen(els.pilotPanel, { }); els.btnPilotFull.onclick = () => pilotFs.toggle(); -/* tampilkan feed kamera live di PiP saat kamera Control fullscreen */ +/* Saat LIVE CAMERA fullscreen, operator tak lagi melihat digital twin (pilot). + PiP di pojok menampilkan mirror scene 3D ROV Control (pilot) supaya attitude + ROV tetap terpantau sambil menonton kamera layar penuh. */ let controlCamPiPRaf = null; function renderControlCamPiP(on) { const cv = document.getElementById("ctrlCamPipCanvas"); @@ -598,8 +603,10 @@ function renderControlCamPiP(on) { if (cv.width !== w) cv.width = w; if (cv.height !== h) cv.height = h; ctx.clearRect(0, 0, cv.width, cv.height); - if (els.camImg && els.camImg.complete && els.camImg.naturalWidth) { - try { ctx.drawImage(els.camImg, 0, 0, cv.width, cv.height); } catch (e) {} + // sumber = canvas WebGL digital twin (RovScene, render kontinu di scene.js) + const src = scene && scene.renderer && scene.renderer.domElement; + if (src && src.width && src.height) { + try { ctx.drawImage(src, 0, 0, cv.width, cv.height); } catch (e) {} if (no) no.style.display = "none"; } else if (no) { no.style.display = "flex";