mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-01-14 16:25:02 -05:00
serve 'video/mp4; codecs="avc1.xxxxxx"' mime type
This can be used when constructing a HTML5 SourceBuffer.
This commit is contained in:
parent
9eff91f7da
commit
7673a00bd9
@ -182,7 +182,9 @@ Example response:
|
|||||||
|
|
||||||
### `/camera/<uuid>/view.mp4`
|
### `/camera/<uuid>/view.mp4`
|
||||||
|
|
||||||
A GET returns a `.mp4` file, with an etag and support for range requests.
|
A GET returns a `.mp4` file, with an etag and support for range requests. The
|
||||||
|
MIME type will be `video/mp4`, with a `codecs` parameter as specified in [RFC
|
||||||
|
6381][rfc-6381].
|
||||||
|
|
||||||
Expected query parameters:
|
Expected query parameters:
|
||||||
|
|
||||||
@ -227,7 +229,8 @@ TODO: error behavior on missing segment. It should be a 404, likely with an
|
|||||||
### `/camera/<uuid>/view.m4s`
|
### `/camera/<uuid>/view.m4s`
|
||||||
|
|
||||||
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions
|
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions
|
||||||
media segment][media-segment].
|
media segment][media-segment]. The MIME type will be `video/mp4`, with a
|
||||||
|
`codecs` parameter as specified in [RFC 6381][rfc-6381].
|
||||||
|
|
||||||
Expected query parameters:
|
Expected query parameters:
|
||||||
|
|
||||||
@ -252,11 +255,8 @@ recording segment for several reasons:
|
|||||||
### `/init/<sha1>.mp4`
|
### `/init/<sha1>.mp4`
|
||||||
|
|
||||||
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions
|
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions
|
||||||
initialization segment][init-segment].
|
initialization segment][init-segment]. The MIME type will be `video/mp4`, with
|
||||||
|
a `codecs` parameter as specified in [RFC 6381][rfc-6381].
|
||||||
TODO: return a MIME type with a `Codecs` parameter as in [RFC 6381](rfc-6381). Web
|
|
||||||
browsers expect this parameter when initializing a `SourceBuffer`; currently
|
|
||||||
the user agent must divine this information.
|
|
||||||
|
|
||||||
[media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments
|
[media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments
|
||||||
[init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments
|
[init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments
|
||||||
|
31
src/db.rs
31
src/db.rs
@ -53,6 +53,7 @@
|
|||||||
|
|
||||||
use error::{Error, ResultExt};
|
use error::{Error, ResultExt};
|
||||||
use fnv;
|
use fnv;
|
||||||
|
use h264;
|
||||||
use lru_cache::LruCache;
|
use lru_cache::LruCache;
|
||||||
use openssl::hash;
|
use openssl::hash;
|
||||||
use parking_lot::{Mutex,MutexGuard};
|
use parking_lot::{Mutex,MutexGuard};
|
||||||
@ -221,11 +222,12 @@ impl rusqlite::types::FromSql for PlaybackData {
|
|||||||
/// the codec, width, height, etc.
|
/// the codec, width, height, etc.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct VideoSampleEntry {
|
pub struct VideoSampleEntry {
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub rfc6381_codec: String,
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub width: u16,
|
pub width: u16,
|
||||||
pub height: u16,
|
pub height: u16,
|
||||||
pub sha1: [u8; 20],
|
pub sha1: [u8; 20],
|
||||||
pub data: Vec<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A row used in `list_recordings_by_time` and `list_recordings_by_id`.
|
/// A row used in `list_recordings_by_time` and `list_recordings_by_id`.
|
||||||
@ -1077,12 +1079,18 @@ impl LockedDatabase {
|
|||||||
id, sha1_vec.len())));
|
id, sha1_vec.len())));
|
||||||
}
|
}
|
||||||
sha1.copy_from_slice(&sha1_vec);
|
sha1.copy_from_slice(&sha1_vec);
|
||||||
self.state.video_sample_entries.insert(id, Arc::new(VideoSampleEntry{
|
let data: Vec<u8> = row.get_checked(4)?;
|
||||||
|
|
||||||
|
// TODO: store this in the database rather than have codec-specific dispatch logic here.
|
||||||
|
let rfc6381_codec = h264::rfc6381_codec_from_sample_entry(&data)?;
|
||||||
|
|
||||||
|
self.state.video_sample_entries.insert(id, Arc::new(VideoSampleEntry {
|
||||||
id: id as i32,
|
id: id as i32,
|
||||||
width: row.get_checked::<_, i32>(2)? as u16,
|
width: row.get_checked::<_, i32>(2)? as u16,
|
||||||
height: row.get_checked::<_, i32>(3)? as u16,
|
height: row.get_checked::<_, i32>(3)? as u16,
|
||||||
sha1: sha1,
|
sha1,
|
||||||
data: row.get_checked(4)?,
|
data,
|
||||||
|
rfc6381_codec,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
info!("Loaded {} video sample entries",
|
info!("Loaded {} video sample entries",
|
||||||
@ -1140,8 +1148,9 @@ impl LockedDatabase {
|
|||||||
|
|
||||||
/// Inserts the specified video sample entry if absent.
|
/// Inserts the specified video sample entry if absent.
|
||||||
/// On success, returns the id of a new or existing row.
|
/// On success, returns the id of a new or existing row.
|
||||||
pub fn insert_video_sample_entry(&mut self, w: u16, h: u16, data: &[u8]) -> Result<i32, Error> {
|
pub fn insert_video_sample_entry(&mut self, w: u16, h: u16, data: Vec<u8>,
|
||||||
let sha1 = hash::hash2(hash::MessageDigest::sha1(), data)?;
|
rfc6381_codec: String) -> Result<i32, Error> {
|
||||||
|
let sha1 = hash::hash2(hash::MessageDigest::sha1(), &data)?;
|
||||||
let mut sha1_bytes = [0u8; 20];
|
let mut sha1_bytes = [0u8; 20];
|
||||||
sha1_bytes.copy_from_slice(&sha1);
|
sha1_bytes.copy_from_slice(&sha1);
|
||||||
|
|
||||||
@ -1168,12 +1177,13 @@ impl LockedDatabase {
|
|||||||
])?;
|
])?;
|
||||||
|
|
||||||
let id = self.conn.last_insert_rowid() as i32;
|
let id = self.conn.last_insert_rowid() as i32;
|
||||||
self.state.video_sample_entries.insert(id, Arc::new(VideoSampleEntry{
|
self.state.video_sample_entries.insert(id, Arc::new(VideoSampleEntry {
|
||||||
id: id,
|
id: id,
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
sha1: sha1_bytes,
|
sha1: sha1_bytes,
|
||||||
data: data.to_vec(),
|
data: data,
|
||||||
|
rfc6381_codec,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
@ -1485,6 +1495,7 @@ mod tests {
|
|||||||
assert_eq!(r.video_samples, row.video_samples);
|
assert_eq!(r.video_samples, row.video_samples);
|
||||||
assert_eq!(r.video_sync_samples, row.video_sync_samples);
|
assert_eq!(r.video_sync_samples, row.video_sync_samples);
|
||||||
assert_eq!(r.sample_file_bytes, row.sample_file_bytes);
|
assert_eq!(r.sample_file_bytes, row.sample_file_bytes);
|
||||||
|
assert_eq!(row.video_sample_entry.rfc6381_codec, "avc1.4d0029");
|
||||||
Ok(())
|
Ok(())
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
}
|
}
|
||||||
@ -1628,7 +1639,9 @@ mod tests {
|
|||||||
assert_unsorted_eq(db.lock().list_reserved_sample_files().unwrap(),
|
assert_unsorted_eq(db.lock().list_reserved_sample_files().unwrap(),
|
||||||
vec![uuid_to_use, uuid_to_keep]);
|
vec![uuid_to_use, uuid_to_keep]);
|
||||||
|
|
||||||
let vse_id = db.lock().insert_video_sample_entry(768, 512, &[0u8; 100]).unwrap();
|
let vse_id = db.lock().insert_video_sample_entry(
|
||||||
|
1920, 1080, include_bytes!("testdata/avc1").to_vec(),
|
||||||
|
"avc1.4d0029".to_owned()).unwrap();
|
||||||
assert!(vse_id > 0, "vse_id = {}", vse_id);
|
assert!(vse_id > 0, "vse_id = {}", vse_id);
|
||||||
|
|
||||||
// Inserting a recording should succeed and remove its uuid from the reserved table.
|
// Inserting a recording should succeed and remove its uuid from the reserved table.
|
||||||
|
34
src/h264.rs
34
src/h264.rs
@ -95,6 +95,7 @@ fn parse_annex_b_extra_data(data: &[u8]) -> Result<(&[u8], &[u8])> {
|
|||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub struct ExtraData {
|
pub struct ExtraData {
|
||||||
pub sample_entry: Vec<u8>,
|
pub sample_entry: Vec<u8>,
|
||||||
|
pub rfc6381_codec: String,
|
||||||
pub width: u16,
|
pub width: u16,
|
||||||
pub height: u16,
|
pub height: u16,
|
||||||
|
|
||||||
@ -134,7 +135,7 @@ impl ExtraData {
|
|||||||
|
|
||||||
// This is a concatenation of the following boxes/classes.
|
// This is a concatenation of the following boxes/classes.
|
||||||
|
|
||||||
// SampleEntry, ISO/IEC 14496-10 section 8.5.2.
|
// SampleEntry, ISO/IEC 14496-12 section 8.5.2.
|
||||||
let avc1_len_pos = sample_entry.len();
|
let avc1_len_pos = sample_entry.len();
|
||||||
sample_entry.write_u32::<BigEndian>(avc1_len as u32)?; // length
|
sample_entry.write_u32::<BigEndian>(avc1_len as u32)?; // length
|
||||||
// type + reserved + data_reference_index = 1
|
// type + reserved + data_reference_index = 1
|
||||||
@ -215,15 +216,28 @@ impl ExtraData {
|
|||||||
length {}", avc1_len, sample_entry.len() - avc1_len_pos,
|
length {}", avc1_len, sample_entry.len() - avc1_len_pos,
|
||||||
avc_decoder_config_len)));
|
avc_decoder_config_len)));
|
||||||
}
|
}
|
||||||
Ok(ExtraData{
|
let rfc6381_codec = rfc6381_codec_from_sample_entry(&sample_entry)?;
|
||||||
sample_entry: sample_entry,
|
Ok(ExtraData {
|
||||||
width: width,
|
sample_entry,
|
||||||
height: height,
|
rfc6381_codec,
|
||||||
need_transform: need_transform,
|
width,
|
||||||
|
height,
|
||||||
|
need_transform,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rfc6381_codec_from_sample_entry(sample_entry: &[u8]) -> Result<String> {
|
||||||
|
if sample_entry.len() < 99 || &sample_entry[4..8] != b"avc1" ||
|
||||||
|
&sample_entry[90..94] != b"avcC" {
|
||||||
|
return Err(Error::new("not a valid AVCSampleEntry".to_owned()));
|
||||||
|
}
|
||||||
|
let profile_idc = sample_entry[103];
|
||||||
|
let constraint_flags_byte = sample_entry[104];
|
||||||
|
let level_idc = sample_entry[105];
|
||||||
|
Ok(format!("avc1.{:02x}{:02x}{:02x}", profile_idc, constraint_flags_byte, level_idc))
|
||||||
|
}
|
||||||
|
|
||||||
/// Transforms sample data from Annex B format to AVC format. Should be called on samples iff
|
/// Transforms sample data from Annex B format to AVC format. Should be called on samples iff
|
||||||
/// `ExtraData::need_transform` is true. Uses an out parameter `avc_sample` rather than a return
|
/// `ExtraData::need_transform` is true. Uses an out parameter `avc_sample` rather than a return
|
||||||
/// so that memory allocations can be reused from sample to sample.
|
/// so that memory allocations can be reused from sample to sample.
|
||||||
@ -245,6 +259,8 @@ pub fn transform_sample_data(annexb_sample: &[u8], avc_sample: &mut Vec<u8>) ->
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use testutil;
|
||||||
|
|
||||||
const ANNEX_B_TEST_INPUT: [u8; 35] = [
|
const ANNEX_B_TEST_INPUT: [u8; 35] = [
|
||||||
0x00, 0x00, 0x00, 0x01, 0x67, 0x4d, 0x00, 0x1f,
|
0x00, 0x00, 0x00, 0x01, 0x67, 0x4d, 0x00, 0x1f,
|
||||||
0x9a, 0x66, 0x02, 0x80, 0x2d, 0xff, 0x35, 0x01,
|
0x9a, 0x66, 0x02, 0x80, 0x2d, 0xff, 0x35, 0x01,
|
||||||
@ -283,6 +299,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_decode() {
|
fn test_decode() {
|
||||||
|
testutil::init();
|
||||||
let data = &ANNEX_B_TEST_INPUT;
|
let data = &ANNEX_B_TEST_INPUT;
|
||||||
let mut pieces = Vec::new();
|
let mut pieces = Vec::new();
|
||||||
super::decode_h264_annex_b(data, |p| {
|
super::decode_h264_annex_b(data, |p| {
|
||||||
@ -294,23 +311,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sample_entry_from_avc_decoder_config() {
|
fn test_sample_entry_from_avc_decoder_config() {
|
||||||
|
testutil::init();
|
||||||
let e = super::ExtraData::parse(&AVC_DECODER_CONFIG_TEST_INPUT, 1280, 720).unwrap();
|
let e = super::ExtraData::parse(&AVC_DECODER_CONFIG_TEST_INPUT, 1280, 720).unwrap();
|
||||||
assert_eq!(&e.sample_entry[..], &TEST_OUTPUT[..]);
|
assert_eq!(&e.sample_entry[..], &TEST_OUTPUT[..]);
|
||||||
assert_eq!(e.width, 1280);
|
assert_eq!(e.width, 1280);
|
||||||
assert_eq!(e.height, 720);
|
assert_eq!(e.height, 720);
|
||||||
assert_eq!(e.need_transform, false);
|
assert_eq!(e.need_transform, false);
|
||||||
|
assert_eq!(e.rfc6381_codec, "avc1.4d001f");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sample_entry_from_annex_b() {
|
fn test_sample_entry_from_annex_b() {
|
||||||
|
testutil::init();
|
||||||
let e = super::ExtraData::parse(&ANNEX_B_TEST_INPUT, 1280, 720).unwrap();
|
let e = super::ExtraData::parse(&ANNEX_B_TEST_INPUT, 1280, 720).unwrap();
|
||||||
assert_eq!(e.width, 1280);
|
assert_eq!(e.width, 1280);
|
||||||
assert_eq!(e.height, 720);
|
assert_eq!(e.height, 720);
|
||||||
assert_eq!(e.need_transform, true);
|
assert_eq!(e.need_transform, true);
|
||||||
|
assert_eq!(e.rfc6381_codec, "avc1.4d001f");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_transform_sample_data() {
|
fn test_transform_sample_data() {
|
||||||
|
testutil::init();
|
||||||
const INPUT: [u8; 64] = [
|
const INPUT: [u8; 64] = [
|
||||||
0x00, 0x00, 0x00, 0x01, 0x67, 0x4d, 0x00, 0x1f,
|
0x00, 0x00, 0x00, 0x01, 0x67, 0x4d, 0x00, 0x1f,
|
||||||
0x9a, 0x66, 0x02, 0x80, 0x2d, 0xff, 0x35, 0x01,
|
0x9a, 0x66, 0x02, 0x80, 0x2d, 0xff, 0x35, 0x01,
|
||||||
|
18
src/mp4.rs
18
src/mp4.rs
@ -1472,8 +1472,19 @@ impl http_entity::Entity for File {
|
|||||||
type Body = slices::Body;
|
type Body = slices::Body;
|
||||||
|
|
||||||
fn add_headers(&self, hdrs: &mut header::Headers) {
|
fn add_headers(&self, hdrs: &mut header::Headers) {
|
||||||
// TODO: add RFC 6381 "Codecs" parameter.
|
let mut mime = String::with_capacity(64);
|
||||||
hdrs.set(header::ContentType("video/mp4".parse().unwrap()));
|
mime.push_str("video/mp4; codecs=\"");
|
||||||
|
let mut first = true;
|
||||||
|
for e in &self.0.video_sample_entries {
|
||||||
|
if first {
|
||||||
|
first = false
|
||||||
|
} else {
|
||||||
|
mime.push_str(", ");
|
||||||
|
}
|
||||||
|
mime.push_str(&e.rfc6381_codec);
|
||||||
|
}
|
||||||
|
mime.push('"');
|
||||||
|
hdrs.set(header::ContentType(mime.parse().unwrap()));
|
||||||
}
|
}
|
||||||
fn last_modified(&self) -> Option<header::HttpDate> { Some(self.0.last_modified) }
|
fn last_modified(&self) -> Option<header::HttpDate> { Some(self.0.last_modified) }
|
||||||
fn etag(&self) -> Option<header::EntityTag> { Some(self.0.etag.clone()) }
|
fn etag(&self) -> Option<header::EntityTag> { Some(self.0.etag.clone()) }
|
||||||
@ -1725,7 +1736,8 @@ mod tests {
|
|||||||
const START_TIME: recording::Time = recording::Time(1430006400i64 * TIME_UNITS_PER_SEC);
|
const START_TIME: recording::Time = recording::Time(1430006400i64 * TIME_UNITS_PER_SEC);
|
||||||
let extra_data = input.get_extra_data().unwrap();
|
let extra_data = input.get_extra_data().unwrap();
|
||||||
let video_sample_entry_id = db.db.lock().insert_video_sample_entry(
|
let video_sample_entry_id = db.db.lock().insert_video_sample_entry(
|
||||||
extra_data.width, extra_data.height, &extra_data.sample_entry).unwrap();
|
extra_data.width, extra_data.height, extra_data.sample_entry,
|
||||||
|
extra_data.rfc6381_codec).unwrap();
|
||||||
let mut output = db.dir.create_writer(&db.syncer_channel, None,
|
let mut output = db.dir.create_writer(&db.syncer_channel, None,
|
||||||
TEST_CAMERA_ID, video_sample_entry_id).unwrap();
|
TEST_CAMERA_ID, video_sample_entry_id).unwrap();
|
||||||
|
|
||||||
|
@ -117,7 +117,8 @@ impl<'a, C, S> Streamer<'a, C, S> where C: 'a + Clocks, S: 'a + stream::Stream {
|
|||||||
let extra_data = stream.get_extra_data()?;
|
let extra_data = stream.get_extra_data()?;
|
||||||
let video_sample_entry_id =
|
let video_sample_entry_id =
|
||||||
self.db.lock().insert_video_sample_entry(extra_data.width, extra_data.height,
|
self.db.lock().insert_video_sample_entry(extra_data.width, extra_data.height,
|
||||||
&extra_data.sample_entry)?;
|
extra_data.sample_entry,
|
||||||
|
extra_data.rfc6381_codec)?;
|
||||||
debug!("{}: video_sample_entry_id={}", self.short_name, video_sample_entry_id);
|
debug!("{}: video_sample_entry_id={}", self.short_name, video_sample_entry_id);
|
||||||
let mut seen_key_frame = false;
|
let mut seen_key_frame = false;
|
||||||
let mut state: Option<WriterState> = None;
|
let mut state: Option<WriterState> = None;
|
||||||
|
BIN
src/testdata/avc1
vendored
Normal file
BIN
src/testdata/avc1
vendored
Normal file
Binary file not shown.
@ -118,8 +118,8 @@ impl TestDb {
|
|||||||
pub fn create_recording_from_encoder(&self, encoder: recording::SampleIndexEncoder)
|
pub fn create_recording_from_encoder(&self, encoder: recording::SampleIndexEncoder)
|
||||||
-> db::ListRecordingsRow {
|
-> db::ListRecordingsRow {
|
||||||
let mut db = self.db.lock();
|
let mut db = self.db.lock();
|
||||||
let video_sample_entry_id =
|
let video_sample_entry_id = db.insert_video_sample_entry(
|
||||||
db.insert_video_sample_entry(1920, 1080, &[0u8; 100]).unwrap();
|
1920, 1080, [0u8; 100].to_vec(), "avc1.000000".to_owned()).unwrap();
|
||||||
{
|
{
|
||||||
let mut tx = db.tx().unwrap();
|
let mut tx = db.tx().unwrap();
|
||||||
tx.bypass_reservation_for_testing = true;
|
tx.bypass_reservation_for_testing = true;
|
||||||
@ -155,7 +155,8 @@ pub fn add_dummy_recordings_to_db(db: &db::Database, num: usize) {
|
|||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
data.extend_from_slice(include_bytes!("testdata/video_sample_index.bin"));
|
data.extend_from_slice(include_bytes!("testdata/video_sample_index.bin"));
|
||||||
let mut db = db.lock();
|
let mut db = db.lock();
|
||||||
let video_sample_entry_id = db.insert_video_sample_entry(1920, 1080, &[0u8; 100]).unwrap();
|
let video_sample_entry_id = db.insert_video_sample_entry(
|
||||||
|
1920, 1080, [0u8; 100].to_vec(), "avc1.000000".to_owned()).unwrap();
|
||||||
const START_TIME: recording::Time = recording::Time(1430006400i64 * TIME_UNITS_PER_SEC);
|
const START_TIME: recording::Time = recording::Time(1430006400i64 * TIME_UNITS_PER_SEC);
|
||||||
const DURATION: recording::Duration = recording::Duration(5399985);
|
const DURATION: recording::Duration = recording::Duration(5399985);
|
||||||
let mut recording = db::RecordingToInsert{
|
let mut recording = db::RecordingToInsert{
|
||||||
|
Loading…
Reference in New Issue
Block a user