diff --git a/camlaps/admin.py b/camlaps/admin.py index b123241..1efbb5b 100644 --- a/camlaps/admin.py +++ b/camlaps/admin.py @@ -18,6 +18,7 @@ class TimelapseJobAdmin(admin.ModelAdmin): 'date_from', 'date_to', 'sampling_interval_minutes', + 'anchor_time', 'fps', 'status', 'progress_percent', @@ -25,4 +26,13 @@ class TimelapseJobAdmin(admin.ModelAdmin): ) list_filter = ('status', 'include_night', 'sampling_preset', 'camera') search_fields = ('camera__name', 'output_rel_path', 'error_message') - readonly_fields = ('progress_percent', 'frames_total', 'frames_processed', 'started_at', 'finished_at') + readonly_fields = ( + 'progress_percent', + 'frames_total', + 'frames_processed', + 'days_total', + 'days_with_frames', + 'days_skipped', + 'started_at', + 'finished_at', + ) diff --git a/camlaps/forms.py b/camlaps/forms.py index 0532031..aab2671 100644 --- a/camlaps/forms.py +++ b/camlaps/forms.py @@ -22,6 +22,7 @@ class TimelapseJobCreateForm(forms.ModelForm): 'sampling_preset', 'fps', 'include_night', + 'anchor_time', 'day_start_time', 'day_end_time', ) @@ -39,6 +40,7 @@ class TimelapseJobCreateForm(forms.ModelForm): ), 'fps': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 120}), 'include_night': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'anchor_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control', 'step': 60}), 'day_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), 'day_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), } \ No newline at end of file diff --git a/camlaps/migrations/0002_timelapsejob_anchor_time_timelapsejob_days_skipped_and_more.py b/camlaps/migrations/0002_timelapsejob_anchor_time_timelapsejob_days_skipped_and_more.py new file mode 100644 index 0000000..885d1c1 --- /dev/null +++ b/camlaps/migrations/0002_timelapsejob_anchor_time_timelapsejob_days_skipped_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.4 on 2026-04-19 19:53 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camlaps', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='timelapsejob', + name='anchor_time', + field=models.TimeField(default=datetime.time(12, 0), verbose_name='Время якоря кадра'), + ), + migrations.AddField( + model_name='timelapsejob', + name='days_skipped', + field=models.PositiveIntegerField(default=0, verbose_name='Пропущено дней'), + ), + migrations.AddField( + model_name='timelapsejob', + name='days_total', + field=models.PositiveIntegerField(default=0, verbose_name='Всего дней в диапазоне'), + ), + migrations.AddField( + model_name='timelapsejob', + name='days_with_frames', + field=models.PositiveIntegerField(default=0, verbose_name='Дней с кадрами'), + ), + ] diff --git a/camlaps/models.py b/camlaps/models.py index 306d1dd..f27196c 100644 --- a/camlaps/models.py +++ b/camlaps/models.py @@ -71,6 +71,7 @@ class TimelapseJob(models.Model): verbose_name='FPS итогового видео', ) include_night = models.BooleanField(default=True, verbose_name='Включать ночные кадры') + anchor_time = models.TimeField(default=time(12, 0), verbose_name='Время якоря кадра') day_start_time = models.TimeField(default=time(6, 0), verbose_name='Начало дня') day_end_time = models.TimeField(default=time(22, 0), verbose_name='Конец дня') @@ -82,6 +83,9 @@ class TimelapseJob(models.Model): ) frames_total = models.PositiveIntegerField(null=True, blank=True, verbose_name='Всего кадров') frames_processed = models.PositiveIntegerField(default=0, verbose_name='Обработано кадров') + days_total = models.PositiveIntegerField(default=0, verbose_name='Всего дней в диапазоне') + days_with_frames = models.PositiveIntegerField(default=0, verbose_name='Дней с кадрами') + days_skipped = models.PositiveIntegerField(default=0, verbose_name='Пропущено дней') output_rel_path = models.CharField(max_length=255, blank=True, verbose_name='Путь к видео в storage') error_message = models.TextField(blank=True, verbose_name='Текст ошибки') @@ -108,3 +112,6 @@ class TimelapseJob(models.Model): if not self.include_night and self.day_start_time >= self.day_end_time: raise ValidationError({'day_end_time': 'Для режима без ночи конец дня должен быть позже начала дня.'}) + + if not self.include_night and not (self.day_start_time <= self.anchor_time <= self.day_end_time): + raise ValidationError({'anchor_time': 'Якорное время должно попадать в дневной интервал.'}) diff --git a/camlaps/services/timelapse_worker.py b/camlaps/services/timelapse_worker.py index 26194b1..157d19a 100644 --- a/camlaps/services/timelapse_worker.py +++ b/camlaps/services/timelapse_worker.py @@ -33,7 +33,37 @@ def _is_day_time(snapshot_time: time, day_start: time, day_end: time) -> bool: return day_start <= snapshot_time <= day_end -def _select_frames(job: TimelapseJob, camera_root: Path) -> list[Path]: +def _time_to_minutes(t: time) -> int: + return t.hour * 60 + t.minute + + +def _pick_nearest_frame_for_day(job: TimelapseJob, day_files: list[Path]) -> Path | None: + candidates: list[tuple[int, Path]] = [] + anchor_minutes = _time_to_minutes(job.anchor_time) + + for img_path in day_files: + if img_path.name.lower() == 'lastsnap.jpg': + continue + + try: + snap_time = datetime.strptime(img_path.stem, '%H-%M-%S').time() + except ValueError: + continue + + if not job.include_night and not _is_day_time(snap_time, job.day_start_time, job.day_end_time): + continue + + diff = abs(_time_to_minutes(snap_time) - anchor_minutes) + candidates.append((diff, img_path)) + + if not candidates: + return None + + candidates.sort(key=lambda x: (x[0], x[1].name)) + return candidates[0][1] + + +def _select_frames(job: TimelapseJob, camera_root: Path) -> tuple[list[Path], int, int]: logger.info('worker:select_frames:start job_id=%s', job.id) selected: list[Path] = [] interval_seconds = int(job.sampling_interval_minutes) * 60 @@ -161,14 +191,23 @@ def process_job(job: TimelapseJob) -> bool: shutil.rmtree(temp_dir, ignore_errors=True) camera_root = _safe_camera_root(job) - frame_paths = _select_frames(job, camera_root) + frame_paths, days_total, days_with_frames = _select_frames(job, camera_root) + days_skipped = max(0, days_total - days_with_frames) if not frame_paths: + TimelapseJob.objects.filter(pk=job.pk).update( + days_total=days_total, + days_with_frames=days_with_frames, + days_skipped=days_skipped, + ) raise RuntimeError('Не найдено кадров под выбранные параметры.') TimelapseJob.objects.filter(pk=job.pk).update( frames_total=len(frame_paths), frames_processed=0, + days_total=days_total, + days_with_frames=days_with_frames, + days_skipped=days_skipped, progress_percent=5, ) @@ -232,6 +271,9 @@ def run_specific_job(job_id: int) -> bool: progress_percent=1, frames_processed=0, frames_total=None, + days_total=0, + days_with_frames=0, + days_skipped=0, error_message='', ) diff --git a/camlaps/templates/camlaps/job_create.html b/camlaps/templates/camlaps/job_create.html index 8f17d33..180cada 100644 --- a/camlaps/templates/camlaps/job_create.html +++ b/camlaps/templates/camlaps/job_create.html @@ -44,6 +44,22 @@ +