From 51322d0e003de2fba69be2bbe05bb220bc306f06 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 10 Aug 2026 00:17:10 -0400 Subject: [PATCH] fix(dashcam): wrap action buttons in div, ffmpeg stream for MOV playback - local-videos.js: action buttons go in a div.data-list-actions inside td so td stays a table cell (not flex); thumbnail td gets fixed width 9rem - app.py: video route now pipes ffmpeg with -c copy -movflags frag_keyframe+empty_moov so browsers get a fragmented MP4 stream that starts playing immediately without needing the moov atom at the end Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app.py | 58 ++++++++++++++------------------------- static/js/local-videos.js | 17 ++++++++---- 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/app.py b/app.py index 10cdec0..d5098e6 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network -import os, time +import os, time, subprocess from gevent.pywsgi import WSGIServer @@ -150,48 +150,30 @@ def local_video_file(): except FileNotFoundError: return jsonify(error=f"Local video not found: {relative_path}"), 404 - ext = os.path.splitext(path)[1].lower() - mime_map = {".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska"} - mime = mime_map.get(ext, "video/mp4") - - file_size = os.path.getsize(path) - range_header = request.headers.get("Range") - - if range_header: - m = range_header.strip().replace("bytes=", "").split("-") - byte1 = int(m[0]) if m[0] else 0 - byte2 = int(m[1]) if len(m) > 1 and m[1] else file_size - 1 - byte2 = min(byte2, file_size - 1) - length = byte2 - byte1 + 1 - - def generate_range(): - with open(path, "rb") as f: - f.seek(byte1) - remaining = length - while remaining > 0: - chunk = f.read(min(65536, remaining)) - if not chunk: - break - remaining -= len(chunk) - yield chunk - - resp = Response(generate_range(), 206, mimetype=mime, direct_passthrough=True) - resp.headers["Content-Range"] = f"bytes {byte1}-{byte2}/{file_size}" - resp.headers["Accept-Ranges"] = "bytes" - resp.headers["Content-Length"] = str(length) - return resp - - def generate_full(): - with open(path, "rb") as f: + # Stream via ffmpeg: copy streams into an MP4 container with moov atom first + # so browsers can start playback immediately without downloading the whole file. + def generate_stream(): + cmd = [ + "ffmpeg", "-nostdin", "-loglevel", "error", + "-i", path, + "-c", "copy", # no re-encode, just remux + "-movflags", "frag_keyframe+empty_moov+faststart", + "-f", "mp4", + "pipe:1", + ] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + try: while True: - chunk = f.read(65536) + chunk = proc.stdout.read(65536) if not chunk: break yield chunk + finally: + proc.stdout.close() + proc.wait() - resp = Response(generate_full(), 200, mimetype=mime, direct_passthrough=True) - resp.headers["Accept-Ranges"] = "bytes" - resp.headers["Content-Length"] = str(file_size) + resp = Response(generate_stream(), 200, mimetype="video/mp4", direct_passthrough=True) + resp.headers["Cache-Control"] = "no-cache" return resp @app.route('/api/local-video/thumbnail') diff --git a/static/js/local-videos.js b/static/js/local-videos.js index 22e0cc9..b419b6d 100644 --- a/static/js/local-videos.js +++ b/static/js/local-videos.js @@ -98,6 +98,8 @@ // Thumbnail cell const preview = document.createElement("td"); + preview.style.width = "9rem"; + preview.style.padding = "0.25rem"; const image = document.createElement("img"); image.className = "local-video-thumbnail"; image.alt = ""; @@ -132,16 +134,18 @@ row.appendChild(upload); if (file.uploaded) uploadedCount++; - // Actions - const actions = document.createElement("td"); - actions.className = "data-list-actions"; + // Actions — wrap in div so td stays a table cell (not flex) + const actionsTd = document.createElement("td"); + actionsTd.style.whiteSpace = "nowrap"; + const actionsDiv = document.createElement("div"); + actionsDiv.className = "data-list-actions"; const playButton = document.createElement("button"); playButton.type = "button"; playButton.className = "btn btn-sm btn-outline-primary btn-icon-mobile"; playButton.dataset.playPath = file.path; playButton.innerHTML = ' Play'; - actions.appendChild(playButton); + actionsDiv.appendChild(playButton); const deleteButton = document.createElement("button"); deleteButton.type = "button"; @@ -149,8 +153,9 @@ deleteButton.dataset.deleteLocalPath = file.path; deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"?`; deleteButton.innerHTML = ' Del'; - actions.appendChild(deleteButton); - row.appendChild(actions); + actionsDiv.appendChild(deleteButton); + actionsTd.appendChild(actionsDiv); + row.appendChild(actionsTd); body.appendChild(row); });