diff --git a/design/api.md b/design/api.md index d2a6ad3..a221374 100644 --- a/design/api.md +++ b/design/api.md @@ -182,7 +182,9 @@ Example response: ### `/camera//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: @@ -227,7 +229,8 @@ TODO: error behavior on missing segment. It should be a 404, likely with an ### `/camera//view.m4s` 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: @@ -252,11 +255,8 @@ recording segment for several reasons: ### `/init/.mp4` A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions -initialization segment][init-segment]. - -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. +initialization segment][init-segment]. The MIME type will be `video/mp4`, with +a `codecs` parameter as specified in [RFC 6381][rfc-6381]. [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 diff --git a/src/db.rs b/src/db.rs index 9d604f0..cd39854 100644 --- a/src/db.rs +++ b/src/db.rs @@ -53,6 +53,7 @@ use error::{Error, ResultExt}; use fnv; +use h264; use lru_cache::LruCache; use openssl::hash; use parking_lot::{Mutex,MutexGuard}; @@ -221,11 +222,12 @@ impl rusqlite::types::FromSql for PlaybackData { /// the codec, width, height, etc. #[derive(Debug)] pub struct VideoSampleEntry { + pub data: Vec, + pub rfc6381_codec: String, pub id: i32, pub width: u16, pub height: u16, pub sha1: [u8; 20], - pub data: Vec, } /// A row used in `list_recordings_by_time` and `list_recordings_by_id`. @@ -1077,12 +1079,18 @@ impl LockedDatabase { id, sha1_vec.len()))); } sha1.copy_from_slice(&sha1_vec); - self.state.video_sample_entries.insert(id, Arc::new(VideoSampleEntry{ + let data: Vec = 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, width: row.get_checked::<_, i32>(2)? as u16, height: row.get_checked::<_, i32>(3)? as u16, - sha1: sha1, - data: row.get_checked(4)?, + sha1, + data, + rfc6381_codec, })); } info!("Loaded {} video sample entries", @@ -1140,8 +1148,9 @@ impl LockedDatabase { /// Inserts the specified video sample entry if absent. /// 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 { - let sha1 = hash::hash2(hash::MessageDigest::sha1(), data)?; + pub fn insert_video_sample_entry(&mut self, w: u16, h: u16, data: Vec, + rfc6381_codec: String) -> Result { + let sha1 = hash::hash2(hash::MessageDigest::sha1(), &data)?; let mut sha1_bytes = [0u8; 20]; sha1_bytes.copy_from_slice(&sha1); @@ -1168,12 +1177,13 @@ impl LockedDatabase { ])?; 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, width: w, height: h, sha1: sha1_bytes, - data: data.to_vec(), + data: data, + rfc6381_codec, })); Ok(id) @@ -1485,6 +1495,7 @@ mod tests { assert_eq!(r.video_samples, row.video_samples); assert_eq!(r.video_sync_samples, row.video_sync_samples); assert_eq!(r.sample_file_bytes, row.sample_file_bytes); + assert_eq!(row.video_sample_entry.rfc6381_codec, "avc1.4d0029"); Ok(()) }).unwrap(); } @@ -1628,7 +1639,9 @@ mod tests { assert_unsorted_eq(db.lock().list_reserved_sample_files().unwrap(), 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); // Inserting a recording should succeed and remove its uuid from the reserved table. diff --git a/src/h264.rs b/src/h264.rs index d981084..eb70fc0 100644 --- a/src/h264.rs +++ b/src/h264.rs @@ -95,6 +95,7 @@ fn parse_annex_b_extra_data(data: &[u8]) -> Result<(&[u8], &[u8])> { #[derive(Debug, PartialEq, Eq)] pub struct ExtraData { pub sample_entry: Vec, + pub rfc6381_codec: String, pub width: u16, pub height: u16, @@ -134,7 +135,7 @@ impl ExtraData { // 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(); sample_entry.write_u32::(avc1_len as u32)?; // length // type + reserved + data_reference_index = 1 @@ -215,15 +216,28 @@ impl ExtraData { length {}", avc1_len, sample_entry.len() - avc1_len_pos, avc_decoder_config_len))); } - Ok(ExtraData{ - sample_entry: sample_entry, - width: width, - height: height, - need_transform: need_transform, + let rfc6381_codec = rfc6381_codec_from_sample_entry(&sample_entry)?; + Ok(ExtraData { + sample_entry, + rfc6381_codec, + width, + height, + need_transform, }) } } +pub fn rfc6381_codec_from_sample_entry(sample_entry: &[u8]) -> Result { + 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 /// `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. @@ -245,6 +259,8 @@ pub fn transform_sample_data(annexb_sample: &[u8], avc_sample: &mut Vec) -> #[cfg(test)] mod tests { + use testutil; + const ANNEX_B_TEST_INPUT: [u8; 35] = [ 0x00, 0x00, 0x00, 0x01, 0x67, 0x4d, 0x00, 0x1f, 0x9a, 0x66, 0x02, 0x80, 0x2d, 0xff, 0x35, 0x01, @@ -283,6 +299,7 @@ mod tests { #[test] fn test_decode() { + testutil::init(); let data = &ANNEX_B_TEST_INPUT; let mut pieces = Vec::new(); super::decode_h264_annex_b(data, |p| { @@ -294,23 +311,28 @@ mod tests { #[test] fn test_sample_entry_from_avc_decoder_config() { + testutil::init(); let e = super::ExtraData::parse(&AVC_DECODER_CONFIG_TEST_INPUT, 1280, 720).unwrap(); assert_eq!(&e.sample_entry[..], &TEST_OUTPUT[..]); assert_eq!(e.width, 1280); assert_eq!(e.height, 720); assert_eq!(e.need_transform, false); + assert_eq!(e.rfc6381_codec, "avc1.4d001f"); } #[test] fn test_sample_entry_from_annex_b() { + testutil::init(); let e = super::ExtraData::parse(&ANNEX_B_TEST_INPUT, 1280, 720).unwrap(); assert_eq!(e.width, 1280); assert_eq!(e.height, 720); assert_eq!(e.need_transform, true); + assert_eq!(e.rfc6381_codec, "avc1.4d001f"); } #[test] fn test_transform_sample_data() { + testutil::init(); const INPUT: [u8; 64] = [ 0x00, 0x00, 0x00, 0x01, 0x67, 0x4d, 0x00, 0x1f, 0x9a, 0x66, 0x02, 0x80, 0x2d, 0xff, 0x35, 0x01, diff --git a/src/mp4.rs b/src/mp4.rs index c713a8d..f5567bd 100644 --- a/src/mp4.rs +++ b/src/mp4.rs @@ -1472,8 +1472,19 @@ impl http_entity::Entity for File { type Body = slices::Body; fn add_headers(&self, hdrs: &mut header::Headers) { - // TODO: add RFC 6381 "Codecs" parameter. - hdrs.set(header::ContentType("video/mp4".parse().unwrap())); + let mut mime = String::with_capacity(64); + 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 { Some(self.0.last_modified) } fn etag(&self) -> Option { Some(self.0.etag.clone()) } @@ -1725,7 +1736,8 @@ mod tests { const START_TIME: recording::Time = recording::Time(1430006400i64 * TIME_UNITS_PER_SEC); let extra_data = input.get_extra_data().unwrap(); 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, TEST_CAMERA_ID, video_sample_entry_id).unwrap(); diff --git a/src/streamer.rs b/src/streamer.rs index a9866d3..104074e 100644 --- a/src/streamer.rs +++ b/src/streamer.rs @@ -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 video_sample_entry_id = 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); let mut seen_key_frame = false; let mut state: Option = None; diff --git a/src/testdata/avc1 b/src/testdata/avc1 new file mode 100644 index 0000000..5e57f24 Binary files /dev/null and b/src/testdata/avc1 differ diff --git a/src/testutil.rs b/src/testutil.rs index 9fd6a77..63d8c8b 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -118,8 +118,8 @@ impl TestDb { pub fn create_recording_from_encoder(&self, encoder: recording::SampleIndexEncoder) -> db::ListRecordingsRow { let mut db = self.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(); { let mut tx = db.tx().unwrap(); 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(); data.extend_from_slice(include_bytes!("testdata/video_sample_index.bin")); 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 DURATION: recording::Duration = recording::Duration(5399985); let mut recording = db::RecordingToInsert{