mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2024-12-26 15:15:56 -05:00
parent
f385215d6e
commit
1f7c4c184a
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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>,
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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`}
|
||||||
|
@ -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>
|
||||||
|
@ -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/<uuid>/<stream>/<recordings></tt>.
|
* <tt>GET /api/cameras/<uuid>/<stream>/<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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user