Tweaky Tweaky

This commit is contained in:
dirwin31
2026-08-27 15:17:20 -07:00
parent f85bfb277b
commit 8bcf4c7abf
7 changed files with 382 additions and 123 deletions
@@ -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">&times;</button>
</header>
<div class="dashcam-video-shell">
<video controls muted playsinline preload="metadata"></video>
<div class="dashcam-player-state" role="status">Loading route metadata&hellip;</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 &amp; 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():
+20 -12
View File
@@ -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)
+24 -5
View File
@@ -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