perf(video): 优化 MJPEG 传输和 JPEG 编码

按 HTTP 消费进度读取最新帧并合并积压通知,复用帧缓冲区所有权,减少 MJPEG 传输和截图的数据复制。

在 JPEG 数据后立即发送下一帧的完整 multipart 头,减少静态画面的浏览器显示等待,并保留保活和客户端释放逻辑。

缓存相同原始帧的 JPEG 编码结果,在输入或画质变化时重新编码;按像素格式分配转换缓冲区,减少闲置内存。

补充缓冲区寿命、慢客户端、序列号重置、编码缓存和保活回归测试。
This commit is contained in:
mofeng-git
2026-10-18 17:42:22 +08:00
parent b657ef76bc
commit 92a08fb2bc
5 changed files with 376 additions and 174 deletions

View File

@@ -24,6 +24,25 @@ use crate::video::VideoFrame;
pub type ClientId = String;
#[cfg(feature = "desktop")]
#[derive(Clone, Copy, PartialEq, Eq)]
struct RawFrameFingerprint {
resolution: crate::video::format::Resolution,
format: PixelFormat,
stride: u32,
online: bool,
len: usize,
hash: u64,
quality: u32,
}
#[cfg(feature = "desktop")]
struct JpegEncoderState {
encoder: JpegEncoder,
last_input: Option<RawFrameFingerprint>,
last_frame: Option<VideoFrame>,
}
#[derive(Debug, Clone)]
pub struct ClientSession {
pub id: ClientId,
@@ -115,7 +134,7 @@ pub struct MjpegStreamHandler {
dropped_same_frames: AtomicU64,
max_drop_same_frames: AtomicU64,
#[cfg(feature = "desktop")]
jpeg_encoder: ParkingMutex<Option<JpegEncoder>>,
jpeg_encoder: ParkingMutex<Option<JpegEncoderState>>,
jpeg_quality: AtomicU64,
}
@@ -219,24 +238,43 @@ impl MjpegStreamHandler {
let resolution = frame.resolution;
let sequence = self.sequence.load(Ordering::Relaxed);
let desired_quality = self.jpeg_quality.load(Ordering::Relaxed) as u32;
// Keep only a fingerprint and the encoded output, not a second raw
// frame. Unchanged input can reuse JPEG bytes even for forced refreshes.
let fingerprint = RawFrameFingerprint {
resolution,
format: frame.format,
stride: frame.stride,
online: frame.online,
len: frame.len(),
hash: frame.get_hash(),
quality: desired_quality,
};
let mut encoder_guard = self.jpeg_encoder.lock();
let encoder = encoder_guard.get_or_insert_with(|| {
if encoder_guard.is_none() {
let config = EncoderConfig::jpeg(resolution, desired_quality);
match JpegEncoder::new(config) {
Ok(enc) => {
let encoder = JpegEncoder::new(config)
.map_err(|e| format!("Failed to create JPEG encoder: {}", e))?;
debug!(
"Created JPEG encoder for MJPEG stream: {}x{} (q={})",
resolution.width, resolution.height, desired_quality
);
enc
}
Err(e) => {
warn!("Failed to create JPEG encoder: {}", e);
panic!("Failed to create JPEG encoder");
}
}
*encoder_guard = Some(JpegEncoderState {
encoder,
last_input: None,
last_frame: None,
});
}
let state = encoder_guard.as_mut().expect("JPEG encoder initialized");
if state.last_input == Some(fingerprint) {
if let Some(mut cached) = state.last_frame.clone() {
cached.sequence = sequence;
cached.capture_ts = frame.capture_ts;
cached.online = frame.online;
return Ok(cached);
}
}
let encoder = &mut state.encoder;
if encoder.config().resolution != resolution {
debug!(
@@ -285,13 +323,12 @@ impl MjpegStreamHandler {
}
};
Ok(VideoFrame::new(
encoded.data,
resolution,
PixelFormat::Mjpeg,
0,
sequence,
))
let mut jpeg = VideoFrame::new(encoded.data, resolution, PixelFormat::Mjpeg, 0, sequence);
jpeg.capture_ts = frame.capture_ts;
jpeg.online = frame.online;
state.last_input = Some(fingerprint);
state.last_frame = Some(jpeg.clone());
Ok(jpeg)
}
pub fn set_offline(&self) {
@@ -550,6 +587,91 @@ mod tests {
use crate::video::{format::Resolution, PixelFormat};
use bytes::Bytes;
#[cfg(feature = "desktop")]
fn raw_frame(resolution: Resolution, sequence: u64) -> VideoFrame {
VideoFrame::new(
Bytes::from(vec![
128;
resolution.width as usize
* resolution.height as usize
* 3
/ 2
]),
resolution,
PixelFormat::Nv12,
resolution.width,
sequence,
)
}
#[cfg(feature = "desktop")]
#[test]
fn raw_cache_reuses_bytes_but_invalidates_on_input_and_quality_changes() {
let handler = Arc::new(MjpegStreamHandler::with_drop_limit(0));
let _guard = ClientGuard::new("raw-test".into(), handler.clone());
let resolution = Resolution::new(64, 32);
handler.update_frame(raw_frame(resolution, 1));
let first = handler.current_frame().unwrap();
let next_input = raw_frame(resolution, 2);
let capture_ts = next_input.capture_ts;
handler.update_frame(next_input);
let repeated = handler.current_frame().unwrap();
assert_eq!(first.data().as_ptr(), repeated.data().as_ptr());
assert_ne!(first.sequence, repeated.sequence);
assert_eq!(repeated.capture_ts, capture_ts);
handler.set_jpeg_quality(40);
handler.update_frame(raw_frame(resolution, 3));
let changed_quality = handler.current_frame().unwrap();
assert_ne!(changed_quality.data().as_ptr(), repeated.data().as_ptr());
let mut data = raw_frame(resolution, 4).data().to_vec();
data[321] = 255; // A small change away from the fast sample positions.
handler.update_frame(VideoFrame::new(
Bytes::from(data),
resolution,
PixelFormat::Nv12,
resolution.width,
4,
));
let changed_pixels = handler.current_frame().unwrap();
assert_ne!(
changed_pixels.data().as_ptr(),
changed_quality.data().as_ptr()
);
let resized = Resolution::new(32, 64);
handler.update_frame(raw_frame(resized, 5));
let changed_resolution = handler.current_frame().unwrap();
assert_eq!(changed_resolution.resolution, resized);
assert_ne!(
changed_resolution.data().as_ptr(),
changed_pixels.data().as_ptr()
);
}
#[cfg(feature = "desktop")]
#[test]
fn raw_duplicates_skip_publication_but_forced_refresh_reuses_jpeg() {
let handler = Arc::new(MjpegStreamHandler::new());
let _guard = ClientGuard::new("raw-refresh-test".into(), handler.clone());
let mut notifications = handler.subscribe();
let resolution = Resolution::new(64, 32);
handler.update_frame(raw_frame(resolution, 1));
let first = handler.current_frame().unwrap();
notifications.try_recv().unwrap();
handler.update_frame(raw_frame(resolution, 2));
assert!(matches!(
notifications.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
*handler.last_frame_ts.write() = Some(Instant::now() - Duration::from_secs(2));
handler.update_frame(raw_frame(resolution, 3));
notifications.try_recv().unwrap();
let refresh = handler.current_frame().unwrap();
assert_ne!(refresh.sequence, first.sequence);
assert_eq!(refresh.data().as_ptr(), first.data().as_ptr());
}
#[tokio::test]
async fn test_stream_handler() {
let handler = MjpegStreamHandler::new();

184
src/stream/mjpeg_http.rs Normal file
View File

@@ -0,0 +1,184 @@
//! Pull the latest JPEG directly when the HTTP consumer is ready.
use std::io;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use futures::Stream;
use tokio::sync::broadcast::error::{RecvError, TryRecvError};
use super::{ClientGuard, MjpegStreamHandler};
const PART_HEADER: &[u8] = b"--frame\r\nContent-Type: image/jpeg\r\n\r\n";
// Chromium commits the previous image after parsing the next part's complete
// headers. Send these immediately after the JPEG, even when the scene stops.
const PART_TRAILER: &[u8] = b"\r\n--frame\r\nContent-Type: image/jpeg\r\n\r\n";
pub(crate) fn body_stream(
handler: Arc<MjpegStreamHandler>,
guard: ClientGuard,
) -> impl Stream<Item = Result<Bytes, io::Error>> + Send {
stream_with_timeout(handler, guard, Duration::from_secs(5))
}
fn stream_with_timeout(
handler: Arc<MjpegStreamHandler>,
guard: ClientGuard,
timeout: Duration,
) -> impl Stream<Item = Result<Bytes, io::Error>> + Send {
// Subscribe before reading the initial frame so an update cannot be missed.
let mut notifications = handler.subscribe();
async_stream::stream! {
let guard = guard;
let mut last_sequence = None;
let mut timeout_count = 0;
let mut keepalive = false;
yield Ok(Bytes::from_static(PART_HEADER));
loop {
// Notifications contain no frame data. Coalesce the backlog, then
// fetch the latest frame rather than queueing JPEGs for slow peers.
loop {
match notifications.try_recv() {
Ok(()) | Err(TryRecvError::Lagged(_)) => timeout_count = 0,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Closed) => return,
}
}
if !handler.is_online() {
break;
}
if let Some(frame) = handler.current_frame() {
if (keepalive || last_sequence != Some(frame.sequence)) && frame.is_valid_jpeg() {
last_sequence = Some(frame.sequence);
// This retains the pooled owner; it does not copy the JPEG.
let data = frame.owned_data_bytes();
drop(frame);
handler.record_frame_sent(guard.id());
yield Ok(data);
yield Ok(Bytes::from_static(PART_TRAILER));
}
}
keepalive = false;
match tokio::time::timeout(timeout, notifications.recv()).await {
Ok(Ok(())) | Ok(Err(RecvError::Lagged(_))) => timeout_count = 0,
Ok(Err(RecvError::Closed)) => break,
Err(_) => {
timeout_count += 1;
if timeout_count > 6 {
break;
}
keepalive = true;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use futures::StreamExt;
fn jpeg(sequence: u64) -> VideoFrame {
let mut data = vec![sequence as u8; 128];
data[..2].copy_from_slice(&[0xff, 0xd8]);
data[126..].copy_from_slice(&[0xff, 0xd9]);
VideoFrame::new(
Bytes::from(data),
Resolution::VGA,
PixelFormat::Mjpeg,
0,
sequence,
)
}
#[tokio::test]
async fn sends_complete_pooled_frame_without_copying_or_waiting_for_next_frame() {
let handler = Arc::new(MjpegStreamHandler::with_drop_limit(0));
let pool = Arc::new(FrameBufferPool::new(1));
let data = jpeg(0).data().to_vec();
let original = data.as_ptr();
handler.update_frame(VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(data, Some(pool.clone()))),
Resolution::VGA,
PixelFormat::Mjpeg,
0,
0,
));
let guard = ClientGuard::new("http-test".into(), handler.clone());
let mut stream = Box::pin(body_stream(handler.clone(), guard));
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_HEADER);
let payload = stream.next().await.unwrap().unwrap();
assert_eq!(payload.as_ptr(), original);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
assert!(
tokio::time::timeout(Duration::from_millis(20), stream.next())
.await
.is_err()
);
// Exceed the broadcast capacity while the HTTP consumer is stalled.
for sequence in 1..=40 {
handler.update_frame(jpeg(sequence));
}
let latest = stream.next().await.unwrap().unwrap();
assert_eq!(latest[2], 40);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
// The previous pooled frame remains valid until its HTTP owner drops.
assert_eq!(payload.as_ptr(), original);
assert_eq!(payload[2], 0);
drop(payload);
let recycled = pool.take(128);
assert_eq!(recycled.as_ptr(), original);
// Capture sequence numbers may reset after reopening the source.
handler.update_frame(jpeg(0));
assert_eq!(stream.next().await.unwrap().unwrap()[2], 0);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
assert_eq!(handler.get_clients_stat()["http-test"].fps, 3);
drop(stream);
assert_eq!(handler.client_count(), 0);
}
#[tokio::test]
async fn keepalive_and_offline_close_release_the_client() {
let handler = Arc::new(MjpegStreamHandler::new());
handler.update_frame(jpeg(1));
let guard = ClientGuard::new("keepalive-test".into(), handler.clone());
let mut stream = Box::pin(stream_with_timeout(
handler.clone(),
guard,
Duration::from_millis(10),
));
for _ in 0..3 {
stream.next().await.unwrap().unwrap();
}
let repeated = tokio::time::timeout(Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(repeated[2], 1);
assert_eq!(stream.next().await.unwrap().unwrap().as_ref(), PART_TRAILER);
handler.set_offline();
assert!(stream.next().await.is_none());
drop(stream);
assert_eq!(handler.client_count(), 0);
}
#[test]
fn dropping_an_unpolled_response_releases_the_client() {
let handler = Arc::new(MjpegStreamHandler::new());
let guard = ClientGuard::new("unpolled-test".into(), handler.clone());
let stream = body_stream(handler.clone(), guard);
assert_eq!(handler.client_count(), 1);
drop(stream);
assert_eq!(handler.client_count(), 0);
}
}

View File

@@ -1,6 +1,7 @@
//! MJPEG multipart streaming and WebSocket HID (for MJPEG mode).
pub mod mjpeg;
pub(crate) mod mjpeg_http;
#[cfg(feature = "desktop")]
pub mod ws_hid;

View File

@@ -33,14 +33,6 @@ pub struct JpegEncoder {
impl JpegEncoder {
/// Create a new JPEG encoder
pub fn new(config: EncoderConfig) -> Result<Self> {
let resolution = config.resolution;
let width = resolution.width as usize;
let height = resolution.height as usize;
// I420: Y = width*height, U = width*height/4, V = width*height/4
let i420_size = width * height * 3 / 2;
let max_uv_plane_size = width * height;
let bgra_size = width * height * 4;
let mut compressor = turbojpeg::Compressor::new().map_err(|e| {
AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e))
})?;
@@ -52,12 +44,18 @@ impl JpegEncoder {
Ok(Self {
config,
compressor,
i420_buffer: vec![0u8; i420_size],
uv_split_buffer: vec![0u8; max_uv_plane_size * 2],
bgra_buffer: vec![0u8; bgra_size],
i420_buffer: Vec::new(),
uv_split_buffer: Vec::new(),
bgra_buffer: Vec::new(),
})
}
fn ensure_i420_buffer(&mut self) {
let size =
self.config.resolution.width as usize * self.config.resolution.height as usize * 3 / 2;
self.i420_buffer.resize(size, 0);
}
/// Create with specific quality
pub fn with_quality(resolution: Resolution, quality: u32) -> Result<Self> {
let config = EncoderConfig::jpeg(resolution, quality);
@@ -146,6 +144,7 @@ impl JpegEncoder {
}
// Convert YUYV to I420 using libyuv (SIMD accelerated)
self.ensure_i420_buffer();
libyuv::yuy2_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv YUYV→I420 failed: {}", e)))?;
@@ -166,6 +165,7 @@ impl JpegEncoder {
)));
}
self.ensure_i420_buffer();
// Reuse bgra_buffer as scratch for the swapped YUYV data.
if self.bgra_buffer.len() < expected_size {
self.bgra_buffer.resize(expected_size, 0);
@@ -202,6 +202,7 @@ impl JpegEncoder {
}
// Convert NV12 to I420 using libyuv (SIMD accelerated)
self.ensure_i420_buffer();
libyuv::nv12_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv NV12→I420 failed: {}", e)))?;
@@ -224,6 +225,8 @@ impl JpegEncoder {
)));
}
self.ensure_i420_buffer();
self.uv_split_buffer.resize(y_size, 0);
let src_uv = &data[y_size..expected_size];
let chroma_plane_size = y_size / 2;
let (u_plane_422, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
@@ -273,6 +276,8 @@ impl JpegEncoder {
)));
}
self.uv_split_buffer.resize(y_size * 2, 0);
self.bgra_buffer.resize(y_size * 4, 0);
let src_uv = &data[y_size..expected_size];
let chroma_plane_size = y_size;
let (u_plane_444, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
@@ -318,6 +323,7 @@ impl JpegEncoder {
}
// Convert RGB24 to I420 using libyuv (SIMD accelerated)
self.ensure_i420_buffer();
libyuv::rgb24_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv RGB24→I420 failed: {}", e)))?;
@@ -340,6 +346,7 @@ impl JpegEncoder {
// Convert BGR24 to I420 using libyuv (SIMD accelerated)
// Note: libyuv's RAWToI420 is BGR24 → I420
self.ensure_i420_buffer();
libyuv::bgr24_to_i420(data, &mut self.i420_buffer, width as i32, height as i32)
.map_err(|e| AppError::VideoError(format!("libyuv BGR24→I420 failed: {}", e)))?;
@@ -395,10 +402,31 @@ mod tests {
use super::*;
#[test]
fn test_i420_buffer_size() {
// 1920x1080 I420 = 1920*1080 + 960*540 + 960*540 = 3110400 bytes
let config = EncoderConfig::jpeg(Resolution::HD1080, 80);
let encoder = JpegEncoder::new(config).unwrap();
assert_eq!(encoder.i420_buffer.len(), 1920 * 1080 * 3 / 2);
fn scratch_buffers_are_allocated_only_for_the_formats_that_use_them() {
let resolution = Resolution::new(64, 32);
let pixels = 64 * 32;
let mut encoder = JpegEncoder::new(EncoderConfig::jpeg(resolution, 80)).unwrap();
assert!(encoder.i420_buffer.is_empty());
assert!(encoder.uv_split_buffer.is_empty());
assert!(encoder.bgra_buffer.is_empty());
encoder.encode_yuyv(&vec![128; pixels * 2], 0).unwrap();
encoder.encode_nv12(&vec![128; pixels * 3 / 2], 1).unwrap();
encoder.encode_rgb(&vec![128; pixels * 3], 2).unwrap();
encoder.encode_bgr(&vec![128; pixels * 3], 3).unwrap();
assert_eq!(encoder.i420_buffer.len(), pixels * 3 / 2);
assert!(encoder.uv_split_buffer.is_empty());
assert!(encoder.bgra_buffer.is_empty());
encoder.encode_yvyu(&vec![128; pixels * 2], 4).unwrap();
assert_eq!(encoder.bgra_buffer.len(), pixels * 2);
encoder.encode_nv16(&vec![128; pixels * 2], 5).unwrap();
assert_eq!(encoder.uv_split_buffer.len(), pixels);
encoder.encode_nv24(&vec![128; pixels * 3], 6).unwrap();
assert_eq!(encoder.uv_split_buffer.len(), pixels * 2);
assert_eq!(encoder.bgra_buffer.len(), pixels * 4);
let mut nv24 = JpegEncoder::new(EncoderConfig::jpeg(resolution, 80)).unwrap();
nv24.encode_nv24(&vec![128; pixels * 3], 0).unwrap();
assert!(nv24.i420_buffer.is_empty());
}
}

View File

@@ -470,122 +470,8 @@ pub async fn mjpeg_stream(
.filter(|id| !id.is_empty() && id.len() <= 64) // Validate: non-empty, max 64 chars
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Create RAII guard - this will automatically register and unregister the client
let guard = Arc::new(crate::stream::mjpeg::ClientGuard::new(
client_id.clone(),
handler.clone(),
));
let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(1);
let guard_clone = guard.clone();
let handler_clone = handler.clone();
tokio::spawn(async move {
let _guard = guard_clone; // Keep guard alive
let mut notify_rx = handler_clone.subscribe();
let mut last_seq = 0u64;
let mut timeout_count = 0;
// Send initial frame if available
if let Some(frame) = handler_clone.current_frame() {
if frame.is_valid_jpeg() {
let data = create_mjpeg_part(frame.data());
// send() blocks until receiver is ready (backpressure)
if tx.send(data).await.is_ok() {
// FPS recording moved to async_stream after yield
last_seq = frame.sequence;
} else {
return; // Receiver closed
}
}
}
loop {
// Check if stream went offline (e.g., during config change)
if !handler_clone.is_online() {
break;
}
// Wait for new frame notification with timeout
let result =
tokio::time::timeout(std::time::Duration::from_secs(5), notify_rx.recv()).await;
match result {
Ok(Ok(())) => {
// Check online status after receiving notification
// set_offline() sends a notification, so we need to check here
if !handler_clone.is_online() {
break;
}
timeout_count = 0;
if let Some(frame) = handler_clone.current_frame() {
// Use != instead of > to handle sequence reset when capturer restarts
// (e.g., after video config change, new capturer starts from seq=0)
if frame.sequence != last_seq && frame.is_valid_jpeg() {
let data = create_mjpeg_part(frame.data());
if tx.send(data).await.is_ok() {
last_seq = frame.sequence;
} else {
break;
}
}
}
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
break;
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {
// Receiver was too slow - skip missed frames and jump to latest
if !handler_clone.is_online() {
break;
}
timeout_count = 0;
if let Some(frame) = handler_clone.current_frame() {
if frame.is_valid_jpeg() {
// Send current frame immediately and reset sequence tracking
if tx.send(create_mjpeg_part(frame.data())).await.is_ok() {
last_seq = frame.sequence;
} else {
break;
}
}
}
}
Err(_) => {
// Timeout - check if still online
timeout_count += 1;
if timeout_count > 6 || !handler_clone.is_online() {
break;
}
// Send last frame again to keep connection alive
let Some(frame) = handler_clone.current_frame() else {
continue;
};
if frame.is_valid_jpeg()
&& tx.send(create_mjpeg_part(frame.data())).await.is_err()
{
break;
}
}
}
}
});
// Create stream that receives from channel and forwards to the HTTP
// body. Record FPS *before* yield so the final frame of a session
// still gets counted (after-yield code in async_stream! only runs
// when the consumer polls again, which never happens for the last
// frame of a closing connection).
let handler_for_stream = handler.clone();
let guard_for_stream = guard.clone();
let body_stream = async_stream::stream! {
while let Some(data) = rx.recv().await {
handler_for_stream.record_frame_sent(guard_for_stream.id());
yield Ok::<bytes::Bytes, std::io::Error>(data);
}
};
let guard = crate::stream::mjpeg::ClientGuard::new(client_id, handler.clone());
let body_stream = crate::stream::mjpeg_http::body_stream(handler, guard);
Response::builder()
.status(StatusCode::OK)
@@ -610,7 +496,7 @@ pub async fn snapshot(State(state): State<Arc<AppState>>) -> impl IntoResponse {
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(frame.data_bytes()))
.body(Body::from(frame.owned_data_bytes()))
.unwrap(),
_ => Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
@@ -618,22 +504,3 @@ pub async fn snapshot(State(state): State<Arc<AppState>>) -> impl IntoResponse {
.unwrap(),
}
}
/// Create MJPEG multipart frame bytes
fn create_mjpeg_part(jpeg_data: &[u8]) -> bytes::Bytes {
use bytes::{BufMut, BytesMut};
let mut buf = BytesMut::with_capacity(128 + jpeg_data.len());
// Write boundary and headers
buf.put_slice(b"--frame\r\n");
buf.put_slice(b"Content-Type: image/jpeg\r\n");
buf.put_slice(format!("Content-Length: {}\r\n", jpeg_data.len()).as_bytes());
buf.put_slice(b"\r\n");
// Write JPEG data
buf.put_slice(jpeg_data);
buf.put_slice(b"\r\n");
buf.freeze()
}