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

@@ -10,7 +10,7 @@ import { setupServer } from "msw/node";
import { Recording, VideoSampleEntry } from "../api";
import { renderWithCtx } from "../testutil";
import { Camera, Stream } from "../types";
import VideoList from "./VideoList";
import VideoList, { combine } from "./VideoList";
import { beforeAll, afterAll, afterEach, expect, test } from "vitest";
const TEST_CAMERA: Camera = {
@@ -45,9 +45,30 @@ const TEST_RANGE2: [number, number] = [
];
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,
openId: 1,
runStartId: 40,
startTime90k: 145750542570000, // 2021-04-26T08:21:13:00000-07:00
endTime90k: 145750548150000, // 2021-04-26T08:22:15:00000-07:00
videoSampleEntryId: 4,
@@ -60,6 +81,7 @@ const TEST_RECORDINGS2: Recording[] = [
{
startId: 42,
openId: 1,
runStartId: 40,
startTime90k: 145757651670000, // 2021-04-27T06:17:43:00000-07:00
endTime90k: 145757656980000, // 2021-04-27T06:18:42:00000-07:00
videoSampleEntryId: 4,
@@ -116,6 +138,59 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
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 () => {
renderWithCtx(
<table>

View File

@@ -17,12 +17,97 @@ interface Props {
range90k: [number, number] | null;
split90k?: number;
trimStartAndEnd: boolean;
setActiveRecording: (
recording: [Stream, api.Recording, api.VideoSampleEntry] | null
) => void;
setActiveRecording: (recording: [Stream, CombinedRecording] | null) => void;
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([], {
maximumFractionDigits: 0,
});
@@ -37,7 +122,8 @@ interface State {
* During loading, this can differ from the requested range.
*/
range90k: [number, number];
response: { status: "skeleton" } | api.FetchResult<api.RecordingsResponse>;
split90k?: number;
response: { status: "skeleton" } | api.FetchResult<CombinedRecording[]>;
}
interface RowProps extends TableRowProps {
@@ -111,12 +197,21 @@ const VideoList = ({
split90k,
};
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);
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) {
const timerId = setTimeout(
@@ -157,8 +252,7 @@ const VideoList = ({
);
} else if (state.response.status === "success") {
const resp = state.response.response;
body = resp.recordings.map((r: api.Recording) => {
const vse = resp.videoSampleEntries[r.videoSampleEntryId];
body = resp.map((r: CombinedRecording) => {
const durationSec = (r.endTime90k - r.startTime90k) / 90000;
const rate = (r.sampleFileBytes / durationSec) * 0.000008;
const start = trimStartAndEnd
@@ -171,10 +265,10 @@ const VideoList = ({
<Row
key={r.startId}
className="recording"
onClick={() => setActiveRecording([stream, r, vse])}
onClick={() => setActiveRecording([stream, r])}
start={formatTime(start)}
end={formatTime(end)}
resolution={`${vse.width}x${vse.height}`}
resolution={`${r.width}x${r.height}`}
fps={frameRateFmt.format(r.videoSamples / durationSec)}
storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`}
bitrate={`${sizeFmt.format(rate)} Mbps`}

View File

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