добавил выбор времени старта таймлайна
All checks were successful
Deploy timelaps / deploy (push) Successful in 7s

This commit is contained in:
ack
2026-04-19 22:54:01 +03:00
parent 34440ebf73
commit 035cbac430
8 changed files with 165 additions and 6 deletions

View File

@@ -18,6 +18,7 @@ class TimelapseJobAdmin(admin.ModelAdmin):
'date_from', 'date_from',
'date_to', 'date_to',
'sampling_interval_minutes', 'sampling_interval_minutes',
'anchor_time',
'fps', 'fps',
'status', 'status',
'progress_percent', 'progress_percent',
@@ -25,4 +26,13 @@ class TimelapseJobAdmin(admin.ModelAdmin):
) )
list_filter = ('status', 'include_night', 'sampling_preset', 'camera') list_filter = ('status', 'include_night', 'sampling_preset', 'camera')
search_fields = ('camera__name', 'output_rel_path', 'error_message') 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',
)

View File

@@ -22,6 +22,7 @@ class TimelapseJobCreateForm(forms.ModelForm):
'sampling_preset', 'sampling_preset',
'fps', 'fps',
'include_night', 'include_night',
'anchor_time',
'day_start_time', 'day_start_time',
'day_end_time', 'day_end_time',
) )
@@ -39,6 +40,7 @@ class TimelapseJobCreateForm(forms.ModelForm):
), ),
'fps': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 120}), 'fps': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 120}),
'include_night': forms.CheckboxInput(attrs={'class': 'form-check-input'}), '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_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'day_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), 'day_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
} }

View File

@@ -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='Дней с кадрами'),
),
]

View File

@@ -71,6 +71,7 @@ class TimelapseJob(models.Model):
verbose_name='FPS итогового видео', verbose_name='FPS итогового видео',
) )
include_night = models.BooleanField(default=True, verbose_name='Включать ночные кадры') 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_start_time = models.TimeField(default=time(6, 0), verbose_name='Начало дня')
day_end_time = models.TimeField(default=time(22, 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_total = models.PositiveIntegerField(null=True, blank=True, verbose_name='Всего кадров')
frames_processed = models.PositiveIntegerField(default=0, 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') output_rel_path = models.CharField(max_length=255, blank=True, verbose_name='Путь к видео в storage')
error_message = models.TextField(blank=True, verbose_name='Текст ошибки') 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: if not self.include_night and self.day_start_time >= self.day_end_time:
raise ValidationError({'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': 'Якорное время должно попадать в дневной интервал.'})

View File

@@ -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 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) logger.info('worker:select_frames:start job_id=%s', job.id)
selected: list[Path] = [] selected: list[Path] = []
interval_seconds = int(job.sampling_interval_minutes) * 60 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) shutil.rmtree(temp_dir, ignore_errors=True)
camera_root = _safe_camera_root(job) 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: 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('Не найдено кадров под выбранные параметры.') raise RuntimeError('Не найдено кадров под выбранные параметры.')
TimelapseJob.objects.filter(pk=job.pk).update( TimelapseJob.objects.filter(pk=job.pk).update(
frames_total=len(frame_paths), frames_total=len(frame_paths),
frames_processed=0, frames_processed=0,
days_total=days_total,
days_with_frames=days_with_frames,
days_skipped=days_skipped,
progress_percent=5, progress_percent=5,
) )
@@ -232,6 +271,9 @@ def run_specific_job(job_id: int) -> bool:
progress_percent=1, progress_percent=1,
frames_processed=0, frames_processed=0,
frames_total=None, frames_total=None,
days_total=0,
days_with_frames=0,
days_skipped=0,
error_message='', error_message='',
) )

View File

@@ -44,6 +44,22 @@
</div> </div>
</div> </div>
<div class="col-12">
<label class="form-label">Время якоря кадра</label>
<div class="d-flex align-items-center gap-2">
<span class="text-muted">🌙</span>
<input id="anchor-time-range" type="range" min="0" max="1439" step="1" class="form-range" value="720">
<span class="text-muted">☀️</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">00:00</small>
<small class="text-muted">23:59</small>
</div>
<div class="mt-2" style="max-width: 220px;">
{{ form.anchor_time }}
</div>
</div>
<div class="col-md-6" id="day-start-wrap"> <div class="col-md-6" id="day-start-wrap">
<label class="form-label">Начало дня</label> <label class="form-label">Начало дня</label>
{{ form.day_start_time }} {{ form.day_start_time }}
@@ -65,15 +81,45 @@
const includeNight = document.getElementById("{{ form.include_night.auto_id }}"); const includeNight = document.getElementById("{{ form.include_night.auto_id }}");
const dayStart = document.getElementById("day-start-wrap"); const dayStart = document.getElementById("day-start-wrap");
const dayEnd = document.getElementById("day-end-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; const enabled = includeNight && includeNight.checked;
if (dayStart) dayStart.style.display = enabled ? "none" : ""; if (dayStart) dayStart.style.display = enabled ? "none" : "";
if (dayEnd) dayEnd.style.display = enabled ? "none" : ""; if (dayEnd) dayEnd.style.display = enabled ? "none" : "";
} }
if (includeNight) includeNight.addEventListener("change", sync); function syncRangeFromInput() {
sync(); 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();
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -47,6 +47,10 @@
<div class="text-muted">Интервал</div> <div class="text-muted">Интервал</div>
<div class="fw-semibold">{{ job.get_sampling_preset_display }}</div> <div class="fw-semibold">{{ job.get_sampling_preset_display }}</div>
</div> </div>
<div class="col-md-6">
<div class="text-muted">Время якоря</div>
<div class="fw-semibold">{{ job.anchor_time }}</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<div class="text-muted">FPS</div> <div class="text-muted">FPS</div>
<div class="fw-semibold">{{ job.fps }}</div> <div class="fw-semibold">{{ job.fps }}</div>
@@ -70,6 +74,14 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="text-muted">Дни</div>
<div class="fw-semibold">{{ job.days_with_frames }} / {{ job.days_total }}</div>
</div>
<div class="col-md-6">
<div class="text-muted">Пропущено дней</div>
<div class="fw-semibold">{{ job.days_skipped }}</div>
</div>
{% if job.error_message %} {% if job.error_message %}
<div class="col-12"> <div class="col-12">

View File

@@ -146,6 +146,9 @@ def retry_job(request, job_id: int):
job.progress_percent = 0 job.progress_percent = 0
job.frames_total = None job.frames_total = None
job.frames_processed = 0 job.frames_processed = 0
job.days_total = 0
job.days_with_frames = 0
job.days_skipped = 0
job.error_message = '' job.error_message = ''
job.started_at = None job.started_at = None
job.finished_at = None job.finished_at = None
@@ -155,6 +158,9 @@ def retry_job(request, job_id: int):
'progress_percent', 'progress_percent',
'frames_total', 'frames_total',
'frames_processed', 'frames_processed',
'days_total',
'days_with_frames',
'days_skipped',
'error_message', 'error_message',
'started_at', 'started_at',
'finished_at', 'finished_at',