View difference between Paste ID: 2FRWu0Ja and z43X1mBH
SHOW: | | - or go back to the newest paste.
1-
import cv2
1+
2
import time
3
import socket
4-
from collections import deque
4+
5
import subprocess
6
from datetime import datetime
7
import re
8
9
# =============== KONFIGURACJA ===============
10-
# ================== KONFIGURACJA ==================
10+
11
RTSP_URL = "rtsp://admin:[email protected]:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif"
12-
CAMERAS = {
12+
13-
    "KAMERA1": "rtsp://admin:[email protected]:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif",
13+
BASE_DIR = r"D:\wideo"
14-
}
14+
RAW_DIR = os.path.join(BASE_DIR, "raw")
15
CLIPS_DIR = os.path.join(BASE_DIR, "clips")
16-
PRE_SECONDS = 10        # ile sekund PRZED triggerem w buforze
16+
17-
POST_SECONDS = 15       # ile sekund PO triggerze nagrywać
17+
RAW_PATH = os.path.join(RAW_DIR, "cam1_raw.mp4")   # tu ffmpeg nagrywa ciągły strumień
18-
FPS_DEFAULT = 25        # jeśli kamera nie poda sensownego FPS
18+
CAMERA_NAME = "KAMERA1"
19
20-
OUTPUT_DIR = r"D:\wideo"
20+
PRE_SECONDS = 10       # ile sekund PRZED triggerem
21-
RETENTION_DAYS = 1      # na testy, potem np. 30
21+
POST_SECONDS = 15      # ile sekund PO triggerze
22
RETENTION_DAYS = 1     # na testy, potem np. 30
23
24
TCP_HOST = "0.0.0.0"
25
TCP_PORT = 5000
26
27-
# ================== POMOCNICZE ==================
27+
FFMPEG_EXE = "ffmpeg"  # jeśli nie w PATH, podaj pełną ścieżkę, np. r"C:\ffmpeg\bin\ffmpeg.exe"
28
29-
def ensure_output_dir():
29+
# globalnie zapamiętany czas startu RAW-a
30-
    os.makedirs(OUTPUT_DIR, exist_ok=True)
30+
raw_start_time = None
31
raw_lock = threading.Lock()
32
33-
def cleanup_old_files():
33+
34-
    now = time.time()
34+
# =============== POMOCNICZE ===============
35-
    cutoff = now - RETENTION_DAYS * 24 * 3600
35+
36-
    for filename in os.listdir(OUTPUT_DIR):
36+
def ensure_dirs():
37-
        filepath = os.path.join(OUTPUT_DIR, filename)
37+
    os.makedirs(RAW_DIR, exist_ok=True)
38-
        if os.path.isfile(filepath):
38+
    os.makedirs(CLIPS_DIR, exist_ok=True)
39-
            if os.path.getmtime(filepath) < cutoff:
39+
40-
                print(f"[CLEANUP] Usuwam stary plik: {filename}")
40+
41-
                os.remove(filepath)
41+
def sanitize_part_code(code: str) -> str:
42
    code = code.strip()
43
    if not code:
44-
def cleanup_worker():
44+
        code = "NO_CODE"
45
    return re.sub(r"[^0-9A-Za-z_-]", "_", code)
46-
        cleanup_old_files()
46+
47
48
def cleanup_old_clips():
49
    while True:
50-
def sanitize_part_code(part_code: str) -> str:
50+
51-
    part_code = part_code.strip()
51+
        cutoff = now - RETENTION_DAYS * 24 * 3600
52-
    if not part_code:
52+
53-
        part_code = "NO_CODE"
53+
            for fname in os.listdir(CLIPS_DIR):
54-
    return re.sub(r"[^0-9A-Za-z_-]", "_", part_code)
54+
                fpath = os.path.join(CLIPS_DIR, fname)
55
                if os.path.isfile(fpath) and os.path.getmtime(fpath) < cutoff:
56
                    print(f"[CLEANUP] Usuwam stary klip: {fname}")
57-
# ================== KAMERA – BUFOR + TRIGGER (LICZONY NA KLATKACH) ==================
57+
                    os.remove(fpath)
58
        except Exception as e:
59-
class CameraRecorder(threading.Thread):
59+
            print("[CLEANUP] Błąd cleanup:", e)
60-
    def __init__(self, name, url):
60+
61-
        super().__init__(daemon=True)
61+
62-
        self.name = name
62+
63-
        self.url = url
63+
# =============== START FFMPEG (RAW) ===============
64
65-
        self.cap = None
65+
def start_ffmpeg_raw():
66-
        self._open_stream()
66+
    """
67
    Uruchamia ffmpeg, który ciągle nagrywa RTSP do RAW_PATH.
68-
        fps = self.cap.get(cv2.CAP_PROP_FPS) if self.cap else 0
68+
    """
69-
        if fps <= 0 or fps > 120:
69+
    global raw_start_time
70-
            fps = FPS_DEFAULT
70+
71-
        self.fps = fps
71+
    # jeśli plik już istnieje – spróbuj go usunąć, żeby zacząć od nowa
72
    if os.path.exists(RAW_PATH):
73-
        self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 640)
73+
74-
        self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480)
74+
            os.remove(RAW_PATH)
75
        except Exception:
76-
        # ile klatek trzymamy w buforze
76+
77-
        self.pre_frames = int(self.fps * PRE_SECONDS)
77+
78-
        # ile klatek nagrywamy po triggerze
78+
    cmd = [
79-
        self.post_frames_target = int(self.fps * POST_SECONDS)
79+
        FFMPEG_EXE,
80
        "-rtsp_transport", "tcp",
81-
        # bufor z ostatnimi klatkami
81+
        "-i", RTSP_URL,
82-
        self.buffer = deque(maxlen=self.pre_frames)
82+
        "-c", "copy",
83
        "-an",
84-
        self.lock = threading.Lock()
84+
        "-reset_timestamps", "1",
85
        RAW_PATH,
86-
        # stan nagrywania
86+
    ]
87-
        self.recording = False
87+
88-
        self.writer = None
88+
    print("[FFMPEG] Start nagrywania RAW...")
89-
        self.post_frames_left = 0  # ile klatek jeszcze zapisać po triggerze
89+
    # odpalamy ffmpeg jako proces w tle
90
    proc = subprocess.Popen(
91-
        # do reconnectów
91+
        cmd,
92-
        self.fail_count = 0
92+
        stdout=subprocess.DEVNULL,
93-
        self.max_fail_before_reopen = int(self.fps * 2)  # ok. 2 sekundy błędów z rzędu
93+
        stderr=subprocess.DEVNULL
94
    )
95-
        print(
95+
96-
            f"[{self.name}] Start. {self.width}x{self.height} @ {self.fps} fps, "
96+
    with raw_lock:
97-
            f"bufor={self.pre_frames} klatek, post={self.post_frames_target} klatek"
97+
        raw_start_time = time.time()
98-
        )
98+
99
    print(f"[FFMPEG] RAW zapisuje się do: {RAW_PATH}")
100-
    def _open_stream(self):
100+
    print(f"[FFMPEG] Czas startu RAW: {datetime.fromtimestamp(raw_start_time)}")
101-
        if self.cap:
101+
102-
            self.cap.release()
102+
    # proces ffmpeg będzie sobie chodził w tle; nie czekamy na niego tutaj
103-
        print(f"[{self.name}] Otwieram RTSP...")
103+
    return proc
104-
        # ograniczamy buforowanie po stronie OpenCV (jeśli jest obsługiwane)
104+
105-
        self.cap = cv2.VideoCapture(self.url)
105+
106
# =============== WYCIĘCIE KLIPU ===============
107-
            self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
107+
108
def cut_clip(part_code: str, trigger_time: float):
109
    """
110
    Wywołuje ffmpeg, żeby wyciąć klip:
111-
        if not self.cap.isOpened():
111+
      [trigger_time - PRE, trigger_time + POST]
112-
            print(f"[{self.name}] ❌ Nie można otworzyć strumienia RTSP!")
112+
    z pliku RAW.
113
    """
114-
    def _start_new_writer(self, part_code: str, use_prebuffer: bool):
114+
    with raw_lock:
115-
        """
115+
        rst = raw_start_time
116-
        Tworzy nowy plik:
116+
117-
        - use_prebuffer=True  -> przed bieżącymi klatkami dopisuje cały bufor (ostatnie PRE_SECONDS)
117+
    if rst is None:
118-
        - use_prebuffer=False -> tylko od TERAZ, bez cofania
118+
        print("[CLIP] RAW jeszcze nie wystartował – pomijam.")
119-
        Nazwa: RRRR_MM_DD_GG_MM_SS_KOD_KAMERA.mp4
119+
        return
120-
        """
120+
121
    if not os.path.exists(RAW_PATH):
122-
        ts_name = datetime.fromtimestamp(now).strftime("%Y_%m_%d_%H_%M_%S")
122+
        print(f"[CLIP] RAW nie istnieje: {RAW_PATH}")
123-
        safe_code = sanitize_part_code(part_code)
123+
        return
124-
        filename = f"{ts_name}_{safe_code}_{self.name}.mp4"
124+
125-
        filepath = os.path.join(OUTPUT_DIR, filename)
125+
    window_start = trigger_time - PRE_SECONDS
126
    offset = window_start - rst
127-
        print(f"[{self.name}] Start nagrania: {filename} | prebuffer={use_prebuffer}")
127+
    if offset < 0:
128
        offset = 0.0  # jeśli trigger był bardzo szybko po starcie
129-
        writer = cv2.VideoWriter(
129+
130-
            filepath,
130+
    duration = PRE_SECONDS + POST_SECONDS
131-
            cv2.VideoWriter_fourcc(*"mp4v"),
131+
132-
            self.fps,
132+
    ts_name = datetime.fromtimestamp(trigger_time).strftime("%Y_%m_%d_%H_%M_%S")
133-
            (self.width, self.height)
133+
    safe_code = sanitize_part_code(part_code)
134-
        )
134+
    out_name = f"{ts_name}_{safe_code}_{CAMERA_NAME}.mp4"
135
    out_path = os.path.join(CLIPS_DIR, out_name)
136-
        if not writer.isOpened():
136+
137-
            print(f"[{self.name}] ❌ Nie można otworzyć pliku do zapisu!")
137+
    print(f"[CLIP] Wycinam klip: {out_name}")
138-
            return None
138+
    print(f"[CLIP] offset={offset:.2f}s, duration={duration:.2f}s")
139
140-
        if use_prebuffer:
140+
    cmd = [
141-
            frames = list(self.buffer)  # snapshot, żeby nie iterować po żywym deque
141+
        FFMPEG_EXE,
142-
            print(f"[{self.name}] Zapis pre-buffer: {len(frames)} klatek")
142+
        "-y",
143-
            for frame in frames:
143+
        "-ss", f"{offset:.3f}",
144-
                writer.write(frame)
144+
        "-i", RAW_PATH,
145
        "-t", f"{duration:.3f}",
146-
        self.recording = True
146+
        "-c", "copy",
147-
        self.writer = writer
147+
        out_path,
148-
        self.post_frames_left = self.post_frames_target  # koniec = post_frames_left == 0
148+
    ]
149
150-
        return writer
150+
    try:
151
        subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
152-
    def trigger(self, part_code: str):
152+
        print(f"[CLIP] Klip zapisany: {out_path}")
153-
        with self.lock:
153+
    except Exception as e:
154-
            if self.recording and self.writer:
154+
        print(f"[CLIP] Błąd wycinania: {e}")
155-
                # przerywamy stare nagranie natychmiast
155+
156-
                print(f"[{self.name}] Kończę poprzednie nagranie (nowy trigger).")
156+
157-
                try:
157+
# =============== SERVER TCP ===============
158-
                    self.writer.release()
158+
159-
                except Exception:
159+
def tcp_server():
160-
                    pass
160+
161-
                self.writer = None
161+
162-
                self.recording = False
162+
163-
                self.post_frames_left = 0
163+
164
    print(f"[TCP] Nasłuch na {TCP_HOST}:{TCP_PORT}")
165-
                # nowe nagranie bez pre-buffer
165+
166-
                self._start_new_writer(part_code, use_prebuffer=False)
166+
167-
            else:
167+
168-
                # pierwsze nagranie po przerwie – z PRE_SECONDS wstecz
168+
169-
                self._start_new_writer(part_code, use_prebuffer=True)
169+
170
                data = conn.recv(1024)
171-
            if self.writer:
171+
172-
                print(
172+
173-
                    f"[{self.name}] Nagrywanie: pre={self.pre_frames} klatek, "
173+
174-
                    f"post={self.post_frames_target} klatek"
174+
175-
                )
175+
176-
            else:
176+
177-
                print(f"[{self.name}] ❌ Nie udało się rozpocząć nagrania.")
177+
178
                trigger_time = time.time()
179-
    def run(self):
179+
                # wycinanie w osobnym wątku, żeby nie blokować TCP
180-
        while True:
180+
                th = threading.Thread(target=cut_clip, args=(code, trigger_time), daemon=True)
181-
            if not self.cap or not self.cap.isOpened():
181+
                th.start()
182-
                self._open_stream()
182+
183-
                time.sleep(1)
183+
184-
                continue
184+
185
186-
            ret, frame = self.cap.read()
186+
187-
            if not ret:
187+
# =============== MAIN ===============
188-
                # nie panikujemy od razu, dajemy trochę błędów pod rząd
188+
189-
                self.fail_count += 1
189+
190-
                if self.fail_count >= self.max_fail_before_reopen:
190+
    ensure_dirs()
191-
                    print(f"[{self.name}] Za dużo błędów z rzędu, ponowne otwarcie RTSP.")
191+
192-
                    self._open_stream()
192+
    # startujemy ffmpeg
193-
                    self.fail_count = 0
193+
    ffmpeg_proc = start_ffmpeg_raw()
194-
                # krótka pauza, ale bardzo mała
194+
195-
                time.sleep(0.01)
195+
    # uruchamiamy sprzątanie starych klipów
196-
                continue
196+
    threading.Thread(target=cleanup_old_clips, daemon=True).start()
197
198-
            self.fail_count = 0  # mamy poprawną klatkę
198+
    print("=== TRIGGER + FFMPEG RECORDER URUCHOMIONY ===")
199
    print("RAW:", RAW_PATH)
200-
            with self.lock:
200+
    print("CLIPS:", CLIPS_DIR)
201-
                # zawsze aktualizujemy bufor PRE_SECONDS
201+
    print(f"Pre={PRE_SECONDS}s, Post={POST_SECONDS}s, Retencja={RETENTION_DAYS} dni\n")
202-
                self.buffer.append(frame.copy())
202+
203
    try:
204-
                if self.recording and self.writer:
204+
        tcp_server()
205-
                    # zapisujemy bieżącą klatkę
205+
    finally:
206-
                    self.writer.write(frame)
206+
        # przy zamknięciu – spróbuj zabić ffmpeg
207-
                    self.post_frames_left -= 1
207+
208
            ffmpeg_proc.terminate()
209-
                    if self.post_frames_left <= 0:
209+
210-
                        print(f"[{self.name}] Koniec nagrania (wyczerpane post-klatki).")
210+
211-
                        try:
211+
212-
                            self.writer.release()
212+
213-
                        except Exception:
213+
214-
                            pass
214+
215-
                        self.writer = None
215+