mydocker/timelapse_service/timelapse_maker.py

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}")