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 <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
|
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
|
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
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
|
|
||||||
@@ -150,48 +150,30 @@ def local_video_file():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
||||||
|
|
||||||
ext = os.path.splitext(path)[1].lower()
|
# Stream via ffmpeg: copy streams into an MP4 container with moov atom first
|
||||||
mime_map = {".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska"}
|
# so browsers can start playback immediately without downloading the whole file.
|
||||||
mime = mime_map.get(ext, "video/mp4")
|
def generate_stream():
|
||||||
|
cmd = [
|
||||||
file_size = os.path.getsize(path)
|
"ffmpeg", "-nostdin", "-loglevel", "error",
|
||||||
range_header = request.headers.get("Range")
|
"-i", path,
|
||||||
|
"-c", "copy", # no re-encode, just remux
|
||||||
if range_header:
|
"-movflags", "frag_keyframe+empty_moov+faststart",
|
||||||
m = range_header.strip().replace("bytes=", "").split("-")
|
"-f", "mp4",
|
||||||
byte1 = int(m[0]) if m[0] else 0
|
"pipe:1",
|
||||||
byte2 = int(m[1]) if len(m) > 1 and m[1] else file_size - 1
|
]
|
||||||
byte2 = min(byte2, file_size - 1)
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
length = byte2 - byte1 + 1
|
try:
|
||||||
|
|
||||||
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:
|
|
||||||
while True:
|
while True:
|
||||||
chunk = f.read(65536)
|
chunk = proc.stdout.read(65536)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
yield chunk
|
yield chunk
|
||||||
|
finally:
|
||||||
|
proc.stdout.close()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
resp = Response(generate_full(), 200, mimetype=mime, direct_passthrough=True)
|
resp = Response(generate_stream(), 200, mimetype="video/mp4", direct_passthrough=True)
|
||||||
resp.headers["Accept-Ranges"] = "bytes"
|
resp.headers["Cache-Control"] = "no-cache"
|
||||||
resp.headers["Content-Length"] = str(file_size)
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@app.route('/api/local-video/thumbnail')
|
@app.route('/api/local-video/thumbnail')
|
||||||
|
|||||||
@@ -98,6 +98,8 @@
|
|||||||
|
|
||||||
// Thumbnail cell
|
// Thumbnail cell
|
||||||
const preview = document.createElement("td");
|
const preview = document.createElement("td");
|
||||||
|
preview.style.width = "9rem";
|
||||||
|
preview.style.padding = "0.25rem";
|
||||||
const image = document.createElement("img");
|
const image = document.createElement("img");
|
||||||
image.className = "local-video-thumbnail";
|
image.className = "local-video-thumbnail";
|
||||||
image.alt = "";
|
image.alt = "";
|
||||||
@@ -132,16 +134,18 @@
|
|||||||
row.appendChild(upload);
|
row.appendChild(upload);
|
||||||
if (file.uploaded) uploadedCount++;
|
if (file.uploaded) uploadedCount++;
|
||||||
|
|
||||||
// Actions
|
// Actions — wrap in div so td stays a table cell (not flex)
|
||||||
const actions = document.createElement("td");
|
const actionsTd = document.createElement("td");
|
||||||
actions.className = "data-list-actions";
|
actionsTd.style.whiteSpace = "nowrap";
|
||||||
|
const actionsDiv = document.createElement("div");
|
||||||
|
actionsDiv.className = "data-list-actions";
|
||||||
|
|
||||||
const playButton = document.createElement("button");
|
const playButton = document.createElement("button");
|
||||||
playButton.type = "button";
|
playButton.type = "button";
|
||||||
playButton.className = "btn btn-sm btn-outline-primary btn-icon-mobile";
|
playButton.className = "btn btn-sm btn-outline-primary btn-icon-mobile";
|
||||||
playButton.dataset.playPath = file.path;
|
playButton.dataset.playPath = file.path;
|
||||||
playButton.innerHTML = '<i class="fas fa-play"></i><span class="btn-label"> Play</span>';
|
playButton.innerHTML = '<i class="fas fa-play"></i><span class="btn-label"> Play</span>';
|
||||||
actions.appendChild(playButton);
|
actionsDiv.appendChild(playButton);
|
||||||
|
|
||||||
const deleteButton = document.createElement("button");
|
const deleteButton = document.createElement("button");
|
||||||
deleteButton.type = "button";
|
deleteButton.type = "button";
|
||||||
@@ -149,8 +153,9 @@
|
|||||||
deleteButton.dataset.deleteLocalPath = file.path;
|
deleteButton.dataset.deleteLocalPath = file.path;
|
||||||
deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"?`;
|
deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"?`;
|
||||||
deleteButton.innerHTML = '<i class="fas fa-trash"></i><span class="btn-label"> Del</span>';
|
deleteButton.innerHTML = '<i class="fas fa-trash"></i><span class="btn-label"> Del</span>';
|
||||||
actions.appendChild(deleteButton);
|
actionsDiv.appendChild(deleteButton);
|
||||||
row.appendChild(actions);
|
actionsTd.appendChild(actionsDiv);
|
||||||
|
row.appendChild(actionsTd);
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user