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 @@ +
+ +
+ 🌙 + + ☀️ +
+
+ 00:00 + 23:59 +
+
+ {{ form.anchor_time }} +
+
+
{{ form.day_start_time }} @@ -65,15 +81,45 @@ const includeNight = document.getElementById("{{ form.include_night.auto_id }}"); const dayStart = document.getElementById("day-start-wrap"); const dayEnd = document.getElementById("day-end-wrap"); + const anchorInput = document.getElementById("{{ form.anchor_time.auto_id }}"); + const anchorRange = document.getElementById("anchor-time-range"); - function sync() { + function toMinutes(hhmm) { + if (!hhmm) return 720; + const parts = hhmm.split(":"); + const h = Number(parts[0] || 0); + const m = Number(parts[1] || 0); + return (h * 60) + m; + } + + function toHHMM(minutes) { + const h = String(Math.floor(minutes / 60)).padStart(2, "0"); + const m = String(minutes % 60).padStart(2, "0"); + return `${h}:${m}`; + } + + function syncNight() { const enabled = includeNight && includeNight.checked; if (dayStart) dayStart.style.display = enabled ? "none" : ""; if (dayEnd) dayEnd.style.display = enabled ? "none" : ""; } - if (includeNight) includeNight.addEventListener("change", sync); - sync(); + function syncRangeFromInput() { + if (!anchorInput || !anchorRange) return; + anchorRange.value = String(toMinutes(anchorInput.value)); + } + + function syncInputFromRange() { + if (!anchorInput || !anchorRange) return; + anchorInput.value = toHHMM(Number(anchorRange.value)); + } + + if (includeNight) includeNight.addEventListener("change", syncNight); + if (anchorRange) anchorRange.addEventListener("input", syncInputFromRange); + if (anchorInput) anchorInput.addEventListener("change", syncRangeFromInput); + + syncNight(); + syncRangeFromInput(); })(); {% endblock %} \ No newline at end of file diff --git a/camlaps/templates/camlaps/job_detail.html b/camlaps/templates/camlaps/job_detail.html index 3e1b23a..456a4d4 100644 --- a/camlaps/templates/camlaps/job_detail.html +++ b/camlaps/templates/camlaps/job_detail.html @@ -47,6 +47,10 @@
Интервал
{{ job.get_sampling_preset_display }}
+
+
Время якоря
+
{{ job.anchor_time }}
+
FPS
{{ job.fps }}
@@ -70,6 +74,14 @@ {% endif %}
+
+
Дни
+
{{ job.days_with_frames }} / {{ job.days_total }}
+
+
+
Пропущено дней
+
{{ job.days_skipped }}
+
{% if job.error_message %}
diff --git a/camlaps/views.py b/camlaps/views.py index aa044cf..3967cad 100644 --- a/camlaps/views.py +++ b/camlaps/views.py @@ -146,6 +146,9 @@ def retry_job(request, job_id: int): job.progress_percent = 0 job.frames_total = None job.frames_processed = 0 + job.days_total = 0 + job.days_with_frames = 0 + job.days_skipped = 0 job.error_message = '' job.started_at = None job.finished_at = None @@ -155,6 +158,9 @@ def retry_job(request, job_id: int): 'progress_percent', 'frames_total', 'frames_processed', + 'days_total', + 'days_with_frames', + 'days_skipped', 'error_message', 'started_at', 'finished_at',