mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-31 13:13:44 +08:00
Tweaky Tweaky
This commit is contained in:
@@ -1244,13 +1244,43 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashcam-segment-bar {
|
||||
.dashcam-player-title-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.dashcam-title-rename {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.dashcam-title-rename:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Cameras left, segments centred, actions right - the side columns stay equal so the
|
||||
segment controls sit in the true centre regardless of how wide either side is. */
|
||||
.dashcam-player-toolbar {
|
||||
align-items: center;
|
||||
background: var(--sidebar-bg);
|
||||
border-bottom: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||
display: grid;
|
||||
gap: var(--gap-sm);
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
padding: 0.65rem var(--padding-lg);
|
||||
}
|
||||
|
||||
.dashcam-segment-bar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-xs);
|
||||
padding: 0.65rem var(--padding-lg);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.dashcam-segment-bar .segment-step {
|
||||
@@ -1284,18 +1314,30 @@
|
||||
|
||||
.dashcam-camera-selector {
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--padding-base) var(--padding-lg) 0;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.dashcam-camera-selector button,
|
||||
.dashcam-player-actions button {
|
||||
.dashcam-camera-selector button {
|
||||
background: var(--input-bg);
|
||||
border: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
.dashcam-player-actions button {
|
||||
align-items: center;
|
||||
background: var(--input-bg);
|
||||
border: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: var(--font-size-base);
|
||||
padding: 0.65rem 1rem;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.dashcam-camera-selector button.active {
|
||||
@@ -1304,18 +1346,21 @@
|
||||
}
|
||||
|
||||
.dashcam-player-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--padding-base) var(--padding-lg) var(--padding-lg);
|
||||
gap: var(--gap-xs);
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.dashcam-player-actions .action-download {
|
||||
.dashcam-player-actions .action-download:not(:disabled) {
|
||||
background: var(--color-confirm);
|
||||
}
|
||||
|
||||
.dashcam-player-actions button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dashcam-player-actions .action-delete {
|
||||
background: var(--danger-bg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Responsive Adaptations */
|
||||
@@ -1385,20 +1430,19 @@
|
||||
}
|
||||
|
||||
.dashcam-player-header,
|
||||
.dashcam-segment-bar,
|
||||
.dashcam-camera-selector,
|
||||
.dashcam-player-actions {
|
||||
.dashcam-player-toolbar {
|
||||
padding-left: var(--padding-base);
|
||||
padding-right: var(--padding-base);
|
||||
}
|
||||
|
||||
.dashcam-camera-selector,
|
||||
.dashcam-player-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
/* Too narrow for three columns: stack them and centre each row. */
|
||||
.dashcam-player-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.dashcam-player-actions .action-delete {
|
||||
margin-left: 0;
|
||||
.dashcam-camera-selector,
|
||||
.dashcam-player-actions {
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
computeRouteStats,
|
||||
formatApproxDuration,
|
||||
getSegmentOptions,
|
||||
shouldUpgradeFromHeight,
|
||||
supportsLowQuality,
|
||||
groupRoutesForView,
|
||||
MAX_RENDERED_ROUTES,
|
||||
@@ -33,10 +34,10 @@ let routesRequestToken = 0
|
||||
let seenRouteNames = new Set()
|
||||
let overlay = null
|
||||
const routeLogsCache = new Map()
|
||||
const FULL_QUALITY_RETRIES = 3
|
||||
const FULL_QUALITY_RETRY_MS = 4000
|
||||
// Wait for the viewer to settle, so scrubbing never queues a remux per segment.
|
||||
const FULL_QUALITY_SETTLE_MS = 1500
|
||||
const FULL_QUALITY_RETRIES = 2
|
||||
const FULL_QUALITY_RETRY_MS = 1000
|
||||
// Only ask for the full stream once the viewer settles, so scrubbing queues no remuxes.
|
||||
const FULL_QUALITY_SETTLE_MS = 1200
|
||||
|
||||
function routeLabel(route) {
|
||||
return route.displayName || route.displayDate || route.name
|
||||
@@ -289,28 +290,35 @@ async function openOverlay(route) {
|
||||
overlay.innerHTML = `
|
||||
<section class="media-player-content dashcam-player" role="dialog" aria-modal="true" aria-label="Route player">
|
||||
<header class="dashcam-player-header">
|
||||
<div><p class="dashcam-player-eyebrow">Dashcam route</p><h2 class="media-player-title-text">${escapeHtml(routeLabel(route))}</h2></div>
|
||||
<div class="dashcam-player-heading">
|
||||
<p class="dashcam-player-eyebrow">Dashcam route</p>
|
||||
<div class="dashcam-player-title-row">
|
||||
<h2 class="media-player-title-text">${escapeHtml(routeLabel(route))}</h2>
|
||||
<button class="dashcam-title-rename action-rename" type="button" title="Rename route" aria-label="Rename route"><i class="bi bi-pencil"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dashcam-player-close action-close" type="button" aria-label="Close player">×</button>
|
||||
</header>
|
||||
<div class="dashcam-video-shell">
|
||||
<video controls muted playsinline preload="metadata"></video>
|
||||
<div class="dashcam-player-state" role="status">Loading route metadata…</div>
|
||||
</div>
|
||||
<div class="dashcam-segment-bar" hidden>
|
||||
<button class="segment-step action-prev-segment" type="button" title="Previous segment (Shift + \u2190)" aria-label="Previous segment"><i class="bi bi-skip-start-fill"></i></button>
|
||||
<select class="segment-select" aria-label="Jump to segment"></select>
|
||||
<button class="segment-step action-next-segment" type="button" title="Next segment (Shift + \u2192)" aria-label="Next segment"><i class="bi bi-skip-end-fill"></i></button>
|
||||
</div>
|
||||
<div class="dashcam-camera-selector" aria-label="Camera selector">
|
||||
<button class="camera-button" data-camera="forward" type="button" disabled hidden>Forward</button>
|
||||
<button class="camera-button" data-camera="wide" type="button" disabled hidden>Wide</button>
|
||||
<button class="camera-button" data-camera="driver" type="button" disabled hidden>Driver</button>
|
||||
</div>
|
||||
<div class="dashcam-player-actions">
|
||||
<button class="action-download" type="button" disabled><i class="bi bi-download"></i> Download</button>
|
||||
<button class="action-logs" type="button"><i class="bi bi-file-earmark-arrow-down"></i> Logs</button>
|
||||
<button class="action-rename" type="button"><i class="bi bi-pencil"></i> Rename</button>
|
||||
<button class="action-delete" type="button"><i class="bi bi-trash"></i> Delete</button>
|
||||
<div class="dashcam-player-toolbar">
|
||||
<div class="dashcam-camera-selector" aria-label="Camera selector">
|
||||
<button class="camera-button" data-camera="forward" type="button" disabled hidden>Forward</button>
|
||||
<button class="camera-button" data-camera="wide" type="button" disabled hidden>Wide</button>
|
||||
<button class="camera-button" data-camera="driver" type="button" disabled hidden>Driver</button>
|
||||
</div>
|
||||
<div class="dashcam-segment-bar" hidden>
|
||||
<button class="segment-step action-prev-segment" type="button" title="Previous segment (Shift + \u2190)" aria-label="Previous segment"><i class="bi bi-skip-start-fill"></i></button>
|
||||
<select class="segment-select" aria-label="Jump to segment"></select>
|
||||
<button class="segment-step action-next-segment" type="button" title="Next segment (Shift + \u2192)" aria-label="Next segment"><i class="bi bi-skip-end-fill"></i></button>
|
||||
</div>
|
||||
<div class="dashcam-player-actions">
|
||||
<button class="action-download" type="button" disabled title="Download route" aria-label="Download route"><i class="bi bi-download"></i></button>
|
||||
<button class="action-logs" type="button" title="View & download logs" aria-label="View and download logs"><i class="bi bi-file-earmark-arrow-down"></i></button>
|
||||
<button class="action-delete" type="button" title="Delete route" aria-label="Delete route"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>`
|
||||
document.body.appendChild(overlay)
|
||||
@@ -328,8 +336,10 @@ async function openOverlay(route) {
|
||||
let current = 0
|
||||
let selectedCamera = null
|
||||
let logsData = null
|
||||
let showingPreview = false
|
||||
let wantsPlayback = true
|
||||
let qualityToken = 0
|
||||
let warmedSegment = null
|
||||
let upgradeController = null
|
||||
let upgradeTimer = null
|
||||
|
||||
const setPlayerMessage = (message, isError = false) => {
|
||||
@@ -365,66 +375,71 @@ async function openOverlay(route) {
|
||||
video.load()
|
||||
}
|
||||
|
||||
// Full-res needs a device-side remux, so wait for it behind playback rather than in front.
|
||||
const cancelUpgrade = () => {
|
||||
qualityToken += 1
|
||||
clearTimeout(upgradeTimer)
|
||||
upgradeTimer = null
|
||||
upgradeController?.abort()
|
||||
upgradeController = null
|
||||
}
|
||||
|
||||
// Prepare the real stream behind the playing preview. Only replace the media source
|
||||
// after the server confirms the remux is ready, so slow device work never blanks it.
|
||||
const requestFullQuality = (segmentUrl, camera, attempt = 0) => {
|
||||
const token = ++qualityToken
|
||||
upgradeController?.abort()
|
||||
const controller = new AbortController()
|
||||
upgradeController = controller
|
||||
const fullUrl = cameraVideoUrl(segmentUrl, camera)
|
||||
const stillCurrent = () =>
|
||||
token === qualityToken && segments[current] === segmentUrl && selectedCamera === camera && !!overlay
|
||||
fetch(fullUrl, { method: "HEAD" })
|
||||
token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && !!overlay
|
||||
|
||||
fetch(fullUrl, { method: "HEAD", signal: controller.signal })
|
||||
.then(response => {
|
||||
if (!stillCurrent()) return
|
||||
if (upgradeController === controller) upgradeController = null
|
||||
if (response.ok) {
|
||||
showingPreview = false
|
||||
swapSource(fullUrl)
|
||||
return
|
||||
}
|
||||
// 503 means the remux is queued behind another one; check back a few times.
|
||||
if (response.status === 503 && attempt < FULL_QUALITY_RETRIES) {
|
||||
setTimeout(() => {
|
||||
upgradeTimer = setTimeout(() => {
|
||||
if (stillCurrent()) requestFullQuality(segmentUrl, camera, attempt + 1)
|
||||
}, FULL_QUALITY_RETRY_MS)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(error => {
|
||||
if (upgradeController === controller) upgradeController = null
|
||||
if (error?.name !== "AbortError") console.error("Could not prepare full-quality route video:", error)
|
||||
})
|
||||
}
|
||||
|
||||
overlay._cancelUpgrade = () => {
|
||||
qualityToken += 1
|
||||
clearTimeout(upgradeTimer)
|
||||
}
|
||||
|
||||
const upgradeToFullQuality = (segmentUrl, camera) => {
|
||||
qualityToken += 1
|
||||
clearTimeout(upgradeTimer)
|
||||
const scheduleUpgrade = (segmentUrl, camera) => {
|
||||
cancelUpgrade()
|
||||
upgradeTimer = setTimeout(() => {
|
||||
if (segments[current] === segmentUrl && selectedCamera === camera && overlay) {
|
||||
requestFullQuality(segmentUrl, camera)
|
||||
}
|
||||
if (!showingPreview || segments[current] !== segmentUrl || selectedCamera !== camera) return
|
||||
requestFullQuality(segmentUrl, camera)
|
||||
}, FULL_QUALITY_SETTLE_MS)
|
||||
}
|
||||
|
||||
const warmNextSegment = () => {
|
||||
const nextUrl = segments[current + 1]
|
||||
if (!nextUrl || !selectedCamera || !supportsLowQuality(selectedCamera)) return
|
||||
if (warmedSegment === nextUrl) return
|
||||
warmedSegment = nextUrl
|
||||
// Only the ffmpeg-free stream is warmed; never transcode a segment nobody watches.
|
||||
fetch(cameraVideoUrl(nextUrl, selectedCamera, "low"), { method: "HEAD" }).catch(() => {})
|
||||
const loadSegment = (autoplay, { message, preview } = {}) => {
|
||||
const segmentUrl = segments[current]
|
||||
const camera = selectedCamera
|
||||
if (!segmentUrl || !camera) return
|
||||
cancelUpgrade()
|
||||
wantsPlayback = autoplay
|
||||
showingPreview = preview === undefined ? supportsLowQuality(camera) : preview
|
||||
setPlayerMessage(message || "Loading video…")
|
||||
video.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined)
|
||||
video.load()
|
||||
if (autoplay) video.play().catch(() => {})
|
||||
}
|
||||
|
||||
const playCurrentSegment = (autoplay = true) => {
|
||||
if (!segments[current] || !selectedCamera) return
|
||||
syncSegmentControls()
|
||||
setPlayerMessage("Loading video…")
|
||||
const segmentUrl = segments[current]
|
||||
const camera = selectedCamera
|
||||
const useLowFirst = supportsLowQuality(camera)
|
||||
qualityToken += 1
|
||||
video.src = cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined)
|
||||
video.load()
|
||||
if (autoplay) video.play().catch(() => {})
|
||||
if (useLowFirst) upgradeToFullQuality(segmentUrl, camera)
|
||||
warmNextSegment()
|
||||
loadSegment(autoplay)
|
||||
}
|
||||
const goToSegment = index => {
|
||||
if (!segments.length) return
|
||||
@@ -435,9 +450,10 @@ async function openOverlay(route) {
|
||||
}
|
||||
const keepPlaying = video.ended || (!video.paused && !video.error)
|
||||
current = target
|
||||
warmedSegment = null
|
||||
playCurrentSegment(keepPlaying)
|
||||
}
|
||||
overlay._cancelUpgrade = cancelUpgrade
|
||||
|
||||
const closeOnEscape = event => {
|
||||
if (document.querySelector(".route-logs-dialog")) return
|
||||
if (event.key === "Escape") {
|
||||
@@ -472,10 +488,27 @@ async function openOverlay(route) {
|
||||
link.remove()
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
if (!showingPreview) return
|
||||
if (shouldUpgradeFromHeight(video.videoHeight)) {
|
||||
scheduleUpgrade(segments[current], selectedCamera)
|
||||
} else {
|
||||
showingPreview = false
|
||||
}
|
||||
})
|
||||
video.addEventListener("loadeddata", () => setPlayerMessage(""))
|
||||
video.addEventListener("playing", () => setPlayerMessage(""))
|
||||
video.addEventListener("waiting", () => setPlayerMessage("Loading video…"))
|
||||
video.addEventListener("error", () => setPlayerMessage("This segment could not be played.", true))
|
||||
video.addEventListener("error", () => {
|
||||
// A dead preview drops through to the real stream rather than showing an error.
|
||||
if (showingPreview) {
|
||||
showingPreview = false
|
||||
cancelUpgrade()
|
||||
loadSegment(wantsPlayback, { preview: false })
|
||||
return
|
||||
}
|
||||
setPlayerMessage("This segment could not be played.", true)
|
||||
})
|
||||
video.addEventListener("ended", () => goToSegment(current + 1))
|
||||
|
||||
prevSegmentButton.onclick = () => goToSegment(current - 1)
|
||||
@@ -487,12 +520,16 @@ async function openOverlay(route) {
|
||||
if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return
|
||||
selectedCamera = button.dataset.camera
|
||||
cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button))
|
||||
const segmentUrl = segments[current]
|
||||
const camera = selectedCamera
|
||||
const useLowFirst = supportsLowQuality(camera)
|
||||
qualityToken += 1
|
||||
swapSource(cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined), { message: "Switching camera…" })
|
||||
if (useLowFirst) upgradeToFullQuality(segmentUrl, camera)
|
||||
const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0
|
||||
const shouldResume = !video.paused && !video.ended
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
if (playbackTime > 0) {
|
||||
try {
|
||||
video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime)
|
||||
} catch (_) {}
|
||||
}
|
||||
}, { once: true })
|
||||
loadSegment(shouldResume, { message: "Switching camera…" })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,13 @@ export function cameraVideoUrl(segmentUrl, camera, quality) {
|
||||
return quality ? `${url}&quality=${encodeURIComponent(quality)}` : url
|
||||
}
|
||||
|
||||
// qcamera.ts only exists for the road camera, so the instant-start tier is forward-only.
|
||||
// loggerd only writes qcamera.ts alongside the road camera.
|
||||
export function supportsLowQuality(camera) {
|
||||
return camera === "forward"
|
||||
}
|
||||
|
||||
// qcamera is 526x330. Only a positively taller frame proves the real stream is already
|
||||
// playing; an unknown height upgrades rather than stranding the viewer on the preview.
|
||||
export function shouldUpgradeFromHeight(height) {
|
||||
return !(Number.isFinite(height) && height > 400)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime, timezone
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
@@ -480,35 +481,162 @@ def test_route_metadata_never_probes_segments_with_ffprobe(monkeypatch, tmp_path
|
||||
assert metadata.get_json()["total_duration"] == 180
|
||||
|
||||
|
||||
def test_low_quality_serves_qcamera_without_touching_ffmpeg(monkeypatch, tmp_path):
|
||||
def test_low_quality_serves_the_wrapped_qcamera_preview(monkeypatch, tmp_path):
|
||||
"""qcamera.ts is tiny, but it still needs the mp4 wrap - MPEG-TS will not play in a <video>."""
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
(segment / "qcamera.ts").write_bytes(b"qcamera-bytes")
|
||||
(segment / "qcamera.ts").write_bytes(b"ts")
|
||||
|
||||
def explode(path):
|
||||
raise AssertionError(f"the low quality tier must not remux: {path}")
|
||||
preview_mp4 = tmp_path / "preview.mp4"
|
||||
preview_mp4.write_bytes(b"preview-video")
|
||||
full_mp4 = tmp_path / "full.mp4"
|
||||
full_mp4.write_bytes(b"full-video")
|
||||
|
||||
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", explode)
|
||||
wrapped = []
|
||||
def wrap(path):
|
||||
wrapped.append(os.path.basename(str(path)))
|
||||
return preview_mp4 if str(path).endswith("qcamera.ts") else full_mp4
|
||||
|
||||
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", wrap)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
low = client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low")
|
||||
assert low.status_code == 200
|
||||
assert low.data == b"qcamera-bytes"
|
||||
assert low.mimetype == "video/mp4"
|
||||
assert low.data == b"preview-video"
|
||||
|
||||
full = client.get(f"/video/{ROUTE_NAME}--0?camera=forward")
|
||||
assert full.data == b"full-video"
|
||||
assert wrapped == ["qcamera.ts", "fcamera.hevc"]
|
||||
|
||||
|
||||
def test_low_quality_falls_back_when_qcamera_is_missing(monkeypatch, tmp_path):
|
||||
def test_low_quality_falls_through_to_the_full_stream_when_qcamera_is_missing(monkeypatch, tmp_path):
|
||||
"""The player always asks for the preview, so a missing one must never be an error."""
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
_stub_remux(monkeypatch, tmp_path)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
# No qcamera.ts on disk, and the driver camera never has one.
|
||||
assert client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low").status_code == 404
|
||||
assert client.get(f"/video/{ROUTE_NAME}--0?camera=driver&quality=low").status_code == 404
|
||||
low = client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low")
|
||||
assert low.status_code == 200
|
||||
assert low.data == b"wrapped-video"
|
||||
|
||||
full = client.get(f"/video/{ROUTE_NAME}--0?camera=forward")
|
||||
assert full.status_code == 200
|
||||
assert full.data == b"wrapped-video"
|
||||
|
||||
def test_low_quality_falls_through_when_the_preview_cannot_be_wrapped(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
(segment / "qcamera.ts").write_bytes(b"ts")
|
||||
|
||||
full_mp4 = tmp_path / "full.mp4"
|
||||
full_mp4.write_bytes(b"full-video")
|
||||
|
||||
def wrap(path):
|
||||
if str(path).endswith("qcamera.ts"):
|
||||
raise ValueError("corrupt preview")
|
||||
return full_mp4
|
||||
|
||||
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", wrap)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
low = client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low")
|
||||
assert low.status_code == 200
|
||||
assert low.data == b"full-video"
|
||||
|
||||
|
||||
def test_only_the_road_camera_has_a_preview(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "ecamera.hevc").write_bytes(b"hevc")
|
||||
(segment / "qcamera.ts").write_bytes(b"ts")
|
||||
_stub_remux(monkeypatch, tmp_path)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
wide = client.get(f"/video/{ROUTE_NAME}--0?camera=wide&quality=low")
|
||||
assert wide.data == b"wrapped-video"
|
||||
|
||||
|
||||
def test_preview_timeout_does_not_wait_again_for_the_full_stream(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
(segment / "qcamera.ts").write_bytes(b"ts")
|
||||
calls = []
|
||||
|
||||
def not_ready(path):
|
||||
calls.append(Path(path).name)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(the_galaxy, "_get_or_create_segment_mp4", not_ready)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
response = client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low")
|
||||
assert response.status_code == 503
|
||||
assert calls == ["qcamera.ts"]
|
||||
|
||||
|
||||
def test_in_progress_segment_is_not_playable(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
(segment / "qcamera.ts").write_bytes(b"ts")
|
||||
(segment / "rlog.lock").touch()
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
response = client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low")
|
||||
assert response.status_code == 409
|
||||
assert "still being recorded" in response.get_json()["error"]
|
||||
|
||||
|
||||
def test_completed_segment_remux_reuses_the_disk_cache(monkeypatch, tmp_path):
|
||||
source = tmp_path / "fcamera.hevc"
|
||||
source.write_bytes(b"hevc")
|
||||
os.utime(source, (1000, 1000))
|
||||
cache = tmp_path / "video_cache"
|
||||
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
|
||||
calls = []
|
||||
|
||||
def wrap(command, check, timeout):
|
||||
calls.append(timeout)
|
||||
Path(command[-1]).write_bytes(b"mp4")
|
||||
|
||||
monkeypatch.setattr(utilities.subprocess, "run", wrap)
|
||||
first = utilities.ffmpeg_mp4_wrap_to_path(source)
|
||||
second = utilities.ffmpeg_mp4_wrap_to_path(source)
|
||||
|
||||
assert first == second
|
||||
assert len(calls) == 1
|
||||
assert 0 < calls[0] <= utilities.VIDEO_REMUX_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def test_segment_remux_timeout_is_bounded_and_removes_partial_output(monkeypatch, tmp_path):
|
||||
source = tmp_path / "fcamera.hevc"
|
||||
source.write_bytes(b"hevc")
|
||||
cache = tmp_path / "video_cache"
|
||||
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
|
||||
timeouts = []
|
||||
|
||||
def timeout(command, check, timeout):
|
||||
timeouts.append(timeout)
|
||||
Path(command[-1]).write_bytes(b"partial")
|
||||
raise subprocess.TimeoutExpired(command, timeout)
|
||||
|
||||
monkeypatch.setattr(utilities.subprocess, "run", timeout)
|
||||
|
||||
with pytest.raises(ValueError, match="Timed out processing video file"):
|
||||
utilities.ffmpeg_mp4_wrap_to_path(source)
|
||||
|
||||
assert len(timeouts) == 1
|
||||
assert 0 < timeouts[0] <= utilities.VIDEO_REMUX_TIMEOUT_SECONDS
|
||||
assert not list(cache.glob("*.mp4"))
|
||||
|
||||
|
||||
def test_segment_video_falls_back_across_cameras(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
_stub_remux(monkeypatch, tmp_path)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
assert client.get(f"/video/{ROUTE_NAME}--0?camera=forward").data == b"wrapped-video"
|
||||
# No ecamera.hevc on disk for this segment.
|
||||
assert client.get(f"/video/{ROUTE_NAME}--0?camera=wide").status_code == 404
|
||||
assert client.get("/video/not-a-segment?camera=forward").status_code == 400
|
||||
|
||||
|
||||
def test_segment_video_supports_range_requests(monkeypatch, tmp_path):
|
||||
@@ -526,6 +654,18 @@ def test_segment_video_supports_range_requests(monkeypatch, tmp_path):
|
||||
assert client.get(f"/video/{ROUTE_NAME}--0?camera=forward", headers={"Range": "bytes=abc"}).status_code in (200, 416)
|
||||
|
||||
|
||||
def test_head_request_prepares_full_quality_without_sending_the_body(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
_stub_remux(monkeypatch, tmp_path)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
prepared = client.head(f"/video/{ROUTE_NAME}--0?camera=forward")
|
||||
assert prepared.status_code == 200
|
||||
assert prepared.mimetype == "video/mp4"
|
||||
assert prepared.data == b""
|
||||
|
||||
|
||||
def test_concurrent_requests_for_one_segment_share_a_single_remux(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
|
||||
@@ -229,23 +229,28 @@ def test_hides_segment_status_when_the_stored_number_is_unsafe():
|
||||
def test_camera_video_url_carries_an_optional_quality_tier():
|
||||
result = evaluate("""
|
||||
const segment = "/video/0000006a--9f0a7bdf9c--7"
|
||||
return {
|
||||
full: cameraVideoUrl(segment, "forward"),
|
||||
low: cameraVideoUrl(segment, "forward", "low"),
|
||||
lowWide: cameraVideoUrl(segment, "wide", "low"),
|
||||
}
|
||||
return { full: cameraVideoUrl(segment, "forward"), low: cameraVideoUrl(segment, "forward", "low") }
|
||||
""")
|
||||
|
||||
assert result["full"] == "/video/0000006a--9f0a7bdf9c--7?camera=forward"
|
||||
assert result["low"] == "/video/0000006a--9f0a7bdf9c--7?camera=forward&quality=low"
|
||||
assert result["lowWide"] == "/video/0000006a--9f0a7bdf9c--7?camera=wide&quality=low"
|
||||
|
||||
|
||||
def test_only_the_road_camera_has_a_low_quality_tier():
|
||||
def test_only_the_road_camera_has_a_preview():
|
||||
"""loggerd writes qcamera.ts alongside the road camera only."""
|
||||
assert evaluate('return ["forward", "wide", "driver"].map(supportsLowQuality)') == [True, False, False]
|
||||
|
||||
|
||||
def test_upgrade_decision_only_trusts_a_positively_tall_frame():
|
||||
"""The server falls back to the full stream, so the frame size is what settles it."""
|
||||
assert evaluate("""
|
||||
return ["forward", "wide", "driver"].map(supportsLowQuality)
|
||||
""") == [True, False, False]
|
||||
return {
|
||||
qcamera: shouldUpgradeFromHeight(330),
|
||||
full: shouldUpgradeFromHeight(1080),
|
||||
unknown: shouldUpgradeFromHeight(0),
|
||||
missing: shouldUpgradeFromHeight(undefined),
|
||||
}
|
||||
""") == {"qcamera": True, "full": False, "unknown": True, "missing": True}
|
||||
|
||||
|
||||
def test_switching_camera_changes_only_the_url_and_not_segment_status():
|
||||
|
||||
@@ -1225,8 +1225,9 @@ ROUTE_THUMBNAIL_CACHE_SECONDS = 7 * 24 * 60 * 60
|
||||
ROUTE_THUMBNAIL_WAIT_SECONDS = 25
|
||||
# One minute per segment, matching loggerd's segment length.
|
||||
SEGMENT_DURATION_SECONDS = 60
|
||||
# Only ever remux one segment at a time; the driving stack needs the headroom.
|
||||
VIDEO_REMUX_WAIT_SECONDS = 25
|
||||
# Only ever remux one segment at a time; the driving stack needs the headroom. The
|
||||
# subprocess timeout is the hard bound, with a small allowance for executor handoff.
|
||||
VIDEO_REMUX_WAIT_SECONDS = utilities.VIDEO_REMUX_TIMEOUT_SECONDS + 5
|
||||
# Segment media never changes once loggerd has closed it, so let the browser keep it.
|
||||
VIDEO_CACHE_SECONDS = 7 * 24 * 60 * 60
|
||||
_VIDEO_REMUX_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="video-remux")
|
||||
@@ -8966,19 +8967,26 @@ def setup(app):
|
||||
camera = request.args.get("camera")
|
||||
filename = {"driver": "dcamera.hevc", "wide": "ecamera.hevc"}.get(camera, "fcamera.hevc")
|
||||
|
||||
# loggerd writes qcamera.ts as H.264 in MPEG-TS, so it plays with no ffmpeg at
|
||||
# all. It exists for the road camera only, so other views skip this tier.
|
||||
# qcamera.ts is a 526x330 companion to the road camera, so wrapping it costs a
|
||||
# fraction of the full stream. It still needs the mp4 wrap - a bare MPEG-TS will
|
||||
# not play in a <video>. Anything missing falls through to the full stream.
|
||||
if request.args.get("quality") == "low" and filename == "fcamera.hevc":
|
||||
for footage_path in FOOTAGE_PATHS:
|
||||
preview_path = os.path.join(footage_path, path, "qcamera.ts")
|
||||
if os.path.isfile(preview_path):
|
||||
return send_file(
|
||||
preview_path,
|
||||
mimetype="video/mp2t",
|
||||
conditional=True,
|
||||
max_age=VIDEO_CACHE_SECONDS,
|
||||
)
|
||||
return {"error": "Low quality video not available"}, 404
|
||||
if not os.path.isfile(preview_path):
|
||||
continue
|
||||
try:
|
||||
preview_mp4 = _get_or_create_segment_mp4(preview_path)
|
||||
except (FileNotFoundError, ValueError):
|
||||
break
|
||||
if preview_mp4 is None:
|
||||
return {"error": "Preview video is still being prepared"}, 503
|
||||
return send_file(
|
||||
preview_mp4,
|
||||
mimetype="video/mp4",
|
||||
conditional=True,
|
||||
max_age=VIDEO_CACHE_SECONDS,
|
||||
)
|
||||
|
||||
for footage_path in FOOTAGE_PATHS:
|
||||
filepath = os.path.join(footage_path, path, filename)
|
||||
|
||||
@@ -669,6 +669,9 @@ FFPROBE_BIN = _resolve_ffmpeg_binary("ffprobe")
|
||||
# Bound the cache by its own size rather than reacting to free space: loggerd already
|
||||
# keeps the disk near full, so the old policy wiped every mp4 on almost every request.
|
||||
VIDEO_CACHE_MAX_BYTES = 512 * 1024 * 1024
|
||||
# A malformed or truncated segment must not occupy the Galaxy's only remux worker
|
||||
# forever. Stream-copy normally finishes in seconds; this also bounds the fallback.
|
||||
VIDEO_REMUX_TIMEOUT_SECONDS = 60
|
||||
|
||||
|
||||
def _prune_video_cache(keep_path=None):
|
||||
@@ -772,15 +775,31 @@ def ffmpeg_mp4_wrap_to_path(filename):
|
||||
return cache_path
|
||||
|
||||
_prune_video_cache(keep_path=cache_path)
|
||||
deadline = time.monotonic() + VIDEO_REMUX_TIMEOUT_SECONDS
|
||||
|
||||
def remaining_time():
|
||||
return max(0.1, deadline - time.monotonic())
|
||||
|
||||
try:
|
||||
subprocess.run([FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)], check=True)
|
||||
subprocess.run(
|
||||
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-i", str(input_path),
|
||||
"-c", "copy", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
check=True,
|
||||
timeout=remaining_time(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
cache_path.unlink(missing_ok=True)
|
||||
raise ValueError(f"Timed out processing video file: {input_path}")
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
subprocess.run([FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)], check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
subprocess.run(
|
||||
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-i", str(input_path),
|
||||
"-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
check=True,
|
||||
timeout=remaining_time(),
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
cache_path.unlink(missing_ok=True)
|
||||
raise ValueError(f"Cannot process video file: {input_path}")
|
||||
|
||||
return cache_path
|
||||
|
||||
Reference in New Issue
Block a user