Fix preview cutoff + sticky left panel
1. Preview endpoint now supports HTTP Range requests (HTTP 206 Partial) - HTML5 video player needs Range support to seek/buffer properly - Without it, video would cut off after a few seconds - Returns chunks of 64KB on demand 2. Left panel (upload form) is now sticky (position: sticky) - Stays in view while right panel (jobs list) scrolls - On mobile (<800px) reverts to normal flow
This commit is contained in:
parent
0ca33be6ac
commit
05fb0081c6
51
app/main.py
51
app/main.py
@ -29,7 +29,7 @@ from fastapi import (
|
|||||||
BackgroundTasks, Request, status
|
BackgroundTasks, Request, status
|
||||||
)
|
)
|
||||||
from fastapi.responses import (
|
from fastapi.responses import (
|
||||||
FileResponse, HTMLResponse, StreamingResponse, JSONResponse
|
FileResponse, HTMLResponse, StreamingResponse, JSONResponse, Response
|
||||||
)
|
)
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
@ -667,14 +667,59 @@ async def download(job_id: str, user: str = Depends(check_auth)):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/preview/{job_id}")
|
@app.get("/api/preview/{job_id}")
|
||||||
async def preview(job_id: str, user: str = Depends(check_auth)):
|
async def preview(job_id: str, request: Request, user: str = Depends(check_auth)):
|
||||||
|
"""Video preview z Range request podporo (potrebno za HTML5 video player)."""
|
||||||
job = load_job(job_id)
|
job = load_job(job_id)
|
||||||
if not job or job.get("status") != "done":
|
if not job or job.get("status") != "done":
|
||||||
raise HTTPException(404, "Ne pripravljen")
|
raise HTTPException(404, "Ne pripravljen")
|
||||||
out = Path(job["output_path"])
|
out = Path(job["output_path"])
|
||||||
if not out.exists():
|
if not out.exists():
|
||||||
raise HTTPException(404, "Output ne obstaja")
|
raise HTTPException(404, "Output ne obstaja")
|
||||||
return FileResponse(out, media_type="video/mp4")
|
|
||||||
|
file_size = out.stat().st_size
|
||||||
|
range_header = request.headers.get("range") or request.headers.get("Range")
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
# Parse "bytes=START-END"
|
||||||
|
try:
|
||||||
|
range_str = range_header.replace("bytes=", "").strip()
|
||||||
|
start_s, end_s = range_str.split("-")
|
||||||
|
start = int(start_s) if start_s else 0
|
||||||
|
end = int(end_s) if end_s else file_size - 1
|
||||||
|
end = min(end, file_size - 1)
|
||||||
|
if start > end or start >= file_size:
|
||||||
|
return Response(status_code=416) # Range Not Satisfiable
|
||||||
|
chunk_size = end - start + 1
|
||||||
|
|
||||||
|
def iter_file():
|
||||||
|
with open(out, "rb") as f:
|
||||||
|
f.seek(start)
|
||||||
|
remaining = chunk_size
|
||||||
|
while remaining > 0:
|
||||||
|
read_size = min(64 * 1024, remaining)
|
||||||
|
data = f.read(read_size)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
remaining -= len(data)
|
||||||
|
yield data
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(chunk_size),
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
}
|
||||||
|
return StreamingResponse(iter_file(), status_code=206, headers=headers,
|
||||||
|
media_type="video/mp4")
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Brez Range — vrni cel file
|
||||||
|
return FileResponse(
|
||||||
|
out,
|
||||||
|
media_type="video/mp4",
|
||||||
|
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/jobs/{job_id}")
|
@app.delete("/api/jobs/{job_id}")
|
||||||
|
|||||||
@ -56,9 +56,20 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
main > section.card:first-of-type {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
main { grid-template-columns: 1fr; }
|
main { grid-template-columns: 1fr; }
|
||||||
|
main > section.card:first-of-type {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user