mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-01 05:33:49 +08:00
Ahhh Controls go BRRR
This commit is contained in:
@@ -1239,19 +1239,49 @@
|
||||
}
|
||||
|
||||
.dashcam-player-state[hidden],
|
||||
.dashcam-segment-status[hidden],
|
||||
.dashcam-segment-bar[hidden],
|
||||
.dashcam-camera-selector button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashcam-segment-status {
|
||||
.dashcam-segment-bar {
|
||||
align-items: center;
|
||||
background: var(--sidebar-bg);
|
||||
border-bottom: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
display: flex;
|
||||
gap: var(--gap-xs);
|
||||
padding: 0.65rem var(--padding-lg);
|
||||
}
|
||||
|
||||
.dashcam-segment-bar .segment-step {
|
||||
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);
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
.dashcam-segment-bar .segment-step:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dashcam-segment-bar .segment-select {
|
||||
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.6rem;
|
||||
}
|
||||
|
||||
|
||||
.dashcam-camera-selector {
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--padding-base) var(--padding-lg) 0;
|
||||
@@ -1355,7 +1385,7 @@
|
||||
}
|
||||
|
||||
.dashcam-player-header,
|
||||
.dashcam-segment-status,
|
||||
.dashcam-segment-bar,
|
||||
.dashcam-camera-selector,
|
||||
.dashcam-player-actions {
|
||||
padding-left: var(--padding-base);
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
cameraVideoUrl,
|
||||
computeRouteStats,
|
||||
formatApproxDuration,
|
||||
getSegmentStatus,
|
||||
groupRoutesByDate,
|
||||
getSegmentOptions,
|
||||
supportsLowQuality,
|
||||
groupRoutesForView,
|
||||
MAX_RENDERED_ROUTES,
|
||||
normalizeRoute,
|
||||
} from "/assets/components/recordings/dashcam_routes_helpers.js"
|
||||
@@ -32,6 +33,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
|
||||
|
||||
function routeLabel(route) {
|
||||
return route.displayName || route.displayDate || route.name
|
||||
@@ -119,8 +124,11 @@ function closeDialog(dialog) {
|
||||
}
|
||||
|
||||
function replaceRoute(updatedRoute) {
|
||||
state.routes = state.routes.map(route => route.name === updatedRoute.name ? updatedRoute : route)
|
||||
if (state.selectedRoute?.name === updatedRoute.name) state.selectedRoute = updatedRoute
|
||||
// Rows bind to this exact object, so replacing it would strand them on the stale one.
|
||||
const existing = state.routes.find(route => route.name === updatedRoute.name)
|
||||
if (existing) Object.assign(existing, updatedRoute)
|
||||
const selected = state.selectedRoute
|
||||
if (selected?.name === updatedRoute.name && selected !== existing) Object.assign(selected, updatedRoute)
|
||||
}
|
||||
|
||||
async function deleteRoute(route) {
|
||||
@@ -285,10 +293,14 @@ async function openOverlay(route) {
|
||||
<button class="dashcam-player-close action-close" type="button" aria-label="Close player">×</button>
|
||||
</header>
|
||||
<div class="dashcam-video-shell">
|
||||
<video controls muted playsinline></video>
|
||||
<video controls muted playsinline preload="metadata"></video>
|
||||
<div class="dashcam-player-state" role="status">Loading route metadata…</div>
|
||||
</div>
|
||||
<div class="dashcam-segment-status" aria-live="polite" hidden></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>
|
||||
@@ -305,7 +317,10 @@ async function openOverlay(route) {
|
||||
|
||||
const video = overlay.querySelector("video")
|
||||
const playerState = overlay.querySelector(".dashcam-player-state")
|
||||
const statusStrip = overlay.querySelector(".dashcam-segment-status")
|
||||
const segmentBar = overlay.querySelector(".dashcam-segment-bar")
|
||||
const segmentSelect = overlay.querySelector(".segment-select")
|
||||
const prevSegmentButton = overlay.querySelector(".action-prev-segment")
|
||||
const nextSegmentButton = overlay.querySelector(".action-next-segment")
|
||||
const downloadButton = overlay.querySelector(".action-download")
|
||||
const logsButton = overlay.querySelector(".action-logs")
|
||||
const cameraButtons = [...overlay.querySelectorAll(".camera-button")]
|
||||
@@ -313,27 +328,130 @@ async function openOverlay(route) {
|
||||
let current = 0
|
||||
let selectedCamera = null
|
||||
let logsData = null
|
||||
let qualityToken = 0
|
||||
let warmedSegment = null
|
||||
let upgradeTimer = null
|
||||
|
||||
const setPlayerMessage = (message, isError = false) => {
|
||||
playerState.textContent = message
|
||||
playerState.hidden = !message
|
||||
playerState.classList.toggle("error", isError)
|
||||
}
|
||||
const updateSegmentStatus = () => {
|
||||
const status = getSegmentStatus(segments, current)
|
||||
statusStrip.textContent = status
|
||||
statusStrip.hidden = !status
|
||||
const syncSegmentControls = () => {
|
||||
segmentSelect.value = String(current)
|
||||
segmentSelect.disabled = segments.length < 2
|
||||
prevSegmentButton.disabled = current <= 0
|
||||
nextSegmentButton.disabled = current >= segments.length - 1
|
||||
}
|
||||
const playCurrentSegment = () => {
|
||||
if (!segments[current] || !selectedCamera) return
|
||||
updateSegmentStatus()
|
||||
setPlayerMessage("Loading video…")
|
||||
video.src = cameraVideoUrl(segments[current], selectedCamera)
|
||||
const buildSegmentPicker = () => {
|
||||
segmentSelect.innerHTML = getSegmentOptions(segments)
|
||||
.map(option => `<option value="${option.index}">${escapeHtml(option.label)}</option>`)
|
||||
.join("")
|
||||
segmentBar.hidden = !segments.length
|
||||
}
|
||||
const swapSource = (url, { message } = {}) => {
|
||||
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 (_) {}
|
||||
}
|
||||
if (shouldResume) video.play().catch(() => {})
|
||||
}, { once: true })
|
||||
if (message) setPlayerMessage(message)
|
||||
video.src = url
|
||||
video.load()
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
|
||||
// Full-res needs a device-side remux, so wait for it behind playback rather than in front.
|
||||
const requestFullQuality = (segmentUrl, camera, attempt = 0) => {
|
||||
const token = ++qualityToken
|
||||
const fullUrl = cameraVideoUrl(segmentUrl, camera)
|
||||
const stillCurrent = () =>
|
||||
token === qualityToken && segments[current] === segmentUrl && selectedCamera === camera && !!overlay
|
||||
fetch(fullUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
if (!stillCurrent()) return
|
||||
if (response.ok) {
|
||||
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(() => {
|
||||
if (stillCurrent()) requestFullQuality(segmentUrl, camera, attempt + 1)
|
||||
}, FULL_QUALITY_RETRY_MS)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
overlay._cancelUpgrade = () => {
|
||||
qualityToken += 1
|
||||
clearTimeout(upgradeTimer)
|
||||
}
|
||||
|
||||
const upgradeToFullQuality = (segmentUrl, camera) => {
|
||||
qualityToken += 1
|
||||
clearTimeout(upgradeTimer)
|
||||
upgradeTimer = setTimeout(() => {
|
||||
if (segments[current] === segmentUrl && selectedCamera === camera && overlay) {
|
||||
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 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()
|
||||
}
|
||||
const goToSegment = index => {
|
||||
if (!segments.length) return
|
||||
const target = Math.min(Math.max(index, 0), segments.length - 1)
|
||||
if (target === current) {
|
||||
syncSegmentControls()
|
||||
return
|
||||
}
|
||||
const keepPlaying = video.ended || (!video.paused && !video.error)
|
||||
current = target
|
||||
warmedSegment = null
|
||||
playCurrentSegment(keepPlaying)
|
||||
}
|
||||
const closeOnEscape = event => {
|
||||
if (event.key === "Escape" && !document.querySelector(".route-logs-dialog")) closeOverlay()
|
||||
if (document.querySelector(".route-logs-dialog")) return
|
||||
if (event.key === "Escape") {
|
||||
closeOverlay()
|
||||
return
|
||||
}
|
||||
if (!event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
goToSegment(current - 1)
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
goToSegment(current + 1)
|
||||
}
|
||||
}
|
||||
|
||||
overlay.addEventListener("click", event => { if (event.target === overlay) closeOverlay() })
|
||||
@@ -358,30 +476,23 @@ async function openOverlay(route) {
|
||||
video.addEventListener("playing", () => setPlayerMessage(""))
|
||||
video.addEventListener("waiting", () => setPlayerMessage("Loading video…"))
|
||||
video.addEventListener("error", () => setPlayerMessage("This segment could not be played.", true))
|
||||
video.addEventListener("ended", () => {
|
||||
if (current + 1 >= segments.length) return
|
||||
current += 1
|
||||
playCurrentSegment()
|
||||
})
|
||||
video.addEventListener("ended", () => goToSegment(current + 1))
|
||||
|
||||
prevSegmentButton.onclick = () => goToSegment(current - 1)
|
||||
nextSegmentButton.onclick = () => goToSegment(current + 1)
|
||||
segmentSelect.onchange = () => goToSegment(Number(segmentSelect.value))
|
||||
|
||||
for (const button of cameraButtons) {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return
|
||||
const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0
|
||||
const shouldResume = !video.paused && !video.ended
|
||||
selectedCamera = button.dataset.camera
|
||||
cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button))
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
if (playbackTime > 0) {
|
||||
try {
|
||||
video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime)
|
||||
} catch (_) {}
|
||||
}
|
||||
if (shouldResume) video.play().catch(() => {})
|
||||
}, { once: true })
|
||||
setPlayerMessage("Switching camera…")
|
||||
video.src = cameraVideoUrl(segments[current], selectedCamera)
|
||||
video.load()
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -402,6 +513,7 @@ async function openOverlay(route) {
|
||||
button.classList.toggle("active", button.dataset.camera === selectedCamera)
|
||||
}
|
||||
downloadButton.disabled = false
|
||||
buildSegmentPicker()
|
||||
playCurrentSegment()
|
||||
} catch (error) {
|
||||
cameraButtons.forEach(button => { button.disabled = true })
|
||||
@@ -411,6 +523,7 @@ async function openOverlay(route) {
|
||||
|
||||
function closeOverlay() {
|
||||
if (!overlay) return
|
||||
overlay._cancelUpgrade?.()
|
||||
document.removeEventListener("keydown", overlay._closeOnEscape)
|
||||
overlay.remove()
|
||||
overlay = null
|
||||
@@ -521,8 +634,7 @@ export function RouteRecordings() {
|
||||
|
||||
${() => {
|
||||
const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder })
|
||||
const groups = groupRoutesByDate(view.visible)
|
||||
const isGrid = state.viewMode === "grid"
|
||||
const groups = groupRoutesForView(view.visible, state.sortOrder)
|
||||
|
||||
return html`
|
||||
<div class="dashcam-results-summary" aria-live="polite">
|
||||
@@ -540,13 +652,12 @@ export function RouteRecordings() {
|
||||
<h2>${group.label}</h2>
|
||||
<span class="dashcam-date-group-count">${group.routes.length} ${group.routes.length === 1 ? "drive" : "drives"}</span>
|
||||
</div>
|
||||
${isGrid ? html`
|
||||
<div class="screen-recordings-grid dashcam-routes-grid">
|
||||
${() => state.viewMode === "grid" ? html`<div class="screen-recordings-grid dashcam-routes-grid">
|
||||
${group.routes.map(route => html`
|
||||
<article class="recording-card dashcam-route-card ${route.is_preserved ? "is-preserved" : ""}" @click="${() => { state.selectedRoute = route }}">
|
||||
<article class="${() => `recording-card dashcam-route-card ${route.is_preserved ? "is-preserved" : ""}`}" @click="${() => { state.selectedRoute = route }}">
|
||||
<div class="dashcam-card-top-bar">
|
||||
<button class="btn-route-action btn-preserve ${route.is_preserved ? "active" : ""}" type="button" aria-label="${route.is_preserved ? "Remove preservation" : "Preserve route"}" title="${route.is_preserved ? "Preserved (click to unpreserve)" : "Click to preserve"}" @click="${event => togglePreserved(route, event)}">
|
||||
<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>
|
||||
<button class="${() => `btn-route-action btn-preserve ${route.is_preserved ? "active" : ""}`}" type="button" aria-label="${() => route.is_preserved ? "Remove preservation" : "Preserve route"}" title="${() => route.is_preserved ? "Preserved (click to unpreserve)" : "Click to preserve"}" @click="${event => togglePreserved(route, event)}">
|
||||
<i class="${() => `bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}`}"></i>
|
||||
</button>
|
||||
<span class="meta-pill id-pill" title="Route ID: ${route.name}"><i class="bi bi-hash"></i>${route.name.split("--").slice(1).join("--") || route.name}</span>
|
||||
</div>
|
||||
@@ -581,11 +692,9 @@ export function RouteRecordings() {
|
||||
</div>
|
||||
</div>
|
||||
</article>`)}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="dashcam-routes-list">
|
||||
</div>` : html`<div class="dashcam-routes-list">
|
||||
${group.routes.map(route => html`
|
||||
<article class="dashcam-route-row ${route.is_preserved ? "is-preserved" : ""}" @click="${() => { state.selectedRoute = route }}">
|
||||
<article class="${() => `dashcam-route-row ${route.is_preserved ? "is-preserved" : ""}`}" @click="${() => { state.selectedRoute = route }}">
|
||||
<div class="dashcam-mini-preview">
|
||||
<span class="dashcam-mini-fallback"><i class="bi bi-camera-video"></i></span>
|
||||
<img src="${route.png}" class="dashcam-mini-img" loading="lazy" alt="" @error="${thumbnailFailed}">
|
||||
@@ -600,7 +709,7 @@ export function RouteRecordings() {
|
||||
<div class="dashcam-route-meta-pills">
|
||||
<span class="meta-pill duration-pill" title="Estimated duration"><i class="bi bi-clock"></i> ${formatApproxDuration(route.approxDurationSeconds)}</span>
|
||||
<span class="meta-pill segments-pill" title="Segments recorded"><i class="bi bi-collection-play"></i> ${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"}</span>
|
||||
${route.is_preserved ? html`<span class="meta-pill preserved-pill" title="Preserved from deletion"><i class="bi bi-heart-fill"></i> Preserved</span>` : ""}
|
||||
${() => route.is_preserved ? html`<span class="meta-pill preserved-pill" title="Preserved from deletion"><i class="bi bi-heart-fill"></i> Preserved</span>` : ""}
|
||||
<span class="meta-pill id-pill" title="Route ID: ${route.name}"><i class="bi bi-hash"></i>${route.name.split("--").slice(1).join("--") || route.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,8 +717,8 @@ export function RouteRecordings() {
|
||||
<button class="btn-route-action btn-play" type="button" title="Play recording" @click="${() => { state.selectedRoute = route }}">
|
||||
<i class="bi bi-play-fill"></i> <span>Play</span>
|
||||
</button>
|
||||
<button class="btn-route-action btn-preserve ${route.is_preserved ? "active" : ""}" type="button" aria-label="${route.is_preserved ? "Remove preservation" : "Preserve route"}" title="${route.is_preserved ? "Preserved (click to unpreserve)" : "Click to preserve"}" @click="${event => togglePreserved(route, event)}">
|
||||
<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>
|
||||
<button class="${() => `btn-route-action btn-preserve ${route.is_preserved ? "active" : ""}`}" type="button" aria-label="${() => route.is_preserved ? "Remove preservation" : "Preserve route"}" title="${() => route.is_preserved ? "Preserved (click to unpreserve)" : "Click to preserve"}" @click="${event => togglePreserved(route, event)}">
|
||||
<i class="${() => `bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}`}"></i>
|
||||
</button>
|
||||
<button class="btn-route-action btn-icon" type="button" title="View & download logs" @click="${event => openLogsFromRow(route, event)}">
|
||||
<i class="bi bi-file-earmark-arrow-down"></i>
|
||||
|
||||
@@ -147,6 +147,16 @@ export function groupRoutesByDate(routes, now = new Date(), locale) {
|
||||
return groups
|
||||
}
|
||||
|
||||
export function groupRoutesForView(routes, sortOrder = "newest", now, locale) {
|
||||
// Date headers would scramble a duration sort, so show one flat group instead.
|
||||
if (sortOrder === "longest" || sortOrder === "shortest") {
|
||||
const list = Array.isArray(routes) ? [...routes] : []
|
||||
if (!list.length) return []
|
||||
return [{ key: sortOrder, label: sortOrder === "longest" ? "Longest first" : "Shortest first", routes: list }]
|
||||
}
|
||||
return groupRoutesByDate(routes, now, locale)
|
||||
}
|
||||
|
||||
export function formatApproxDuration(seconds) {
|
||||
const minutes = Math.max(0, Math.round(Number(seconds) / 60) || 0)
|
||||
if (minutes < 1) return "Less than 1 min"
|
||||
@@ -168,10 +178,24 @@ export function getSegmentStatus(segmentUrls, playbackIndex) {
|
||||
if (!Array.isArray(segmentUrls) || !Number.isInteger(playbackIndex) || playbackIndex < 0 || playbackIndex >= segmentUrls.length) return ""
|
||||
const segmentNumber = parseStoredSegmentNumber(segmentUrls[playbackIndex])
|
||||
if (segmentNumber == null) return ""
|
||||
return `Segment ${segmentNumber} · ${playbackIndex + 1} of ${segmentUrls.length}`
|
||||
return `Segment ${segmentNumber}`
|
||||
}
|
||||
|
||||
export function cameraVideoUrl(segmentUrl, camera) {
|
||||
export function getSegmentOptions(segmentUrls) {
|
||||
if (!Array.isArray(segmentUrls)) return []
|
||||
return segmentUrls.map((_, index) => ({
|
||||
index,
|
||||
label: getSegmentStatus(segmentUrls, index) || `Clip ${index + 1}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function cameraVideoUrl(segmentUrl, camera, quality) {
|
||||
const separator = String(segmentUrl).includes("?") ? "&" : "?"
|
||||
return `${segmentUrl}${separator}camera=${encodeURIComponent(camera)}`
|
||||
const url = `${segmentUrl}${separator}camera=${encodeURIComponent(camera)}`
|
||||
return quality ? `${url}&quality=${encodeURIComponent(quality)}` : url
|
||||
}
|
||||
|
||||
// qcamera.ts only exists for the road camera, so the instant-start tier is forward-only.
|
||||
export function supportsLowQuality(camera) {
|
||||
return camera === "forward"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
@@ -391,19 +392,64 @@ def test_preserve_limit_counts_routes_not_segments(monkeypatch, tmp_path):
|
||||
assert client.post("/api/routes/00000099--9f0a7bdf9c/preserve").status_code == 400
|
||||
|
||||
|
||||
def test_video_cache_evicts_oldest_instead_of_wiping_everything(monkeypatch, tmp_path):
|
||||
"""A tight disk used to delete every cached mp4, so playback re-muxed on every request."""
|
||||
cache = tmp_path / "video_cache"
|
||||
cache.mkdir()
|
||||
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
|
||||
monkeypatch.setattr(utilities, "VIDEO_CACHE_MAX_BYTES", 300)
|
||||
|
||||
for index in range(4):
|
||||
entry = cache / f"{index}.mp4"
|
||||
entry.write_bytes(b"x" * 100)
|
||||
os.utime(entry, (1000 + index, 1000 + index))
|
||||
|
||||
utilities._prune_video_cache()
|
||||
|
||||
survivors = sorted(path.name for path in cache.glob("*.mp4"))
|
||||
# Budget is 300 bytes of 400 used, so only the oldest goes.
|
||||
assert survivors == ["1.mp4", "2.mp4", "3.mp4"]
|
||||
|
||||
|
||||
def test_video_cache_never_evicts_the_entry_being_written(monkeypatch, tmp_path):
|
||||
cache = tmp_path / "video_cache"
|
||||
cache.mkdir()
|
||||
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
|
||||
monkeypatch.setattr(utilities, "VIDEO_CACHE_MAX_BYTES", 50)
|
||||
|
||||
for index in range(3):
|
||||
entry = cache / f"{index}.mp4"
|
||||
entry.write_bytes(b"x" * 100)
|
||||
os.utime(entry, (1000 + index, 1000 + index))
|
||||
|
||||
keep = cache / "0.mp4"
|
||||
utilities._prune_video_cache(keep_path=keep)
|
||||
|
||||
assert keep.exists()
|
||||
|
||||
|
||||
def _stub_remux(monkeypatch, tmp_path, payload=b"wrapped-video"):
|
||||
"""Stand in for the ffmpeg remux, returning a real file so send_file can stream it."""
|
||||
wrapped = tmp_path / "wrapped.mp4"
|
||||
wrapped.write_bytes(payload)
|
||||
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", lambda path: wrapped)
|
||||
return wrapped
|
||||
|
||||
|
||||
def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path):
|
||||
segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3, 11)]
|
||||
for segment in segments:
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
monkeypatch.setattr(utilities, "get_video_duration", lambda path: 60)
|
||||
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
|
||||
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_process_builder", lambda path: io.BytesIO(b"wrapped-video"))
|
||||
_stub_remux(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(utilities, "ffmpeg_concat_segments_to_mp4", lambda paths, cache_key=None: io.BytesIO(b"combined-video"))
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
metadata = client.get(f"/api/routes/{ROUTE_NAME}")
|
||||
assert metadata.status_code == 200
|
||||
assert metadata.get_json()["segment_urls"] == [f"/video/{ROUTE_NAME}--{number}" for number in (0, 3, 11)]
|
||||
# One minute per segment, without probing each one with ffprobe.
|
||||
assert metadata.get_json()["total_duration"] == 180
|
||||
|
||||
segment_video = client.get(f"/video/{ROUTE_NAME}--3?camera=forward")
|
||||
assert segment_video.status_code == 200
|
||||
@@ -414,3 +460,99 @@ def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path):
|
||||
assert combined_video.status_code == 200
|
||||
assert combined_video.mimetype == "video/mp4"
|
||||
assert combined_video.data == b"combined-video"
|
||||
|
||||
|
||||
def test_route_metadata_never_probes_segments_with_ffprobe(monkeypatch, tmp_path):
|
||||
"""Probing each segment put one subprocess per segment in front of playback."""
|
||||
for number in (0, 1, 2):
|
||||
segment = _make_segment(tmp_path, segment_num=number)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
|
||||
def explode(path):
|
||||
raise AssertionError(f"ffprobe must stay off the route metadata path: {path}")
|
||||
|
||||
monkeypatch.setattr(utilities, "get_video_duration", explode)
|
||||
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
metadata = client.get(f"/api/routes/{ROUTE_NAME}")
|
||||
assert metadata.status_code == 200
|
||||
assert metadata.get_json()["total_duration"] == 180
|
||||
|
||||
|
||||
def test_low_quality_serves_qcamera_without_touching_ffmpeg(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
(segment / "qcamera.ts").write_bytes(b"qcamera-bytes")
|
||||
|
||||
def explode(path):
|
||||
raise AssertionError(f"the low quality tier must not remux: {path}")
|
||||
|
||||
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", explode)
|
||||
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"
|
||||
|
||||
|
||||
def test_low_quality_falls_back_when_qcamera_is_missing(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)
|
||||
|
||||
# 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
|
||||
|
||||
full = client.get(f"/video/{ROUTE_NAME}--0?camera=forward")
|
||||
assert full.status_code == 200
|
||||
assert full.data == b"wrapped-video"
|
||||
|
||||
|
||||
def test_segment_video_supports_range_requests(monkeypatch, tmp_path):
|
||||
segment = _make_segment(tmp_path, segment_num=0)
|
||||
(segment / "fcamera.hevc").write_bytes(b"hevc")
|
||||
_stub_remux(monkeypatch, tmp_path, payload=b"0123456789")
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
partial = client.get(f"/video/{ROUTE_NAME}--0?camera=forward", headers={"Range": "bytes=2-5"})
|
||||
assert partial.status_code == 206
|
||||
assert partial.data == b"2345"
|
||||
assert partial.headers["Content-Range"] == "bytes 2-5/10"
|
||||
|
||||
# A malformed range used to raise inside the hand-rolled parser.
|
||||
assert client.get(f"/video/{ROUTE_NAME}--0?camera=forward", headers={"Range": "bytes=abc"}).status_code in (200, 416)
|
||||
|
||||
|
||||
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")
|
||||
wrapped = tmp_path / "wrapped.mp4"
|
||||
wrapped.write_bytes(b"wrapped-video")
|
||||
|
||||
calls = []
|
||||
started = threading.Event()
|
||||
|
||||
def slow_remux(path):
|
||||
calls.append(path)
|
||||
started.set()
|
||||
time.sleep(0.3)
|
||||
return wrapped
|
||||
|
||||
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", slow_remux)
|
||||
client = _make_client(monkeypatch, tmp_path)
|
||||
|
||||
results = []
|
||||
def fetch():
|
||||
results.append(client.get(f"/video/{ROUTE_NAME}--0?camera=forward").status_code)
|
||||
|
||||
threads = [threading.Thread(target=fetch) for _ in range(4)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert results == [200, 200, 200, 200]
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -143,7 +143,45 @@ def test_filters_preserved_routes_before_applying_the_render_limit():
|
||||
assert view["allPreserved"] is True
|
||||
|
||||
|
||||
def test_segment_status_uses_stored_sparse_numbers_and_playback_position():
|
||||
def test_duration_sorts_render_as_one_flat_group():
|
||||
result = evaluate("""
|
||||
const routes = [
|
||||
{ name: "a", _startedAtMs: Date.parse("2026-08-20T10:00:00Z"), approxDurationSeconds: 3000 },
|
||||
{ name: "b", _startedAtMs: Date.parse("2026-08-22T10:00:00Z"), approxDurationSeconds: 2700 },
|
||||
{ name: "c", _startedAtMs: Date.parse("2026-08-21T10:00:00Z"), approxDurationSeconds: 1800 },
|
||||
]
|
||||
const view = buildRouteView(routes, { sortOrder: "longest" })
|
||||
const groups = groupRoutesForView(view.visible, "longest", new Date("2026-08-27T12:00:00Z"), "en-US")
|
||||
return { labels: groups.map(g => g.label), order: groups.flatMap(g => g.routes.map(r => r.name)) }
|
||||
""")
|
||||
|
||||
assert result["labels"] == ["Longest first"]
|
||||
assert result["order"] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_date_sorts_still_group_by_day():
|
||||
labels = evaluate("""
|
||||
const routes = [
|
||||
{ name: "a", _startedAtMs: Date.parse("2026-08-20T10:00:00Z"), approxDurationSeconds: 3000 },
|
||||
{ name: "b", _startedAtMs: Date.parse("2026-08-22T10:00:00Z"), approxDurationSeconds: 2700 },
|
||||
]
|
||||
const view = buildRouteView(routes, { sortOrder: "newest" })
|
||||
return groupRoutesForView(view.visible, "newest", new Date("2026-08-27T12:00:00Z"), "en-US").map(g => g.label)
|
||||
""")
|
||||
|
||||
assert labels == ["August 22, 2026", "August 20, 2026"]
|
||||
|
||||
|
||||
def test_grouping_an_empty_list_yields_no_groups():
|
||||
assert evaluate("""
|
||||
return [
|
||||
groupRoutesForView([], "longest").length,
|
||||
groupRoutesForView([], "newest").length,
|
||||
]
|
||||
""") == [0, 0]
|
||||
|
||||
|
||||
def test_segment_status_reports_the_stored_segment_number():
|
||||
statuses = evaluate('''
|
||||
const segments = [
|
||||
"/video/0000006a--9f0a7bdf9c--0",
|
||||
@@ -153,13 +191,29 @@ def test_segment_status_uses_stored_sparse_numbers_and_playback_position():
|
||||
return segments.map((_, index) => getSegmentStatus(segments, index))
|
||||
''')
|
||||
|
||||
assert statuses == [
|
||||
"Segment 0 · 1 of 3",
|
||||
"Segment 3 · 2 of 3",
|
||||
"Segment 11 · 3 of 3",
|
||||
assert statuses == ["Segment 0", "Segment 3", "Segment 11"]
|
||||
|
||||
|
||||
def test_segment_options_label_every_clip_for_the_jump_picker():
|
||||
options = evaluate("""
|
||||
return getSegmentOptions([
|
||||
"/video/0000006a--9f0a7bdf9c--12",
|
||||
"/video/0000006a--9f0a7bdf9c--13",
|
||||
"/video/not-a-segment",
|
||||
])
|
||||
""")
|
||||
|
||||
assert options == [
|
||||
{"index": 0, "label": "Segment 12"},
|
||||
{"index": 1, "label": "Segment 13"},
|
||||
{"index": 2, "label": "Clip 3"},
|
||||
]
|
||||
|
||||
|
||||
def test_segment_options_tolerate_a_missing_segment_list():
|
||||
assert evaluate("return [getSegmentOptions(undefined), getSegmentOptions([])]") == [[], []]
|
||||
|
||||
|
||||
def test_hides_segment_status_when_the_stored_number_is_unsafe():
|
||||
results = evaluate('''
|
||||
return [
|
||||
@@ -172,6 +226,28 @@ def test_hides_segment_status_when_the_stored_number_is_unsafe():
|
||||
assert results == [None, "", ""]
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
""")
|
||||
|
||||
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():
|
||||
"""loggerd writes qcamera.ts alongside the road camera only."""
|
||||
assert evaluate("""
|
||||
return ["forward", "wide", "driver"].map(supportsLowQuality)
|
||||
""") == [True, False, False]
|
||||
|
||||
|
||||
def test_switching_camera_changes_only_the_url_and_not_segment_status():
|
||||
result = evaluate('''
|
||||
const segments = ["/video/0000006a--9f0a7bdf9c--7"]
|
||||
|
||||
@@ -1223,6 +1223,15 @@ ROUTE_THUMBNAIL_CACHE_SECONDS = 7 * 24 * 60 * 60
|
||||
# Browsers only allow a handful of connections per origin, so a request must never
|
||||
# park on the preview queue: give up and let the card fall back, the job keeps running.
|
||||
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
|
||||
# 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")
|
||||
_VIDEO_REMUX_FUTURES = {}
|
||||
_VIDEO_REMUX_LOCK = threading.Lock()
|
||||
_ROUTE_THUMBNAIL_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="route-thumbnail")
|
||||
_ROUTE_THUMBNAIL_FUTURES = {}
|
||||
_ROUTE_THUMBNAIL_LOCK = threading.Lock()
|
||||
@@ -1324,6 +1333,36 @@ def _generate_route_thumbnail(preview_path):
|
||||
return None
|
||||
|
||||
|
||||
def _remove_video_remux_future(key, future):
|
||||
with _VIDEO_REMUX_LOCK:
|
||||
if _VIDEO_REMUX_FUTURES.get(key) is future:
|
||||
_VIDEO_REMUX_FUTURES.pop(key, None)
|
||||
|
||||
|
||||
def _get_or_create_segment_mp4(source_path):
|
||||
"""Remuxed mp4 for one segment, or None if it is not ready in time.
|
||||
|
||||
Concurrent requests share one ffmpeg run instead of racing to write the same file.
|
||||
"""
|
||||
key = str(source_path)
|
||||
created = False
|
||||
with _VIDEO_REMUX_LOCK:
|
||||
future = _VIDEO_REMUX_FUTURES.get(key)
|
||||
if future is None:
|
||||
future = _VIDEO_REMUX_EXECUTOR.submit(utilities.ffmpeg_mp4_wrap_to_path, source_path)
|
||||
_VIDEO_REMUX_FUTURES[key] = future
|
||||
created = True
|
||||
|
||||
if created:
|
||||
future.add_done_callback(lambda completed: _remove_video_remux_future(key, completed))
|
||||
|
||||
try:
|
||||
return future.result(timeout=VIDEO_REMUX_WAIT_SECONDS)
|
||||
except TimeoutError:
|
||||
# The callback keeps the running job deduplicated, then evicts it when done.
|
||||
return None
|
||||
|
||||
|
||||
def _remove_route_thumbnail_future(key, future):
|
||||
with _ROUTE_THUMBNAIL_LOCK:
|
||||
if _ROUTE_THUMBNAIL_FUTURES.get(key) is future:
|
||||
@@ -6425,14 +6464,13 @@ def setup(app):
|
||||
if segments:
|
||||
base_path = os.path.join(footage_path, segments[0])
|
||||
segment_urls = [f"/video/{segment}" for segment in segments]
|
||||
total_duration = sum(
|
||||
utilities.get_video_duration(os.path.join(footage_path, segment, "fcamera.hevc"))
|
||||
for segment in segments
|
||||
)
|
||||
# Probing each segment cost an ffprobe before playback could even start,
|
||||
# and segments are a fixed minute anyway.
|
||||
total_duration = len(segments) * SEGMENT_DURATION_SECONDS
|
||||
return {
|
||||
"name": name,
|
||||
"segment_urls": segment_urls,
|
||||
"total_duration": round(total_duration),
|
||||
"total_duration": total_duration,
|
||||
"date": utilities.get_route_start_time(base_path),
|
||||
"available_cameras": utilities.get_available_cameras(base_path),
|
||||
}, 200
|
||||
@@ -8922,65 +8960,43 @@ def setup(app):
|
||||
|
||||
@app.route("/video/<path>", methods=["GET"])
|
||||
def get_video(path):
|
||||
if not utilities.SEGMENT_RE.fullmatch(path or ""):
|
||||
return {"error": "Invalid segment name"}, 400
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
for footage_path in FOOTAGE_PATHS:
|
||||
filepath = f"{footage_path}{path}/{filename}"
|
||||
filepath = os.path.join(footage_path, path, filename)
|
||||
if os.path.exists(filepath):
|
||||
file_handle = utilities.ffmpeg_mp4_wrap_process_builder(filepath)
|
||||
try:
|
||||
cache_path = _get_or_create_segment_mp4(filepath)
|
||||
except (FileNotFoundError, ValueError) as error:
|
||||
return {"error": str(error)}, 409
|
||||
if cache_path is None:
|
||||
return {"error": "Video is still being prepared"}, 503
|
||||
|
||||
file_handle.seek(0, 2)
|
||||
file_size = file_handle.tell()
|
||||
file_handle.seek(0)
|
||||
|
||||
range_header = request.headers.get('Range', None)
|
||||
if range_header:
|
||||
byte_start = 0
|
||||
byte_end = file_size - 1
|
||||
|
||||
if range_header.startswith('bytes='):
|
||||
range_spec = range_header[6:]
|
||||
if '-' in range_spec:
|
||||
start, end = range_spec.split('-', 1)
|
||||
if start:
|
||||
byte_start = max(0, int(start))
|
||||
if end:
|
||||
byte_end = min(file_size - 1, int(end))
|
||||
|
||||
if byte_start >= file_size:
|
||||
file_handle.close()
|
||||
return Response("Requested Range Not Satisfiable", 416)
|
||||
|
||||
byte_end = max(byte_start, byte_end)
|
||||
|
||||
file_handle.seek(byte_start)
|
||||
read_length = byte_end - byte_start + 1
|
||||
data = file_handle.read(read_length)
|
||||
|
||||
response = Response(
|
||||
data,
|
||||
206,
|
||||
headers={
|
||||
'Content-Range': f'bytes {byte_start}-{byte_end}/{file_size}',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': str(len(data)),
|
||||
'Content-Type': 'video/mp4'
|
||||
}
|
||||
)
|
||||
else:
|
||||
data = file_handle.read()
|
||||
response = Response(
|
||||
data,
|
||||
200,
|
||||
headers={
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': str(file_size),
|
||||
'Content-Type': 'video/mp4'
|
||||
}
|
||||
)
|
||||
|
||||
file_handle.close()
|
||||
return response
|
||||
# send_file streams from disk and handles Range and ETag itself.
|
||||
return send_file(
|
||||
cache_path,
|
||||
mimetype="video/mp4",
|
||||
conditional=True,
|
||||
max_age=VIDEO_CACHE_SECONDS,
|
||||
)
|
||||
return {"error": "Video not found"}, 404
|
||||
|
||||
def main():
|
||||
|
||||
@@ -650,6 +650,57 @@ def encode_parameters(params_dict):
|
||||
encoded_data = base64.b64encode(obfuscated_data.encode("utf-8")).decode("utf-8")
|
||||
return encoded_data
|
||||
|
||||
# The venv ships ffmpeg/ffprobe as console scripts that exec the real binary only
|
||||
# after a full CPython startup. Resolve past the shim once and skip that per call.
|
||||
def _resolve_ffmpeg_binary(name):
|
||||
try:
|
||||
import ffmpeg as ffmpeg_package
|
||||
candidate = Path(ffmpeg_package.__file__).parent / "install" / "bin" / name
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
except Exception:
|
||||
pass
|
||||
return shutil.which(name) or name
|
||||
|
||||
|
||||
FFMPEG_BIN = _resolve_ffmpeg_binary("ffmpeg")
|
||||
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
|
||||
|
||||
|
||||
def _prune_video_cache(keep_path=None):
|
||||
"""Evict oldest-first until the cache fits its budget."""
|
||||
try:
|
||||
entries = []
|
||||
for cache_file in VIDEO_CACHE_PATH.glob("*.mp4"):
|
||||
try:
|
||||
stat = cache_file.stat()
|
||||
except OSError:
|
||||
continue
|
||||
entries.append((stat.st_mtime, stat.st_size, cache_file))
|
||||
except OSError:
|
||||
return
|
||||
|
||||
total = sum(size for _, size, _ in entries)
|
||||
if total <= VIDEO_CACHE_MAX_BYTES:
|
||||
return
|
||||
|
||||
keep = str(keep_path) if keep_path else None
|
||||
for _, size, cache_file in sorted(entries):
|
||||
if total <= VIDEO_CACHE_MAX_BYTES:
|
||||
break
|
||||
if keep and str(cache_file) == keep:
|
||||
continue
|
||||
try:
|
||||
cache_file.unlink()
|
||||
total -= size
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
|
||||
if not input_files:
|
||||
raise ValueError("No input files provided for concatenation")
|
||||
@@ -665,6 +716,8 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
|
||||
if cache_path.exists() and all(cache_path.stat().st_mtime > Path(f).stat().st_mtime for f in input_files):
|
||||
return open(cache_path, "rb")
|
||||
|
||||
_prune_video_cache(keep_path=cache_path)
|
||||
|
||||
list_file = VIDEO_CACHE_PATH / f"{file_hash}.txt"
|
||||
with open(list_file, "w") as f:
|
||||
for seg in input_files:
|
||||
@@ -672,14 +725,14 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
||||
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
||||
"-i", str(list_file), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
check=True
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
||||
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
||||
"-i", str(list_file), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
check=True
|
||||
)
|
||||
@@ -693,7 +746,11 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
|
||||
|
||||
return open(cache_path, "rb")
|
||||
|
||||
def ffmpeg_mp4_wrap_process_builder(filename):
|
||||
def ffmpeg_mp4_wrap_to_path(filename):
|
||||
"""Remux one raw .hevc segment to mp4 and return the cache path.
|
||||
|
||||
Callers get a path, not a handle, so send_file can stream it without reading it all.
|
||||
"""
|
||||
input_path = Path(filename)
|
||||
|
||||
if not input_path.exists():
|
||||
@@ -708,31 +765,29 @@ def ffmpeg_mp4_wrap_process_builder(filename):
|
||||
|
||||
VIDEO_CACHE_PATH.mkdir(exist_ok=True)
|
||||
|
||||
total, used, free = shutil.disk_usage(VIDEO_CACHE_PATH)
|
||||
if free < 500 * 1024 * 1024:
|
||||
for cache_file in VIDEO_CACHE_PATH.glob("*.mp4"):
|
||||
try:
|
||||
cache_file.unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
file_hash = hashlib.md5(str(input_path).encode()).hexdigest()
|
||||
cache_path = VIDEO_CACHE_PATH / f"{file_hash}.mp4"
|
||||
|
||||
if cache_path.exists() and cache_path.stat().st_mtime > input_path.stat().st_mtime:
|
||||
return open(cache_path, "rb")
|
||||
return cache_path
|
||||
|
||||
_prune_video_cache(keep_path=cache_path)
|
||||
|
||||
try:
|
||||
subprocess.run(["ffmpeg", "-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)
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)], check=True)
|
||||
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()
|
||||
raise ValueError(f"Cannot process video file: {input_path}")
|
||||
|
||||
return open(cache_path, "rb")
|
||||
return cache_path
|
||||
|
||||
|
||||
def ffmpeg_mp4_wrap_process_builder(filename):
|
||||
return open(ffmpeg_mp4_wrap_to_path(filename), "rb")
|
||||
|
||||
def format_git_date(raw_date: str):
|
||||
date_object = datetime.strptime(raw_date.split()[1], "%Y-%m-%d")
|
||||
@@ -3014,7 +3069,7 @@ def get_segments_in_route(route_time_str, footage_path):
|
||||
def get_video_duration(input_path):
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
FFPROBE_BIN, "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", str(input_path)
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True)
|
||||
return float(result.stdout)
|
||||
@@ -3105,7 +3160,7 @@ VIDEO_TO_PNG_TIMEOUT_SECONDS = 20
|
||||
def video_to_png(input_path, output_path):
|
||||
try:
|
||||
subprocess.run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
FFMPEG_BIN, "-hide_banner", "-loglevel", "error",
|
||||
"-ss", "1",
|
||||
"-i", str(input_path),
|
||||
"-frames:v", "1",
|
||||
|
||||
Reference in New Issue
Block a user