feat(dashcam): mobile layout fix + optional hw-encode conversion on import

- yaris.css: on mobile (<576px) hide file/size/date/upload cols, show
  filename inside thumb cell, icon-only buttons, dash-actions-td nowrap
- local-videos.js: filename in thumb cell for mobile; Converting badge with
  spinner; auto-refresh every 10s while any file is converting
- app_video.py: CONVERT_SETTING, is_convert_enabled(), queue_convert(),
  _convert_bg() — h264_v4l2m2m + aac faststart, deletes original on success
- app_dash.py: call queue_convert() after each successful copy on import
- app_settings.py: expose video_convert_enabled in get/save settings
- settings.html: toggle for 'Convert imported videos for web playback'

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 00:30:59 -04:00
co-authored by Copilot
parent dd2f95b9c1
commit 01704d5429
7 changed files with 130 additions and 18 deletions
+3
View File
@@ -520,6 +520,9 @@ def copy_files(source_folder, destination_folder, relative_paths=None):
try:
shutil.copy2(abs_path, dest_path)
copied += 1
# Queue background conversion if enabled (import app_video lazily to avoid circular import)
from modules import app_video as _app_video
_app_video.queue_convert(rel_path)
except OSError as e:
errors.append(f"{rel_path}: {e}")
+4 -3
View File
@@ -38,6 +38,7 @@ def get_settings():
return {
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
"local_video_retention_days": app_video.retention_days(),
"video_convert_enabled": app_video.is_convert_enabled(),
"cellular_enabled": _setting_bool(
"cellular_enabled", "YARIS_CELLULAR_ENABLED"
),
@@ -98,6 +99,7 @@ def update_settings(body):
values = {}
for field in (
"dashcam_upload_enabled",
"video_convert_enabled",
"cellular_enabled",
"ntfy_enabled",
"clear_ntfy_url",
@@ -182,9 +184,8 @@ def update_settings(body):
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
"1" if values["dashcam_upload_enabled"] else "0"
),
app_video.RETENTION_SETTING: str(
values["local_video_retention_days"]
),
app_video.RETENTION_SETTING: str(values["local_video_retention_days"]),
app_video.CONVERT_SETTING: "1" if values["video_convert_enabled"] else "0",
"cellular_enabled": "1" if values["cellular_enabled"] else "0",
"cellular_apn": apn,
"alerts_enabled": "1" if values["ntfy_enabled"] else "0",
+70 -1
View File
@@ -12,10 +12,76 @@ from modules import app_dash, app_sql
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails")
RETENTION_SETTING = "local_video_retention_days"
CONVERT_SETTING = "video_convert_enabled"
_thumbnail_lock = Semaphore()
# Tracks which relative paths are currently being generated (set of strings)
_generating = set()
_generating_lock = Semaphore()
_converting = set()
_converting_lock = Semaphore()
def is_convert_enabled():
return app_sql.get_setting(CONVERT_SETTING, "0") == "1"
def queue_convert(relative_path):
"""Spawn a background greenlet to re-encode relative_path to web-ready MP4.
No-op if conversion is disabled, already running, or file is already MP4."""
if not is_convert_enabled():
return
if relative_path.lower().endswith(".mp4"):
return
with _converting_lock:
if relative_path in _converting:
return
_converting.add(relative_path)
gevent.spawn(_convert_bg, relative_path)
def _convert_bg(relative_path):
try:
source_path = _local_path(relative_path)
mp4_relative = os.path.splitext(relative_path)[0] + ".mp4"
mp4_path = _local_path(mp4_relative)
if not app_dash.begin_local_video_use(relative_path):
return
try:
tmp_path = mp4_path + ".converting.mp4"
cmd = [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", source_path,
"-c:v", "h264_v4l2m2m",
"-c:a", "aac",
"-movflags", "faststart",
"-f", "mp4",
tmp_path,
]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
while proc.poll() is None:
sleep(1)
if proc.returncode == 0 and os.path.isfile(tmp_path) and os.path.getsize(tmp_path) > 0:
os.replace(tmp_path, mp4_path)
# Delete original now that MP4 is ready
try:
os.remove(source_path)
except OSError:
pass
# Also remove old thumbnail so it gets regenerated from MP4
_source, thumb = _thumbnail_path(relative_path)
if os.path.isfile(thumb):
try:
os.remove(thumb)
except OSError:
pass
elif os.path.exists(tmp_path):
os.remove(tmp_path)
finally:
app_dash.end_local_video_use(relative_path)
finally:
with _converting_lock:
_converting.discard(relative_path)
def _local_path(relative_path):
@@ -47,6 +113,8 @@ def list_local_videos():
except OSError as e:
errors.append(f"{relative_path}: {e}")
continue
with _converting_lock:
converting = relative_path in _converting
files.append(
{
"path": relative_path,
@@ -57,6 +125,7 @@ def list_local_videos():
stat.st_size,
stat.st_mtime,
) in uploaded_stats,
"converting": converting,
}
)
total_size += stat.st_size