Unified Stream trait: read() and write() on one type

Stream trait: read() returns PesFrame, write() accepts PesFrame.
A stream is a stream — you read from it or write to it.
No separate Input/Output traits.

API: libfreemkv::input(url) and libfreemkv::output(url, title, codecs)
Returns Box<dyn Stream>.
This commit is contained in:
MattJackson
2026-04-15 03:33:29 +00:00
parent bd644d2f60
commit ff6004a567
33 changed files with 689 additions and 400 deletions
+6 -6
View File
@@ -111,12 +111,12 @@ impl CodecParser for H264Parser {
// pictureParameterSetNALUnit = pps
let mut record = vec![
1, // configurationVersion
sps[1], // profile
sps[2], // compatibility
sps[3], // level
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
1, // configurationVersion
sps[1], // profile
sps[2], // compatibility
sps[3], // level
0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
0xE1, // 3 bits reserved (111) + 5 bits numSPS (1)
(sps.len() >> 8) as u8,
sps.len() as u8,
];
+2 -1
View File
@@ -172,7 +172,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> {
}
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
(from..data.len().saturating_sub(2)).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
(from..data.len().saturating_sub(2))
.find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
}
#[cfg(test)]
+11 -2
View File
@@ -333,8 +333,8 @@ impl IOStream for DiscStream {
}
}
impl crate::pes::InputStream for DiscStream {
fn next_frame(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
impl crate::pes::Stream for DiscStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
// Return buffered frame if available
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
@@ -399,6 +399,15 @@ impl crate::pes::InputStream for DiscStream {
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only"))
}
fn finish(&mut self) -> io::Result<()> {
self.drive.unlock_tray();
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
+8 -2
View File
@@ -256,8 +256,8 @@ impl IsoStream {
}
}
impl crate::pes::InputStream for IsoStream {
fn next_frame(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
impl crate::pes::Stream for IsoStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
// Return buffered frame
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
@@ -301,6 +301,12 @@ impl crate::pes::InputStream for IsoStream {
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "ISO is read-only for PES"))
}
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &crate::disc::DiscTitle {
&self.disc_title
}
+1 -1
View File
@@ -412,7 +412,7 @@ impl<W: Write + Seek> IsoWriter<W> {
icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes());
icb[ad_offset + 4..ad_offset + 8].copy_from_slice(&sector_pos.to_le_bytes());
ad_offset += 8; // each short_ad is 8 bytes
let extent_sectors = ((extent_len + SECTOR_SIZE - 1) / SECTOR_SIZE) as u32;
let extent_sectors = extent_len.div_ceil(SECTOR_SIZE) as u32;
sector_pos += extent_sectors;
remaining -= extent_len;
}
+19 -74
View File
@@ -3,9 +3,7 @@
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
//! Other tools skip the header during TS sync recovery (scan for 0x47).
use crate::disc::{
AudioStream, Codec, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream,
};
use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
use serde::{Deserialize, Serialize};
use std::io::{self, Read, Seek, SeekFrom, Write};
@@ -84,25 +82,25 @@ impl M2tsMeta {
.map(|s| match s {
Stream::Video(v) => MetaStream::Video {
pid: v.pid,
codec: codec_to_str(v.codec),
resolution: v.resolution.clone(),
frame_rate: v.frame_rate.clone(),
hdr: hdr_to_str(v.hdr),
codec: v.codec.id().into(),
resolution: v.resolution.to_string(),
frame_rate: v.frame_rate.to_string(),
hdr: v.hdr.id().into(),
label: v.label.clone(),
secondary: v.secondary,
},
Stream::Audio(a) => MetaStream::Audio {
pid: a.pid,
codec: codec_to_str(a.codec),
channels: a.channels.clone(),
codec: a.codec.id().into(),
channels: a.channels.to_string(),
language: a.language.clone(),
sample_rate: a.sample_rate.clone(),
sample_rate: a.sample_rate.to_string(),
label: a.label.clone(),
secondary: a.secondary,
},
Stream::Subtitle(s) => MetaStream::Subtitle {
pid: s.pid,
codec: codec_to_str(s.codec),
codec: s.codec.id().into(),
language: s.language.clone(),
forced: s.forced,
},
@@ -133,10 +131,10 @@ impl M2tsMeta {
secondary,
} => Stream::Video(VideoStream {
pid: *pid,
codec: str_to_codec(codec),
resolution: resolution.clone(),
frame_rate: frame_rate.clone(),
hdr: str_to_hdr(hdr),
codec: codec.parse().unwrap(),
resolution: resolution.parse().unwrap(),
frame_rate: frame_rate.parse().unwrap(),
hdr: hdr.parse().unwrap(),
color_space: ColorSpace::Bt709,
secondary: *secondary,
label: label.clone(),
@@ -151,10 +149,10 @@ impl M2tsMeta {
secondary,
} => Stream::Audio(AudioStream {
pid: *pid,
codec: str_to_codec(codec),
channels: channels.clone(),
codec: codec.parse().unwrap(),
channels: channels.parse().unwrap(),
language: language.clone(),
sample_rate: sample_rate.clone(),
sample_rate: sample_rate.parse().unwrap(),
secondary: *secondary,
label: label.clone(),
}),
@@ -165,7 +163,7 @@ impl M2tsMeta {
forced,
} => Stream::Subtitle(SubtitleStream {
pid: *pid,
codec: str_to_codec(codec),
codec: codec.parse().unwrap(),
language: language.clone(),
forced: *forced,
codec_data: None,
@@ -277,58 +275,5 @@ pub fn read_header_from_stream(r: &mut impl Read) -> io::Result<Option<M2tsMeta>
Ok(Some(meta))
}
// Codec string conversion (compact, no English — just codec identifiers)
fn codec_to_str(c: Codec) -> String {
match c {
Codec::Hevc => "hevc",
Codec::H264 => "h264",
Codec::Vc1 => "vc1",
Codec::Mpeg2 => "mpeg2",
Codec::TrueHd => "truehd",
Codec::DtsHdMa => "dtshd_ma",
Codec::DtsHdHr => "dtshd_hr",
Codec::Dts => "dts",
Codec::Ac3 => "ac3",
Codec::Ac3Plus => "eac3",
Codec::Lpcm => "lpcm",
Codec::Pgs => "pgs",
Codec::DvdSub => "dvdsub",
Codec::Unknown(_) => "unknown",
}
.into()
}
fn str_to_codec(s: &str) -> Codec {
match s {
"hevc" => Codec::Hevc,
"h264" => Codec::H264,
"vc1" => Codec::Vc1,
"mpeg2" => Codec::Mpeg2,
"truehd" => Codec::TrueHd,
"dtshd_ma" => Codec::DtsHdMa,
"dtshd_hr" => Codec::DtsHdHr,
"dts" => Codec::Dts,
"ac3" => Codec::Ac3,
"eac3" => Codec::Ac3Plus,
"lpcm" => Codec::Lpcm,
"pgs" => Codec::Pgs,
_ => Codec::Unknown(0),
}
}
fn hdr_to_str(h: HdrFormat) -> String {
match h {
HdrFormat::Sdr => "sdr",
HdrFormat::Hdr10 => "hdr10",
HdrFormat::DolbyVision => "dv",
}
.into()
}
fn str_to_hdr(s: &str) -> HdrFormat {
match s {
"hdr10" => HdrFormat::Hdr10,
"dv" => HdrFormat::DolbyVision,
_ => HdrFormat::Sdr,
}
}
// Serialization uses Codec::id() / HdrFormat::id() and Display impls.
// Deserialization uses FromStr impls (.parse()) on each enum.
+5 -42
View File
@@ -35,7 +35,7 @@ impl MkvTrack {
Codec::Mpeg2 => "V_MPEG2",
_ => "V_MPEG2",
};
let (w, h) = parse_resolution(&v.resolution);
let (w, h) = v.resolution.pixels();
Self {
track_type: ebml::TRACK_TYPE_VIDEO,
codec_id,
@@ -61,8 +61,8 @@ impl MkvTrack {
Codec::Lpcm => "A_PCM/INT/BIG",
_ => "A_AC3",
};
let sr = parse_sample_rate(&a.sample_rate);
let ch = parse_channels(&a.channels);
let sr = a.sample_rate.hz();
let ch = a.channels.count();
Self {
track_type: ebml::TRACK_TYPE_AUDIO,
codec_id,
@@ -414,45 +414,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Helpers
// ============================================================
fn parse_resolution(s: &str) -> (u32, u32) {
if s.contains("2160") {
(3840, 2160)
} else if s.contains("1080") {
(1920, 1080)
} else if s.contains("720") {
(1280, 720)
} else if s.contains("576") {
(720, 576)
} else if s.contains("480") {
(720, 480)
} else {
(1920, 1080)
}
}
fn parse_sample_rate(s: &str) -> f64 {
if s.contains("192") {
192_000.0
} else if s.contains("96") {
96000.0
} else {
48000.0
}
}
fn parse_channels(s: &str) -> u8 {
if s.contains("7.1") {
8
} else if s.contains("5.1") {
6
} else if s.contains("stereo") || s.contains("2.0") {
2
} else if s.contains("mono") {
1
} else {
6
}
}
// Old parse_resolution/parse_sample_rate/parse_channels removed —
// Resolution::pixels(), SampleRate::hz(), AudioChannels::count() replace them.
#[cfg(test)]
mod tests {
+11 -4
View File
@@ -6,11 +6,12 @@
use super::mkv::{MkvMuxer, MkvTrack};
use super::WriteSeek;
use crate::disc::DiscTitle;
use crate::pes::{OutputStream, PesFrame};
use crate::pes::PesFrame;
use std::io;
pub struct MkvOutputStream {
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
title: DiscTitle,
}
impl MkvOutputStream {
@@ -43,12 +44,16 @@ impl MkvOutputStream {
&title.chapters,
)?;
Ok(Self { muxer: Some(muxer) })
Ok(Self { muxer: Some(muxer), title: title.clone() })
}
}
impl OutputStream for MkvOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> {
impl crate::pes::Stream for MkvOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "MKV output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
if let Some(ref mut muxer) = self.muxer {
muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
} else {
@@ -63,4 +68,6 @@ impl OutputStream for MkvOutputStream {
Ok(())
}
}
fn info(&self) -> &DiscTitle { &self.title }
}
+1 -1
View File
@@ -45,7 +45,7 @@ pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream;
pub use network::NetworkStream;
pub use null::NullStream;
pub use resolve::{open_input, open_output, open_pes_input, open_pes_output, parse_url, InputOptions, StreamUrl};
pub use resolve::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl};
pub use stdio::StdioStream;
use crate::disc::DiscTitle;
+6 -5
View File
@@ -148,7 +148,8 @@ impl Read for NetworkStream {
mod tests {
use super::*;
use crate::disc::{
AudioStream, Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream,
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat,
Resolution, SampleRate, Stream, VideoStream,
};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -165,8 +166,8 @@ mod tests {
Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: "2160p".into(),
frame_rate: "23.976".into(),
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
secondary: false,
@@ -175,9 +176,9 @@ mod tests {
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::TrueHd,
channels: "7.1".into(),
channels: AudioChannels::Surround71,
language: "eng".into(),
sample_rate: "48kHz".into(),
sample_rate: SampleRate::S48,
secondary: false,
label: "English".into(),
}),
+59 -48
View File
@@ -1,19 +1,15 @@
//! PES output adapters — every output format muxes from PES frames.
//!
//! Each output knows its own format:
//! - M2TS: PES → BD-TS packets → file (via TsMuxer)
//! - Null: discard
//! - Stdio: raw frame data to stdout
//! - Network: PES → BD-TS → TCP (via TsMuxer)
//! PES output streams — each writes its own format from PES frames.
use super::tsmux::TsMuxer;
use crate::disc::DiscTitle;
use crate::pes::{OutputStream, PesFrame};
use crate::pes::PesFrame;
use std::io::{self, Write};
/// M2TS output — PES frames → BD-TS packets → file.
// ── M2TS ────────────────────────────────────────────────────────────────────
pub struct M2tsOutputStream {
muxer: TsMuxer<io::BufWriter<std::fs::File>>,
title: DiscTitle,
}
impl M2tsOutputStream {
@@ -21,79 +17,94 @@ impl M2tsOutputStream {
let file = std::fs::File::create(path)
.map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path, e)))?;
let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file);
let pids = Self::extract_pids(title);
Ok(Self {
muxer: TsMuxer::new(writer, &pids),
})
}
fn extract_pids(title: &DiscTitle) -> Vec<u16> {
title.streams.iter().map(|s| match s {
crate::disc::Stream::Video(v) => v.pid,
crate::disc::Stream::Audio(a) => a.pid,
crate::disc::Stream::Subtitle(s) => s.pid,
}).collect()
let pids = extract_pids(title);
Ok(Self { muxer: TsMuxer::new(writer, &pids), title: title.clone() })
}
}
impl OutputStream for M2tsOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> {
impl crate::pes::Stream for M2tsOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "M2TS output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
self.muxer.write_frame(frame.track, frame.pts, &frame.data)
}
fn finish(&mut self) -> io::Result<()> {
self.muxer.finish_ref()
}
fn finish(&mut self) -> io::Result<()> { self.muxer.finish_ref() }
fn info(&self) -> &DiscTitle { &self.title }
}
/// Null output — discards all frames.
pub struct NullOutputStream;
// ── Null ────────────────────────────────────────────────────────────────────
impl OutputStream for NullOutputStream {
fn write_frame(&mut self, _frame: &PesFrame) -> io::Result<()> { Ok(()) }
pub struct NullOutputStream { title: DiscTitle }
impl NullOutputStream {
pub fn new(title: &DiscTitle) -> Self { Self { title: title.clone() } }
}
impl crate::pes::Stream for NullOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> { Ok(None) }
fn write(&mut self, _: &PesFrame) -> io::Result<()> { Ok(()) }
fn finish(&mut self) -> io::Result<()> { Ok(()) }
fn info(&self) -> &DiscTitle { &self.title }
}
/// Stdio output — writes raw frame data to stdout.
// ── Stdio ───────────────────────────────────────────────────────────────────
pub struct StdioOutputStream {
writer: io::BufWriter<io::Stdout>,
title: DiscTitle,
}
impl StdioOutputStream {
pub fn new() -> Self {
Self { writer: io::BufWriter::new(io::stdout()) }
pub fn new(title: &DiscTitle) -> Self {
Self { writer: io::BufWriter::new(io::stdout()), title: title.clone() }
}
}
impl OutputStream for StdioOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> {
impl crate::pes::Stream for StdioOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "stdio output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
self.writer.write_all(&frame.data)
}
fn finish(&mut self) -> io::Result<()> {
self.writer.flush()
}
fn finish(&mut self) -> io::Result<()> { self.writer.flush() }
fn info(&self) -> &DiscTitle { &self.title }
}
/// Network output — PES frames → BD-TS → TCP.
// ── Network ─────────────────────────────────────────────────────────────────
pub struct NetworkOutputStream {
muxer: TsMuxer<io::BufWriter<std::net::TcpStream>>,
title: DiscTitle,
}
impl NetworkOutputStream {
pub fn connect(addr: &str, title: &DiscTitle) -> io::Result<Self> {
let stream = std::net::TcpStream::connect(addr)?;
let writer = io::BufWriter::with_capacity(256 * 1024, stream);
let pids = M2tsOutputStream::extract_pids(title);
Ok(Self {
muxer: TsMuxer::new(writer, &pids),
})
let pids = extract_pids(title);
Ok(Self { muxer: TsMuxer::new(writer, &pids), title: title.clone() })
}
}
impl OutputStream for NetworkOutputStream {
fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> {
impl crate::pes::Stream for NetworkOutputStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "network output is write-only"))
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
self.muxer.write_frame(frame.track, frame.pts, &frame.data)
}
fn finish(&mut self) -> io::Result<()> {
self.muxer.finish_ref()
}
fn finish(&mut self) -> io::Result<()> { self.muxer.finish_ref() }
fn info(&self) -> &DiscTitle { &self.title }
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn extract_pids(title: &DiscTitle) -> Vec<u16> {
title.streams.iter().map(|s| match s {
crate::disc::Stream::Video(v) => v.pid,
crate::disc::Stream::Audio(a) => a.pid,
crate::disc::Stream::Subtitle(s) => s.pid,
}).collect()
}
-1
View File
@@ -82,7 +82,6 @@ impl PsDemuxer {
let mut pos = 0;
while let Some(sc) = find_start_code(&self.buffer, pos) {
if sc + 3 >= self.buffer.len() {
// Not enough bytes to read the start code ID.
break;
+5 -5
View File
@@ -305,7 +305,7 @@ pub struct InputOptions {
// ── PES-based open ──────────────────────────────────────────────────────────
/// Open a PES input stream (produces PES frames).
pub fn open_pes_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::InputStream>> {
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Iso { ref path } => {
@@ -369,11 +369,11 @@ pub fn open_pes_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crat
}
/// Open a PES output stream (consumes PES frames).
pub fn open_pes_output(
pub fn output(
url: &str,
title: &crate::disc::DiscTitle,
codec_privates: &[Option<Vec<u8>>],
) -> io::Result<Box<dyn crate::pes::OutputStream>> {
) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Mkv { ref path } => {
@@ -393,10 +393,10 @@ pub fn open_pes_output(
Ok(Box::new(super::pesout::NetworkOutputStream::connect(addr, title)?))
}
StreamUrl::Stdio => {
Ok(Box::new(super::pesout::StdioOutputStream::new()))
Ok(Box::new(super::pesout::StdioOutputStream::new(title)))
}
StreamUrl::Null => {
Ok(Box::new(super::pesout::NullOutputStream))
Ok(Box::new(super::pesout::NullOutputStream::new(title)))
}
StreamUrl::Disc { .. } => {
Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only"))