every.channel: sanitized baseline
This commit is contained in:
commit
897e556bea
258 changed files with 74298 additions and 0 deletions
23
crates/ec-moq/Cargo.toml
Normal file
23
crates/ec-moq/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "ec-moq"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
bytes = "1"
|
||||
ec-core = { path = "../ec-core" }
|
||||
ec-iroh = { path = "../ec-iroh" }
|
||||
iroh = "0.96"
|
||||
iroh-moq = { path = "../../third_party/iroh-live/iroh-moq" }
|
||||
moq-lite = "0.10.1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
blake3.workspace = true
|
||||
ec-crypto = { path = "../ec-crypto" }
|
||||
hex = "0.4"
|
||||
832
crates/ec-moq/src/lib.rs
Normal file
832
crates/ec-moq/src/lib.rs
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
//! Media over QUIC (MoQ) scaffolding.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use bytes::Bytes;
|
||||
use ec_core::Manifest;
|
||||
use ec_iroh::DiscoveryConfig;
|
||||
use iroh::{protocol::Router, Endpoint, EndpointAddr, SecretKey};
|
||||
use moq_lite::{BroadcastConsumer, BroadcastProducer, Group, Track};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrackName {
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GroupId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectMeta {
|
||||
pub created_unix_ms: u64,
|
||||
pub content_type: String,
|
||||
pub size_bytes: u64,
|
||||
pub timing: Option<TimingMeta>,
|
||||
pub encryption: Option<EncryptionMeta>,
|
||||
pub chunk_hash: Option<String>,
|
||||
pub chunk_hash_alg: Option<String>,
|
||||
pub chunk_proof: Option<Vec<String>>,
|
||||
pub chunk_proof_alg: Option<String>,
|
||||
pub manifest_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectPayload {
|
||||
pub meta: ObjectMeta,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_TRACK_NAME: &str = "chunks";
|
||||
pub const DEFAULT_MANIFEST_TRACK_NAME: &str = "manifests";
|
||||
|
||||
pub trait Publisher {
|
||||
fn publish_object(
|
||||
&self,
|
||||
track: &TrackName,
|
||||
group: GroupId,
|
||||
object: ObjectPayload,
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
pub trait Subscriber {
|
||||
fn subscribe_track(&self, track: &TrackName) -> Result<()>;
|
||||
}
|
||||
|
||||
pub trait Relay {
|
||||
fn announce_track(&self, track: &TrackName) -> Result<()>;
|
||||
fn cache_object(&self, track: &TrackName, group: GroupId, object: ObjectPayload) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimingMeta {
|
||||
pub chunk_index: u64,
|
||||
pub chunk_start_27mhz: u64,
|
||||
pub chunk_duration_27mhz: u64,
|
||||
pub utc_start_unix: Option<i64>,
|
||||
pub sync_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptionMeta {
|
||||
pub alg: String,
|
||||
pub key_id: String,
|
||||
pub nonce_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileRelay {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FileRelay {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn write_object(
|
||||
&self,
|
||||
track: &TrackName,
|
||||
group: GroupId,
|
||||
object_id: ObjectId,
|
||||
object: &ObjectPayload,
|
||||
) -> Result<()> {
|
||||
let base = self.object_dir(track, group, object_id);
|
||||
fs::create_dir_all(&base)
|
||||
.with_context(|| format!("failed to create {}", base.display()))?;
|
||||
|
||||
let data_path = base.join("data.bin");
|
||||
let meta_path = base.join("meta.json");
|
||||
|
||||
fs::write(&data_path, &object.data)
|
||||
.with_context(|| format!("failed to write {}", data_path.display()))?;
|
||||
fs::write(&meta_path, serde_json::to_vec_pretty(&object.meta)?)
|
||||
.with_context(|| format!("failed to write {}", meta_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn object_dir(&self, track: &TrackName, group: GroupId, object_id: ObjectId) -> PathBuf {
|
||||
let namespace = sanitize_component(&track.namespace);
|
||||
let name = sanitize_component(&track.name);
|
||||
self.root
|
||||
.join(namespace)
|
||||
.join(name)
|
||||
.join(format!("group-{}", group.0))
|
||||
.join(format!("object-{}", object_id.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Relay for FileRelay {
|
||||
fn announce_track(&self, _track: &TrackName) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cache_object(&self, track: &TrackName, group: GroupId, object: ObjectPayload) -> Result<()> {
|
||||
self.write_object(track, group, ObjectId(0), &object)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => c,
|
||||
'A'..='Z' => c.to_ascii_lowercase(),
|
||||
_ => '_',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn encode_object_frame(meta: &ObjectMeta, data: &[u8]) -> Result<Vec<u8>> {
|
||||
let meta_bytes = serde_json::to_vec(meta)?;
|
||||
let meta_len = u32::try_from(meta_bytes.len()).map_err(|_| anyhow!("object meta too large"))?;
|
||||
let mut out = Vec::with_capacity(4 + meta_bytes.len() + data.len());
|
||||
out.extend_from_slice(&meta_len.to_be_bytes());
|
||||
out.extend_from_slice(&meta_bytes);
|
||||
out.extend_from_slice(data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn decode_object_frame(bytes: &[u8]) -> Result<ObjectPayload> {
|
||||
if bytes.len() < 4 {
|
||||
return Err(anyhow!("object frame too short"));
|
||||
}
|
||||
let meta_len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
|
||||
if bytes.len() < 4 + meta_len {
|
||||
return Err(anyhow!("object frame missing metadata bytes"));
|
||||
}
|
||||
let meta = serde_json::from_slice(&bytes[4..4 + meta_len])?;
|
||||
let data = bytes[4 + meta_len..].to_vec();
|
||||
Ok(ObjectPayload { meta, data })
|
||||
}
|
||||
|
||||
pub fn encode_manifest_frame(manifest: &Manifest) -> Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(manifest)?)
|
||||
}
|
||||
|
||||
pub fn decode_manifest_frame(bytes: &[u8]) -> Result<Manifest> {
|
||||
Ok(serde_json::from_slice(bytes)?)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MoqNode {
|
||||
endpoint: Endpoint,
|
||||
router: Router,
|
||||
moq: iroh_moq::Moq,
|
||||
}
|
||||
|
||||
impl MoqNode {
|
||||
pub async fn bind(secret: Option<SecretKey>) -> Result<Self> {
|
||||
let discovery = DiscoveryConfig::from_env()?;
|
||||
Self::bind_with_discovery(secret, discovery).await
|
||||
}
|
||||
|
||||
pub async fn bind_with_discovery(
|
||||
secret: Option<SecretKey>,
|
||||
discovery: DiscoveryConfig,
|
||||
) -> Result<Self> {
|
||||
let endpoint = ec_iroh::build_endpoint(secret, discovery).await?;
|
||||
let moq = iroh_moq::Moq::new(endpoint.clone());
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_moq::ALPN, moq.protocol_handler())
|
||||
.spawn();
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
router,
|
||||
moq,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &Endpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub fn endpoint_addr(&self) -> EndpointAddr {
|
||||
self.router.endpoint().addr()
|
||||
}
|
||||
|
||||
pub async fn publish_objects(
|
||||
&self,
|
||||
broadcast_name: impl Into<String>,
|
||||
track_name: impl Into<String>,
|
||||
) -> Result<MoqPublisher> {
|
||||
let broadcast_name = broadcast_name.into();
|
||||
let track_name = track_name.into();
|
||||
let mut broadcast = BroadcastProducer::default();
|
||||
let track = broadcast.create_track(Track {
|
||||
name: track_name.clone(),
|
||||
priority: 0,
|
||||
});
|
||||
self.moq
|
||||
.publish(broadcast_name.clone(), broadcast.clone())
|
||||
.await?;
|
||||
Ok(MoqPublisher {
|
||||
broadcast_name,
|
||||
track_name,
|
||||
broadcast,
|
||||
track,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a broadcast containing multiple tracks, all created before publishing.
|
||||
///
|
||||
/// This avoids subtle issues in some MoQ implementations where tracks added after the
|
||||
/// initial publish are not reliably deliverable to subscribers.
|
||||
pub async fn publish_track_set(
|
||||
&self,
|
||||
broadcast_name: impl Into<String>,
|
||||
object_tracks: Vec<String>,
|
||||
manifest_tracks: Vec<String>,
|
||||
) -> Result<MoqPublishSet> {
|
||||
let broadcast_name = broadcast_name.into();
|
||||
let mut broadcast = BroadcastProducer::default();
|
||||
|
||||
let mut object = HashMap::new();
|
||||
for name in object_tracks {
|
||||
let track = broadcast.create_track(Track {
|
||||
name: name.clone(),
|
||||
priority: 0,
|
||||
});
|
||||
object.insert(name, track);
|
||||
}
|
||||
|
||||
let mut manifests = HashMap::new();
|
||||
for name in manifest_tracks {
|
||||
let track = broadcast.create_track(Track {
|
||||
name: name.clone(),
|
||||
priority: 0,
|
||||
});
|
||||
manifests.insert(name, track);
|
||||
}
|
||||
|
||||
self.moq.publish(broadcast_name.clone(), broadcast).await?;
|
||||
Ok(MoqPublishSet {
|
||||
broadcast_name,
|
||||
object,
|
||||
manifests,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn subscribe_objects(
|
||||
&self,
|
||||
remote: EndpointAddr,
|
||||
broadcast_name: impl Into<String>,
|
||||
track_name: impl Into<String>,
|
||||
) -> Result<MoqObjectStream> {
|
||||
let broadcast_name = broadcast_name.into();
|
||||
let track_name = track_name.into();
|
||||
let mut session = self.moq.connect(remote).await?;
|
||||
let broadcast = session.subscribe(&broadcast_name).await?;
|
||||
let track = subscribe_track(&broadcast, &track_name)?;
|
||||
MoqObjectStream::spawn(session, track)
|
||||
}
|
||||
|
||||
pub async fn subscribe_manifests(
|
||||
&self,
|
||||
remote: EndpointAddr,
|
||||
broadcast_name: impl Into<String>,
|
||||
track_name: impl Into<String>,
|
||||
) -> Result<MoqManifestStream> {
|
||||
let broadcast_name = broadcast_name.into();
|
||||
let track_name = track_name.into();
|
||||
let mut session = self.moq.connect(remote).await?;
|
||||
let broadcast = session.subscribe(&broadcast_name).await?;
|
||||
let track = subscribe_track(&broadcast, &track_name)?;
|
||||
MoqManifestStream::spawn(session, track)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MoqPublishSet {
|
||||
broadcast_name: String,
|
||||
object: HashMap<String, moq_lite::TrackProducer>,
|
||||
manifests: HashMap<String, moq_lite::TrackProducer>,
|
||||
}
|
||||
|
||||
impl MoqPublishSet {
|
||||
pub fn publish_object(
|
||||
&mut self,
|
||||
track_name: &str,
|
||||
group: GroupId,
|
||||
object: ObjectPayload,
|
||||
) -> Result<()> {
|
||||
let Some(track) = self.object.get_mut(track_name) else {
|
||||
return Err(anyhow!("unknown object track {}", track_name));
|
||||
};
|
||||
let Some(mut group_writer) = track.create_group(Group { sequence: group.0 }) else {
|
||||
return Err(anyhow!("group {} already published", group.0));
|
||||
};
|
||||
let frame = encode_object_frame(&object.meta, &object.data)?;
|
||||
group_writer.write_frame(Bytes::from(frame));
|
||||
group_writer.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn publish_manifest(
|
||||
&mut self,
|
||||
track_name: &str,
|
||||
sequence: u64,
|
||||
manifest: &Manifest,
|
||||
) -> Result<()> {
|
||||
let Some(track) = self.manifests.get_mut(track_name) else {
|
||||
return Err(anyhow!("unknown manifest track {}", track_name));
|
||||
};
|
||||
let Some(mut group_writer) = track.create_group(Group { sequence }) else {
|
||||
return Err(anyhow!("manifest group {} already published", sequence));
|
||||
};
|
||||
let frame = encode_manifest_frame(manifest)?;
|
||||
group_writer.write_frame(Bytes::from(frame));
|
||||
group_writer.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn broadcast_name(&self) -> &str {
|
||||
&self.broadcast_name
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MoqPublisher {
|
||||
broadcast_name: String,
|
||||
track_name: String,
|
||||
broadcast: BroadcastProducer,
|
||||
track: moq_lite::TrackProducer,
|
||||
}
|
||||
|
||||
impl MoqPublisher {
|
||||
pub fn publish_object(&mut self, group: GroupId, object: ObjectPayload) -> Result<()> {
|
||||
let Some(mut group_writer) = self.track.create_group(Group { sequence: group.0 }) else {
|
||||
return Err(anyhow!("group {} already published", group.0));
|
||||
};
|
||||
let frame = encode_object_frame(&object.meta, &object.data)?;
|
||||
group_writer.write_frame(Bytes::from(frame));
|
||||
group_writer.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_side_track(&mut self, track_name: impl Into<String>) -> Result<MoqSidePublisher> {
|
||||
let track_name = track_name.into();
|
||||
let track = self.broadcast.create_track(Track {
|
||||
name: track_name.clone(),
|
||||
priority: 0,
|
||||
});
|
||||
Ok(MoqSidePublisher { track_name, track })
|
||||
}
|
||||
|
||||
pub fn create_manifest_track(
|
||||
&mut self,
|
||||
track_name: impl Into<String>,
|
||||
) -> Result<MoqManifestPublisher> {
|
||||
let track_name = track_name.into();
|
||||
let track = self.broadcast.create_track(Track {
|
||||
name: track_name.clone(),
|
||||
priority: 0,
|
||||
});
|
||||
Ok(MoqManifestPublisher { track_name, track })
|
||||
}
|
||||
|
||||
pub fn broadcast_name(&self) -> &str {
|
||||
&self.broadcast_name
|
||||
}
|
||||
|
||||
pub fn track_name(&self) -> &str {
|
||||
&self.track_name
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MoqSidePublisher {
|
||||
track_name: String,
|
||||
track: moq_lite::TrackProducer,
|
||||
}
|
||||
|
||||
impl MoqSidePublisher {
|
||||
pub fn publish_object(&mut self, group: GroupId, object: ObjectPayload) -> Result<()> {
|
||||
let Some(mut group_writer) = self.track.create_group(Group { sequence: group.0 }) else {
|
||||
return Err(anyhow!("group {} already published", group.0));
|
||||
};
|
||||
let frame = encode_object_frame(&object.meta, &object.data)?;
|
||||
group_writer.write_frame(Bytes::from(frame));
|
||||
group_writer.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn track_name(&self) -> &str {
|
||||
&self.track_name
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MoqManifestPublisher {
|
||||
track_name: String,
|
||||
track: moq_lite::TrackProducer,
|
||||
}
|
||||
|
||||
impl MoqManifestPublisher {
|
||||
pub fn publish_manifest(&mut self, sequence: u64, manifest: &Manifest) -> Result<()> {
|
||||
let Some(mut group_writer) = self.track.create_group(Group { sequence }) else {
|
||||
return Err(anyhow!("manifest group {} already published", sequence));
|
||||
};
|
||||
let frame = encode_manifest_frame(manifest)?;
|
||||
group_writer.write_frame(Bytes::from(frame));
|
||||
group_writer.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn track_name(&self) -> &str {
|
||||
&self.track_name
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MoqObjectStream {
|
||||
receiver: mpsc::Receiver<ObjectPayload>,
|
||||
_task: JoinHandle<()>,
|
||||
_session: iroh_moq::MoqSession,
|
||||
}
|
||||
|
||||
impl MoqObjectStream {
|
||||
fn spawn(session: iroh_moq::MoqSession, mut track: moq_lite::TrackConsumer) -> Result<Self> {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let next_group = track.next_group().await;
|
||||
let Some(mut group) = (match next_group {
|
||||
Ok(group) => group,
|
||||
Err(err) => {
|
||||
tracing::warn!("moq track error: {err:#}");
|
||||
break;
|
||||
}
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
loop {
|
||||
match group.read_frame().await {
|
||||
Ok(Some(frame)) => buffer.extend_from_slice(&frame),
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
tracing::warn!("moq group error: {err:#}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if buffer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match decode_object_frame(&buffer) {
|
||||
Ok(object) => {
|
||||
if tx.send(object).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to decode object frame: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
receiver: rx,
|
||||
_task: task,
|
||||
_session: session,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn recv(&mut self) -> Option<ObjectPayload> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MoqManifestStream {
|
||||
receiver: mpsc::Receiver<Manifest>,
|
||||
_task: JoinHandle<()>,
|
||||
_session: iroh_moq::MoqSession,
|
||||
}
|
||||
|
||||
impl MoqManifestStream {
|
||||
fn spawn(session: iroh_moq::MoqSession, mut track: moq_lite::TrackConsumer) -> Result<Self> {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let next_group = track.next_group().await;
|
||||
let Some(mut group) = (match next_group {
|
||||
Ok(group) => group,
|
||||
Err(err) => {
|
||||
tracing::warn!("moq manifest track error: {err:#}");
|
||||
break;
|
||||
}
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
loop {
|
||||
match group.read_frame().await {
|
||||
Ok(Some(frame)) => buffer.extend_from_slice(&frame),
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
tracing::warn!("moq manifest group error: {err:#}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if buffer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match decode_manifest_frame(&buffer) {
|
||||
Ok(manifest) => {
|
||||
if tx.send(manifest).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to decode manifest frame: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
receiver: rx,
|
||||
_task: task,
|
||||
_session: session,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn recv(&mut self) -> Option<Manifest> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_track(broadcast: &BroadcastConsumer, name: &str) -> Result<moq_lite::TrackConsumer> {
|
||||
let track = broadcast.subscribe_track(&Track::new(name));
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HlsWriter {
|
||||
output_dir: PathBuf,
|
||||
window: usize,
|
||||
target_duration: f64,
|
||||
init_filename: String,
|
||||
segments: std::collections::VecDeque<HlsSegment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HlsSegment {
|
||||
index: u64,
|
||||
duration: f64,
|
||||
filename: String,
|
||||
}
|
||||
|
||||
impl HlsWriter {
|
||||
pub fn new_cmaf(
|
||||
output_dir: impl Into<PathBuf>,
|
||||
target_duration: f64,
|
||||
window: usize,
|
||||
) -> Result<Self> {
|
||||
// CMAF-only writer: init.mp4 + segment_*.m4s + HLS playlist as a local compatibility artifact.
|
||||
let output_dir = output_dir.into();
|
||||
fs::create_dir_all(&output_dir)
|
||||
.with_context(|| format!("failed to create {}", output_dir.display()))?;
|
||||
Ok(Self {
|
||||
output_dir,
|
||||
window: window.max(1),
|
||||
target_duration,
|
||||
init_filename: "init.mp4".to_string(),
|
||||
segments: std::collections::VecDeque::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_init_segment(&mut self, data: &[u8]) -> Result<PathBuf> {
|
||||
let path = self.output_dir.join(&self.init_filename);
|
||||
fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
|
||||
self.write_playlist()?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_segment(&mut self, index: u64, duration: f64, data: &[u8]) -> Result<PathBuf> {
|
||||
let filename = format!("segment_{index:06}.m4s");
|
||||
let path = self.output_dir.join(&filename);
|
||||
fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
|
||||
|
||||
self.segments.push_back(HlsSegment {
|
||||
index,
|
||||
duration,
|
||||
filename,
|
||||
});
|
||||
while self.segments.len() > self.window {
|
||||
self.segments.pop_front();
|
||||
}
|
||||
|
||||
self.write_playlist()?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_playlist(&self) -> Result<()> {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("#EXTM3U".to_string());
|
||||
lines.push("#EXT-X-VERSION:7".to_string());
|
||||
lines.push("#EXT-X-INDEPENDENT-SEGMENTS".to_string());
|
||||
lines.push(format!("#EXT-X-MAP:URI=\"{}\"", self.init_filename));
|
||||
let target = self.target_duration.ceil().max(1.0) as u64;
|
||||
lines.push(format!("#EXT-X-TARGETDURATION:{target}"));
|
||||
if let Some(first) = self.segments.front() {
|
||||
lines.push(format!("#EXT-X-MEDIA-SEQUENCE:{}", first.index));
|
||||
}
|
||||
for seg in &self.segments {
|
||||
lines.push(format!("#EXTINF:{:.3},", seg.duration));
|
||||
lines.push(seg.filename.clone());
|
||||
}
|
||||
let playlist_path = self.output_dir.join("index.m3u8");
|
||||
fs::write(&playlist_path, lines.join("\n") + "\n")
|
||||
.with_context(|| format!("failed to write {}", playlist_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chunk_duration_secs(meta: &ObjectMeta, fallback: Duration) -> f64 {
|
||||
if let Some(timing) = &meta.timing {
|
||||
let secs = timing.chunk_duration_27mhz as f64 / 27_000_000.0;
|
||||
if secs > 0.0 {
|
||||
return secs;
|
||||
}
|
||||
}
|
||||
fallback.as_secs_f64()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn sanitize_component_is_stable() {
|
||||
assert_eq!(sanitize_component("Hello World!"), "hello_world_");
|
||||
assert_eq!(sanitize_component("a-b_C9"), "a-b_c9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_frame_roundtrip() {
|
||||
let meta = ObjectMeta {
|
||||
created_unix_ms: 1,
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
size_bytes: 3,
|
||||
timing: Some(TimingMeta {
|
||||
chunk_index: 7,
|
||||
chunk_start_27mhz: 0,
|
||||
chunk_duration_27mhz: 54_000_000,
|
||||
utc_start_unix: None,
|
||||
sync_status: "synthetic".to_string(),
|
||||
}),
|
||||
encryption: None,
|
||||
chunk_hash: Some("00".repeat(32)),
|
||||
chunk_hash_alg: Some("blake3".to_string()),
|
||||
chunk_proof: Some(vec!["00".repeat(32)]),
|
||||
chunk_proof_alg: Some("merkle+blake3".to_string()),
|
||||
manifest_id: Some("m".to_string()),
|
||||
};
|
||||
let data = b"abc".to_vec();
|
||||
let frame = encode_object_frame(&meta, &data).unwrap();
|
||||
let decoded = decode_object_frame(&frame).unwrap();
|
||||
assert_eq!(decoded.data, data);
|
||||
assert_eq!(decoded.meta.created_unix_ms, meta.created_unix_ms);
|
||||
assert_eq!(
|
||||
decoded.meta.timing.as_ref().unwrap().chunk_index,
|
||||
meta.timing.as_ref().unwrap().chunk_index
|
||||
);
|
||||
assert_eq!(decoded.meta.manifest_id, meta.manifest_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_short_frame() {
|
||||
assert!(decode_object_frame(&[]).is_err());
|
||||
assert!(decode_object_frame(&[0, 0, 0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_frame_roundtrip() {
|
||||
let manifest = ec_core::Manifest {
|
||||
body: ec_core::ManifestBody {
|
||||
stream_id: ec_core::StreamId("s".to_string()),
|
||||
epoch_id: "e".to_string(),
|
||||
chunk_duration_ms: 2000,
|
||||
total_chunks: 1,
|
||||
chunk_start_index: 0,
|
||||
encoder_profile_id: "p".to_string(),
|
||||
merkle_root: "00".repeat(32),
|
||||
created_unix_ms: 1,
|
||||
metadata: Vec::new(),
|
||||
chunk_hashes: vec!["11".repeat(32)],
|
||||
variants: None,
|
||||
},
|
||||
manifest_id: "m".to_string(),
|
||||
signatures: Vec::new(),
|
||||
};
|
||||
let bytes = encode_manifest_frame(&manifest).unwrap();
|
||||
let decoded = decode_manifest_frame(&bytes).unwrap();
|
||||
assert_eq!(decoded.manifest_id, "m");
|
||||
assert_eq!(decoded.body.epoch_id, "e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_frame_signed_roundtrip_verifies() {
|
||||
let prev = env::var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY").ok();
|
||||
env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", "11".repeat(32));
|
||||
let keypair = ec_crypto::load_manifest_keypair_from_env()
|
||||
.expect("load should not error")
|
||||
.expect("keypair should exist");
|
||||
|
||||
let chunk_hashes = vec![blake3::hash(b"chunk0").to_hex().to_string()];
|
||||
let merkle_root = ec_core::merkle_root_from_hashes(&chunk_hashes).unwrap();
|
||||
let body = ec_core::ManifestBody {
|
||||
stream_id: ec_core::StreamId("s".to_string()),
|
||||
epoch_id: "e".to_string(),
|
||||
chunk_duration_ms: 2000,
|
||||
total_chunks: 1,
|
||||
chunk_start_index: 0,
|
||||
encoder_profile_id: "p".to_string(),
|
||||
merkle_root,
|
||||
created_unix_ms: 1,
|
||||
metadata: Vec::new(),
|
||||
chunk_hashes,
|
||||
variants: None,
|
||||
};
|
||||
let manifest_id = body.manifest_id().unwrap();
|
||||
let sig = ec_crypto::sign_manifest_id(&manifest_id, &keypair);
|
||||
assert!(ec_crypto::verify_manifest_signature(&manifest_id, &sig));
|
||||
|
||||
let manifest = ec_core::Manifest {
|
||||
body,
|
||||
manifest_id: manifest_id.clone(),
|
||||
signatures: vec![sig],
|
||||
};
|
||||
let bytes = encode_manifest_frame(&manifest).unwrap();
|
||||
let decoded = decode_manifest_frame(&bytes).unwrap();
|
||||
assert_eq!(decoded.manifest_id, manifest_id);
|
||||
assert_eq!(decoded.signatures.len(), 1);
|
||||
assert!(ec_crypto::verify_manifest_signature(
|
||||
&decoded.manifest_id,
|
||||
&decoded.signatures[0]
|
||||
));
|
||||
|
||||
match prev {
|
||||
Some(value) => env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", value),
|
||||
None => env::remove_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_frame_encrypt_decrypt_roundtrip_and_hash_matches_plaintext() {
|
||||
let stream_id = "ec/stream/v1/source/test/device-a/channel-b";
|
||||
let chunk_index = 7u64;
|
||||
let plaintext = b"hello every.channel";
|
||||
let expected_hash = blake3::hash(plaintext).to_hex().to_string();
|
||||
let enc = ec_crypto::encrypt_stream_data(stream_id, chunk_index, plaintext, None);
|
||||
|
||||
let meta = ObjectMeta {
|
||||
created_unix_ms: 1,
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
size_bytes: enc.ciphertext.len() as u64,
|
||||
timing: Some(TimingMeta {
|
||||
chunk_index,
|
||||
chunk_start_27mhz: 0,
|
||||
chunk_duration_27mhz: 54_000_000,
|
||||
utc_start_unix: None,
|
||||
sync_status: "synthetic".to_string(),
|
||||
}),
|
||||
encryption: Some(EncryptionMeta {
|
||||
alg: enc.alg.to_string(),
|
||||
key_id: stream_id.to_string(),
|
||||
nonce_hex: hex::encode(enc.nonce),
|
||||
}),
|
||||
chunk_hash: Some(expected_hash.clone()),
|
||||
chunk_hash_alg: Some("blake3".to_string()),
|
||||
chunk_proof: None,
|
||||
chunk_proof_alg: None,
|
||||
manifest_id: None,
|
||||
};
|
||||
|
||||
let frame = encode_object_frame(&meta, &enc.ciphertext).unwrap();
|
||||
let decoded = decode_object_frame(&frame).unwrap();
|
||||
let out = ec_crypto::decrypt_stream_data(stream_id, chunk_index, &decoded.data, None)
|
||||
.expect("decrypt should succeed");
|
||||
assert_eq!(out, plaintext);
|
||||
assert_eq!(
|
||||
decoded.meta.chunk_hash.as_deref(),
|
||||
Some(expected_hash.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
blake3::hash(&out).to_hex().to_string(),
|
||||
decoded.meta.chunk_hash.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue