237 lines
8.8 KiB
Python
237 lines
8.8 KiB
Python
import argparse
|
|
import os
|
|
import subprocess
|
|
import logging
|
|
import sys
|
|
import shutil
|
|
from datetime import datetime, timedelta
|
|
from multiprocessing import Pool
|
|
|
|
# --- Configuration ---
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
handlers=[logging.StreamHandler(sys.stdout)]
|
|
)
|
|
|
|
BASEDIR = "/media/seagata16t/hikvision16t"
|
|
TIMELAPSE_BASEDIR = "/media/seagata16t/all_timelapses/"
|
|
DIRS = ["reolink_4", "hikvision_2", "reolink_5", "hikvision_4", "reolink_1", "reolink_2", "reolink_3"]
|
|
DEFAULT_FPS = 30
|
|
TARGET_LEVEL = "root"
|
|
JPG_RETENTION_DAYS = 7
|
|
|
|
# Scale video to 1920x1080 preserving aspect ratio, pad with black bars if needed
|
|
SCALE_FILTER = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2"
|
|
|
|
# --- Helpers ---
|
|
|
|
def get_video_duration(filepath):
|
|
"""Pobiera czas trwania wideo za pomocą ffprobe."""
|
|
cmd = [
|
|
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
|
'-of', 'default=noprint_wrappers=1:nokey=1', filepath
|
|
]
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
return float(result.stdout.strip())
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def _run_ffmpeg(cmd, label):
|
|
"""Uruchamia ffmpeg, loguje błędy zamiast je wyciszać."""
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
logging.error(f"[{label}] ffmpeg error (kod {result.returncode}):\n{result.stderr[-2000:]}")
|
|
raise subprocess.CalledProcessError(result.returncode, cmd)
|
|
|
|
def cleanup_old_jpgs():
|
|
"""Usuwa katalogi JPG starsze niż JPG_RETENTION_DAYS."""
|
|
cutoff_date = datetime.now().date() - timedelta(days=JPG_RETENTION_DAYS)
|
|
total_removed = 0
|
|
total_size_freed = 0
|
|
|
|
for cam_dir in DIRS:
|
|
cam_path = os.path.join(BASEDIR, cam_dir)
|
|
if not os.path.exists(cam_path):
|
|
continue
|
|
|
|
for date_folder in os.listdir(cam_path):
|
|
folder_path = os.path.join(cam_path, date_folder)
|
|
if not os.path.isdir(folder_path):
|
|
continue
|
|
|
|
try:
|
|
folder_date = datetime.strptime(date_folder, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
continue
|
|
|
|
if folder_date < cutoff_date:
|
|
folder_size = sum(
|
|
os.path.getsize(os.path.join(folder_path, f))
|
|
for f in os.listdir(folder_path)
|
|
if os.path.isfile(os.path.join(folder_path, f))
|
|
)
|
|
try:
|
|
shutil.rmtree(folder_path)
|
|
total_removed += 1
|
|
total_size_freed += folder_size
|
|
logging.info(f"Usunięto: {folder_path}")
|
|
except Exception as e:
|
|
logging.error(f"Błąd usuwania {folder_path}: {e}")
|
|
|
|
if total_removed > 0:
|
|
size_gb = total_size_freed / (1024**3)
|
|
logging.info(f"Czyszczenie zakończone: usunięto {total_removed} katalogów, zwolniono {size_gb:.2f} GB")
|
|
else:
|
|
logging.info("Brak starych katalogów JPG do usunięcia")
|
|
|
|
# --- Core Functions ---
|
|
|
|
def create_from_images(cam_dir, date_list, period_name):
|
|
"""Tworzy timelapse z surowych zdjęć JPG."""
|
|
tmp_file_path = f'/tmp/{cam_dir}_{period_name}_images.txt'
|
|
image_count = 0
|
|
|
|
try:
|
|
with open(tmp_file_path, 'w') as f:
|
|
for target_date in sorted(date_list):
|
|
source_path = os.path.join(BASEDIR, cam_dir, target_date)
|
|
if not os.path.exists(source_path):
|
|
continue
|
|
|
|
jpgs = sorted([
|
|
os.path.join(source_path, img)
|
|
for img in os.listdir(source_path)
|
|
if img.lower().endswith(".jpg") and os.path.getsize(os.path.join(source_path, img)) > 0
|
|
])
|
|
|
|
for jpg_file in jpgs:
|
|
f.write(f"file '{jpg_file}'\n")
|
|
image_count += 1
|
|
|
|
if image_count == 0:
|
|
logging.warning(f"[{cam_dir}] Brak obrazów dla {period_name}")
|
|
return
|
|
|
|
output_dir = TIMELAPSE_BASEDIR if TARGET_LEVEL == 'root' else os.path.join(TIMELAPSE_BASEDIR, cam_dir)
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
output_file = os.path.join(output_dir, f"timelapse_{cam_dir}_{period_name}.mp4")
|
|
|
|
ffmpeg_cmd = [
|
|
'ffmpeg', '-f', 'concat', '-safe', '0', '-i', tmp_file_path,
|
|
'-vf', SCALE_FILTER,
|
|
'-r', str(DEFAULT_FPS), '-vcodec', 'libx264', '-crf', '23',
|
|
'-preset', 'medium', '-pix_fmt', 'yuv420p', '-y', output_file
|
|
]
|
|
logging.info(f"[{cam_dir}] Start renderowania {image_count} zdjęć...")
|
|
_run_ffmpeg(ffmpeg_cmd, cam_dir)
|
|
logging.info(f"[{cam_dir}] Gotowe: {output_file}")
|
|
finally:
|
|
if os.path.exists(tmp_file_path):
|
|
os.remove(tmp_file_path)
|
|
|
|
def merge_and_target_duration(cam_dir, date_list, period_name, target_duration_sec):
|
|
"""Łączy wideo MP4 i przyspiesza do celu."""
|
|
output_dir = TIMELAPSE_BASEDIR if TARGET_LEVEL == 'root' else os.path.join(TIMELAPSE_BASEDIR, cam_dir)
|
|
|
|
video_files = []
|
|
total_input_duration = 0.0
|
|
|
|
for target_date in sorted(date_list):
|
|
expected_video = os.path.join(output_dir, f"timelapse_{cam_dir}_{target_date}.mp4")
|
|
if os.path.exists(expected_video):
|
|
dur = get_video_duration(expected_video)
|
|
if dur > 0:
|
|
video_files.append(expected_video)
|
|
total_input_duration += dur
|
|
|
|
if not video_files:
|
|
logging.warning(f"[{cam_dir}] Brak plików MP4 dla {period_name}")
|
|
return
|
|
|
|
speed_multiplier = total_input_duration / float(target_duration_sec)
|
|
pts_value = 1 / speed_multiplier
|
|
|
|
logging.info(f"[{cam_dir}] Wejście: {total_input_duration:.1f}s | Cel: {target_duration_sec}s | Speed: {speed_multiplier:.2f}x")
|
|
|
|
list_file = f'/tmp/{cam_dir}_{period_name}_videos.txt'
|
|
try:
|
|
with open(list_file, 'w') as f:
|
|
for v in video_files:
|
|
f.write(f"file '{v}'\n")
|
|
|
|
output_file = os.path.join(output_dir, f"timelapse_{cam_dir}_{period_name}.mp4")
|
|
|
|
ffmpeg_cmd = [
|
|
'ffmpeg', '-f', 'concat', '-safe', '0', '-i', list_file,
|
|
'-vf', f"setpts={pts_value}*PTS,{SCALE_FILTER}",
|
|
'-r', str(DEFAULT_FPS), '-an',
|
|
'-vcodec', 'libx264', '-crf', '23', '-preset', 'ultrafast', '-y',
|
|
output_file
|
|
]
|
|
_run_ffmpeg(ffmpeg_cmd, cam_dir)
|
|
logging.info(f"[{cam_dir}] Gotowe: {output_file}")
|
|
finally:
|
|
if os.path.exists(list_file):
|
|
os.remove(list_file)
|
|
|
|
# --- Paralelizacja ---
|
|
|
|
def camera_worker(args):
|
|
cam_dir, date_list, period_name, date_arg = args
|
|
try:
|
|
if date_arg in ('year', 'month', 'week'):
|
|
target_durations = {'year': 300, 'month': 180, 'week': 120}
|
|
merge_and_target_duration(cam_dir, date_list, period_name, target_durations[date_arg])
|
|
else:
|
|
create_from_images(cam_dir, date_list, period_name)
|
|
except Exception as e:
|
|
logging.error(f"Błąd w procesie kamery {cam_dir}: {e}")
|
|
|
|
def make_timelapses(date_arg):
|
|
today = datetime.now()
|
|
|
|
if date_arg == 'today':
|
|
date_list = [today.strftime('%Y-%m-%d')]
|
|
period_name = date_list[0]
|
|
elif date_arg == 'week':
|
|
date_list = [(today - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]
|
|
period_name = f"week-{today.year}-{today.isocalendar()[1]}"
|
|
elif date_arg == 'month':
|
|
first = today.replace(day=1).date()
|
|
last_prev = first - timedelta(days=1)
|
|
date_list = [(last_prev.replace(day=1) + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(last_prev.day)]
|
|
period_name = f"month-{last_prev.year}-{last_prev.month:02d}"
|
|
elif date_arg == 'year':
|
|
ly = today.year - 1
|
|
days_in_year = (datetime(ly + 1, 1, 1) - datetime(ly, 1, 1)).days # 365 lub 366
|
|
date_list = [(datetime(ly, 1, 1).date() + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days_in_year)]
|
|
period_name = f"year-{ly}"
|
|
else:
|
|
date_list = [date_arg]
|
|
period_name = date_arg
|
|
|
|
tasks = [(cam, date_list, period_name, date_arg) for cam in DIRS]
|
|
|
|
with Pool(processes=2) as pool:
|
|
pool.map(camera_worker, tasks)
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("date", help="today, week, month, year lub YYYY-MM-DD")
|
|
parser.add_argument("--cleanup", action="store_true", help="Usuń JPG starsze niż 7 dni")
|
|
args = parser.parse_args()
|
|
|
|
start_time = datetime.now()
|
|
logging.info(f"Rozpoczęto zadanie: {args.date}")
|
|
|
|
make_timelapses(args.date)
|
|
|
|
if args.cleanup or args.date == 'today':
|
|
logging.info("Rozpoczynam czyszczenie starych JPG...")
|
|
cleanup_old_jpgs()
|
|
|
|
duration = datetime.now() - start_time
|
|
logging.info(f"Zakończono wszystko w czasie: {duration}")
|