добавил выбор времени старта таймлайна
All checks were successful
Deploy timelaps / deploy (push) Successful in 7s
All checks were successful
Deploy timelaps / deploy (push) Successful in 7s
This commit is contained in:
@@ -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',
|
||||||
|
)
|
||||||
|
|||||||
@@ -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'}),
|
||||||
}
|
}
|
||||||
@@ -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='Дней с кадрами'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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': 'Якорное время должно попадать в дневной интервал.'})
|
||||||
|
|||||||
@@ -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='',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user