seamlessly merge minor VSE changes

Improves #302.
This commit is contained in:
Scott Lamb 2024-02-12 17:35:27 -08:00
parent f385215d6e
commit 1f7c4c184a
8 changed files with 210 additions and 19 deletions

View File

@ -8,6 +8,12 @@ upgrades, e.g. `v0.6.x` -> `v0.7.x`. The config file format and
[API](ref/api.md) currently have no stability guarantees, so they may change [API](ref/api.md) currently have no stability guarantees, so they may change
even on minor releases, e.g. `v0.7.5` -> `v0.7.6`. even on minor releases, e.g. `v0.7.5` -> `v0.7.6`.
## unreleased
* seamlessly merge together recordings which have imperceptible changes in
their `VideoSampleEntry`. Improves
[#302](https://github.com/scottlamb/moonfire-nvr/issues/302).
## v0.7.12 (2024-01-08) ## v0.7.12 (2024-01-08)
* update to Retina 0.4.7, supporting RTSP servers that do not set * update to Retina 0.4.7, supporting RTSP servers that do not set

View File

@ -342,6 +342,7 @@ arbitrary order. Each recording object has the following properties:
together are as described. Adjacent recordings from the same RTSP session together are as described. Adjacent recordings from the same RTSP session
may be coalesced in this fashion to reduce the amount of redundant data may be coalesced in this fashion to reduce the amount of redundant data
transferred. transferred.
* `runStartId`. The id of the first recording in this run.
* `firstUncommitted` (optional). If this range is not fully committed to the * `firstUncommitted` (optional). If this range is not fully committed to the
database, the first id that is uncommitted. This is significant because database, the first id that is uncommitted. This is significant because
it's possible that after a crash and restart, this id will refer to a it's possible that after a crash and restart, this id will refer to a

View File

@ -470,6 +470,7 @@ pub struct Recording {
pub video_sample_entry_id: i32, pub video_sample_entry_id: i32,
pub start_id: i32, pub start_id: i32,
pub open_id: u32, pub open_id: u32,
pub run_start_id: i32,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub first_uncommitted: Option<i32>, pub first_uncommitted: Option<i32>,

View File

@ -496,6 +496,7 @@ impl Service {
} else { } else {
Some(end) Some(end)
}, },
run_start_id: row.run_start_id,
start_time_90k: row.time.start.0, start_time_90k: row.time.start.0,
end_time_90k: row.time.end.0, end_time_90k: row.time.end.0,
sample_file_bytes: row.sample_file_bytes, sample_file_bytes: row.sample_file_bytes,

View File

@ -10,7 +10,7 @@ import { setupServer } from "msw/node";
import { Recording, VideoSampleEntry } from "../api"; import { Recording, VideoSampleEntry } from "../api";
import { renderWithCtx } from "../testutil"; import { renderWithCtx } from "../testutil";
import { Camera, Stream } from "../types"; import { Camera, Stream } from "../types";
import VideoList from "./VideoList"; import VideoList, { combine } from "./VideoList";
import { beforeAll, afterAll, afterEach, expect, test } from "vitest"; import { beforeAll, afterAll, afterEach, expect, test } from "vitest";
const TEST_CAMERA: Camera = { const TEST_CAMERA: Camera = {
@ -45,9 +45,30 @@ const TEST_RANGE2: [number, number] = [
]; ];
const TEST_RECORDINGS1: Recording[] = [ const TEST_RECORDINGS1: Recording[] = [
{
startId: 44,
openId: 1,
runStartId: 44,
startTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
endTime90k: 145750558950000, // 2021-04-26T08:24:15:00000-07:00
videoSampleEntryId: 4,
videoSamples: 1860,
sampleFileBytes: 248000,
},
{
startId: 43,
openId: 1,
runStartId: 40,
startTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00
endTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
videoSampleEntryId: 4,
videoSamples: 1860,
sampleFileBytes: 248000,
},
{ {
startId: 42, startId: 42,
openId: 1, openId: 1,
runStartId: 40,
startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00 startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00
endTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00 endTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00
videoSampleEntryId: 4, videoSampleEntryId: 4,
@ -60,6 +81,7 @@ const TEST_RECORDINGS2: Recording[] = [
{ {
startId: 42, startId: 42,
openId: 1, openId: 1,
runStartId: 40,
startTime90k: 145757651670000, // 2021-04-27T06:17:43:00000-07:00 startTime90k: 145757651670000, // 2021-04-27T06:17:43:00000-07:00
endTime90k: 145757656980000, // 2021-04-27T06:18:42:00000-07:00 endTime90k: 145757656980000, // 2021-04-27T06:18:42:00000-07:00
videoSampleEntryId: 4, videoSampleEntryId: 4,
@ -116,6 +138,59 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers()); afterEach(() => server.resetHandlers());
afterAll(() => server.close()); afterAll(() => server.close());
test("combine", () => {
const actual = combine(undefined, {
videoSampleEntries: TEST_VIDEO_SAMPLE_ENTRIES,
recordings: TEST_RECORDINGS1,
});
const expected = [
// 44 shouldn't be combined; it's not from the same run as the others.
{
startId: 44,
endId: 44,
openId: 1,
runStartId: 44,
startTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
endTime90k: 145750558950000, // 2021-04-26T08:24:15:00000-07:00
videoSamples: 1860,
sampleFileBytes: 248000,
aspectWidth: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectWidth,
aspectHeight: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectHeight,
width: TEST_VIDEO_SAMPLE_ENTRIES[4].width,
height: TEST_VIDEO_SAMPLE_ENTRIES[4].height,
firstUncommitted: undefined,
growing: undefined,
},
// 42 and 43 are combinable.
{
startId: 42,
endId: 43,
openId: 1,
runStartId: 40,
startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00
endTime90k: 145750553550000, // 2021-04-26T08:23:15:00000-07:00
videoSamples: 3720,
sampleFileBytes: 496000,
aspectWidth: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectWidth,
aspectHeight: TEST_VIDEO_SAMPLE_ENTRIES[4].aspectHeight,
width: TEST_VIDEO_SAMPLE_ENTRIES[4].width,
height: TEST_VIDEO_SAMPLE_ENTRIES[4].height,
firstUncommitted: undefined,
growing: undefined,
},
];
// XXX: unsure why this doesn't work:
//
// expect(actual).toContainEqual(expected)
//
// ...but this does:
expect(actual).toHaveLength(expected.length);
for (let i = 0; i < expected.length; i++) {
expect(actual[i]).toEqual(expected[i]);
}
});
test("load", async () => { test("load", async () => {
renderWithCtx( renderWithCtx(
<table> <table>

View File

@ -17,12 +17,97 @@ interface Props {
range90k: [number, number] | null; range90k: [number, number] | null;
split90k?: number; split90k?: number;
trimStartAndEnd: boolean; trimStartAndEnd: boolean;
setActiveRecording: ( setActiveRecording: (recording: [Stream, CombinedRecording] | null) => void;
recording: [Stream, api.Recording, api.VideoSampleEntry] | null
) => void;
formatTime: (time90k: number) => string; formatTime: (time90k: number) => string;
} }
/**
* Matches `api.Recording`, except that two entries with differing
* `videoSampleEntryId` but the same resolution may be combined.
*/
export interface CombinedRecording {
startId: number;
endId?: number;
runStartId: number;
firstUncommitted?: number;
growing?: boolean;
openId: number;
startTime90k: number;
endTime90k: number;
videoSamples: number;
sampleFileBytes: number;
width: number;
height: number;
aspectWidth: number;
aspectHeight: number;
}
/**
* Combines recordings, which are assumed to already be sorted in descending
* chronological order.
*
* This is exported only for testing.
*/
export function combine(
split90k: number | undefined,
response: api.RecordingsResponse
): CombinedRecording[] {
let out = [];
let cur = null;
for (const r of response.recordings) {
const vse = response.videoSampleEntries[r.videoSampleEntryId];
// Combine `r` into `cur` if `r` precedes r, shouldn't be split, and
// has similar resolution. It doesn't have to have exactly the same
// video sample entry; minor changes to encoding can be seamlessly
// combined into one `.mp4` file.
if (
cur !== null &&
r.openId === cur.openId &&
r.runStartId === cur.runStartId &&
(r.endId ?? r.startId) + 1 === cur.startId &&
cur.width === vse.width &&
cur.height === vse.height &&
cur.aspectWidth === vse.aspectWidth &&
cur.aspectHeight === vse.aspectHeight &&
(split90k === undefined || r.endTime90k - cur.startTime90k <= split90k)
) {
cur.startId = r.startId;
cur.firstUncommitted == r.firstUncommitted ?? cur.firstUncommitted;
cur.startTime90k = r.startTime90k;
cur.videoSamples += r.videoSamples;
cur.sampleFileBytes += r.sampleFileBytes;
continue;
}
// Otherwise, start a new `cur`, flushing any existing one.
if (cur !== null) {
out.push(cur);
}
cur = {
startId: r.startId,
endId: r.endId ?? r.startId,
runStartId: r.runStartId,
firstUncommitted: r.firstUncommitted,
growing: r.growing,
openId: r.openId,
startTime90k: r.startTime90k,
endTime90k: r.endTime90k,
videoSamples: r.videoSamples,
sampleFileBytes: r.sampleFileBytes,
width: vse.width,
height: vse.height,
aspectWidth: vse.aspectWidth,
aspectHeight: vse.aspectHeight,
};
}
if (cur !== null) {
out.push(cur);
}
return out;
}
const frameRateFmt = new Intl.NumberFormat([], { const frameRateFmt = new Intl.NumberFormat([], {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}); });
@ -37,7 +122,8 @@ interface State {
* During loading, this can differ from the requested range. * During loading, this can differ from the requested range.
*/ */
range90k: [number, number]; range90k: [number, number];
response: { status: "skeleton" } | api.FetchResult<api.RecordingsResponse>; split90k?: number;
response: { status: "skeleton" } | api.FetchResult<CombinedRecording[]>;
} }
interface RowProps extends TableRowProps { interface RowProps extends TableRowProps {
@ -111,12 +197,21 @@ const VideoList = ({
split90k, split90k,
}; };
let response = await api.recordings(req, { signal }); let response = await api.recordings(req, { signal });
if (response.status === "success") {
// Sort recordings in descending order by start time.
response.response.recordings.sort((a, b) => b.startId - a.startId);
}
clearTimeout(timerId); clearTimeout(timerId);
setState({ range90k, response }); if (response.status === "success") {
// Sort recordings in descending order.
response.response.recordings.sort((a, b) => b.startId - a.startId);
setState({
range90k,
split90k,
response: {
status: "success",
response: combine(split90k, response.response),
},
});
} else {
setState({ range90k, split90k, response });
}
}; };
if (range90k !== null) { if (range90k !== null) {
const timerId = setTimeout( const timerId = setTimeout(
@ -157,8 +252,7 @@ const VideoList = ({
); );
} else if (state.response.status === "success") { } else if (state.response.status === "success") {
const resp = state.response.response; const resp = state.response.response;
body = resp.recordings.map((r: api.Recording) => { body = resp.map((r: CombinedRecording) => {
const vse = resp.videoSampleEntries[r.videoSampleEntryId];
const durationSec = (r.endTime90k - r.startTime90k) / 90000; const durationSec = (r.endTime90k - r.startTime90k) / 90000;
const rate = (r.sampleFileBytes / durationSec) * 0.000008; const rate = (r.sampleFileBytes / durationSec) * 0.000008;
const start = trimStartAndEnd const start = trimStartAndEnd
@ -171,10 +265,10 @@ const VideoList = ({
<Row <Row
key={r.startId} key={r.startId}
className="recording" className="recording"
onClick={() => setActiveRecording([stream, r, vse])} onClick={() => setActiveRecording([stream, r])}
start={formatTime(start)} start={formatTime(start)}
end={formatTime(end)} end={formatTime(end)}
resolution={`${vse.width}x${vse.height}`} resolution={`${r.width}x${r.height}`}
fps={frameRateFmt.format(r.videoSamples / durationSec)} fps={frameRateFmt.format(r.videoSamples / durationSec)}
storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`} storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`}
bitrate={`${sizeFmt.format(rate)} Mbps`} bitrate={`${sizeFmt.format(rate)} Mbps`}

View File

@ -16,7 +16,7 @@ import { Stream } from "../types";
import DisplaySelector, { DEFAULT_DURATION } from "./DisplaySelector"; import DisplaySelector, { DEFAULT_DURATION } from "./DisplaySelector";
import StreamMultiSelector from "./StreamMultiSelector"; import StreamMultiSelector from "./StreamMultiSelector";
import TimerangeSelector from "./TimerangeSelector"; import TimerangeSelector from "./TimerangeSelector";
import VideoList from "./VideoList"; import VideoList, { CombinedRecording } from "./VideoList";
import { useLayoutEffect } from "react"; import { useLayoutEffect } from "react";
import { fillAspect } from "../aspect"; import { fillAspect } from "../aspect";
import useResizeObserver from "@react-hook/resize-observer"; import useResizeObserver from "@react-hook/resize-observer";
@ -208,7 +208,7 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
); );
const [activeRecording, setActiveRecording] = useState< const [activeRecording, setActiveRecording] = useState<
[Stream, api.Recording, api.VideoSampleEntry] | null [Stream, CombinedRecording] | null
>(null); >(null);
const formatTime = useMemo(() => { const formatTime = useMemo(() => {
return (time90k: number) => { return (time90k: number) => {
@ -341,8 +341,8 @@ const Main = ({ toplevel, timeZoneName, Frame }: Props) => {
trimStartAndEnd ? range90k! : undefined trimStartAndEnd ? range90k! : undefined
)} )}
aspect={[ aspect={[
activeRecording[2].aspectWidth, activeRecording[1].aspectWidth,
activeRecording[2].aspectHeight, activeRecording[1].aspectHeight,
]} ]}
/> />
</Modal> </Modal>

View File

@ -325,6 +325,16 @@ export async function deleteUser(
}); });
} }
export interface RecordingSpecifier {
startId: number;
endId?: number;
firstUncommitted?: number;
growing?: boolean;
openId: number;
startTime90k: number;
endTime90k: number;
}
/** /**
* Represents a range of one or more recordings as in a single array entry of * Represents a range of one or more recordings as in a single array entry of
* <tt>GET /api/cameras/&lt;uuid>/&lt;stream>/&lt;recordings></tt>. * <tt>GET /api/cameras/&lt;uuid>/&lt;stream>/&lt;recordings></tt>.
@ -339,6 +349,9 @@ export interface Recording {
*/ */
endId?: number; endId?: number;
/** id of the first recording in this run. */
runStartId: number;
/** /**
* If this range is not fully committed to the database, the first id that is * If this range is not fully committed to the database, the first id that is
* uncommitted. This is significant because it's possible that after a crash * uncommitted. This is significant because it's possible that after a crash
@ -459,7 +472,7 @@ export async function recordings(req: RecordingsRequest, init: RequestInit) {
export function recordingUrl( export function recordingUrl(
cameraUuid: string, cameraUuid: string,
stream: StreamType, stream: StreamType,
r: Recording, r: RecordingSpecifier,
timestampTrack: boolean, timestampTrack: boolean,
trimToRange90k?: [number, number] trimToRange90k?: [number, number]
): string { ): string {