1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
use super::{
capture::StreamerState, error::VideoReaderError, set_location_property, StreamCapture,
StreamCaptureError,
};
use gstreamer::prelude::*;
use kornia_image::{Image, ImageSize};
use std::{path::Path, time::Duration};
pub use gstreamer::SeekFlags;
/// The codec to use for the video writer.
pub enum VideoCodec {
/// H.264 codec.
H264,
}
/// The format of the image to write to the video file.
///
/// Usually will be the combination of the image format and the pixel type.
pub enum ImageFormat {
/// 8-bit RGB format.
Rgb8,
/// 8-bit mono format.
Mono8,
}
/// A struct for writing video files.
pub struct VideoWriter {
pipeline: gstreamer::Pipeline,
appsrc: gstreamer_app::AppSrc,
fps: i32,
format: ImageFormat,
counter: u64,
handle: Option<std::thread::JoinHandle<()>>,
}
impl VideoWriter {
/// Create a new VideoWriter.
///
/// # Arguments
///
/// * `path` - The path to save the video file.
/// * `codec` - The codec to use for the video writer.
/// * `format` - The expected image format.
/// * `fps` - The frames per second of the video.
/// * `size` - The size of the video.
pub fn new(
path: impl AsRef<Path>,
codec: VideoCodec,
format: ImageFormat,
fps: i32,
size: ImageSize,
) -> Result<Self, StreamCaptureError> {
// make sure that we do not initialize gstreamer several times
if !gstreamer::INITIALIZED.load(std::sync::atomic::Ordering::Relaxed) {
gstreamer::init()?;
}
// TODO: Add support for other codecs
#[allow(unreachable_patterns)]
let _codec = match codec {
VideoCodec::H264 => "x264enc",
_ => {
return Err(StreamCaptureError::InvalidConfig(
"Unsupported codec".to_string(),
))
}
};
// TODO: Add support for other formats
let format_str = match format {
ImageFormat::Mono8 => "GRAY8",
ImageFormat::Rgb8 => "RGB",
};
if fps <= 0 {
return Err(StreamCaptureError::InvalidConfig(format!(
"fps must be positive, got {fps}"
)));
}
// The output path is set as a property after parsing (see `set_location_property`).
let pipeline_str = "appsrc name=src ! \
videoconvert ! video/x-raw,format=I420 ! \
x264enc ! \
video/x-h264,profile=main ! \
h264parse ! \
mp4mux ! \
filesink name=filesink";
let pipeline = gstreamer::parse::launch(pipeline_str)?
.dynamic_cast::<gstreamer::Pipeline>()
.map_err(StreamCaptureError::DowncastPipelineError)?;
set_location_property(&pipeline, "filesink", path.as_ref())?;
let appsrc = pipeline
.by_name("src")
.ok_or_else(|| StreamCaptureError::GetElementByNameError)?
.dynamic_cast::<gstreamer_app::AppSrc>()
.map_err(StreamCaptureError::DowncastPipelineError)?;
appsrc.set_format(gstreamer::Format::Time);
let caps = gstreamer::Caps::builder("video/x-raw")
.field("format", format_str)
.field("width", size.width as i32)
.field("height", size.height as i32)
.field("framerate", gstreamer::Fraction::new(fps, 1))
.build();
appsrc.set_caps(Some(&caps));
appsrc.set_is_live(true);
appsrc.set_property("block", false);
Ok(Self {
pipeline,
appsrc,
fps,
format,
counter: 0,
handle: None,
})
}
/// Start the video writer.
///
/// Set the pipeline to playing and launch a task to handle the bus messages.
pub fn start(&mut self) -> Result<(), StreamCaptureError> {
// set the pipeline to playing
self.pipeline.set_state(gstreamer::State::Playing)?;
let bus = self.pipeline.bus().ok_or(StreamCaptureError::BusError)?;
// launch a task to handle the bus messages, exit when EOS is received and set the pipeline to null
let handle = std::thread::spawn(move || {
for msg in bus.iter_timed(gstreamer::ClockTime::NONE) {
match msg.view() {
gstreamer::MessageView::Eos(..) => {
log::debug!("gstreamer received EOS");
break;
}
gstreamer::MessageView::Error(err) => {
log::error!(
"Error from {:?}: {} ({:?})",
msg.src().map(|s| s.path_string()),
err.error(),
err.debug()
);
break;
}
_ => {}
}
}
});
self.handle = Some(handle);
Ok(())
}
/// Close the video writer.
///
/// Set the pipeline to null and join the thread.
///
pub fn close(&mut self) -> Result<(), StreamCaptureError> {
// send end of stream to the appsrc
self.appsrc.end_of_stream()?;
self.pipeline.set_state(gstreamer::State::Null)?;
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
return Err(StreamCaptureError::JoinThreadError);
}
}
Ok(())
}
/// Write an image to the video file.
///
/// # Arguments
///
/// * `img` - The image to write to the video file.
// TODO: explore supporting write_async
pub fn write<const C: usize>(&mut self, img: &Image<u8, C>) -> Result<(), StreamCaptureError> {
// check if the image channels are correct
match self.format {
ImageFormat::Mono8 => {
if C != 1 {
return Err(StreamCaptureError::InvalidImageFormat(format!(
"Invalid number of channels: expected 1, got {C}"
)));
}
}
ImageFormat::Rgb8 => {
if C != 3 {
return Err(StreamCaptureError::InvalidImageFormat(format!(
"Invalid number of channels: expected 3, got {C}"
)));
}
}
}
// TODO: verify is there is a cheaper way to copy the buffer
let mut buffer = gstreamer::Buffer::from_mut_slice(img.as_slice().to_vec());
let pts =
gstreamer::ClockTime::from_nseconds(self.counter * 1_000_000_000 / self.fps as u64);
let duration = gstreamer::ClockTime::from_nseconds(1_000_000_000 / self.fps as u64);
let buffer_ref = buffer.get_mut().ok_or(StreamCaptureError::GetBufferError)?;
buffer_ref.set_pts(Some(pts));
buffer_ref.set_duration(Some(duration));
self.counter += 1;
if let Err(err) = self.appsrc.push_buffer(buffer) {
return Err(StreamCaptureError::InvalidConfig(err.to_string()));
}
Ok(())
}
}
impl Drop for VideoWriter {
fn drop(&mut self) {
if self.handle.is_some() {
if let Err(e) = self.close() {
log::warn!("Failed to close video writer safely on drop: {:?}", e);
}
}
}
}
/// A struct for reading video files
pub struct VideoReader(StreamCapture);
impl VideoReader {
/// Creates a new `VideoReader`
///
/// # Arguments
///
/// * `path` - The path to the video file to be read.
/// * `format` - The expected image format.
pub fn new(path: impl AsRef<Path>, format: ImageFormat) -> Result<Self, VideoReaderError> {
// TODO: Support more formats
let video_format = match format {
ImageFormat::Rgb8 => "RGB",
ImageFormat::Mono8 => "GRAY8",
};
// The input path is set as a property after parsing (see `set_location_property`);
// the pipeline only starts reading once `start` sets it to PLAYING.
let pipeline = format!(
"filesrc name=filesrc ! \
decodebin ! \
videoconvert ! \
video/x-raw,format={video_format} ! \
appsink name=sink sync=true"
);
let capture = StreamCapture::new(&pipeline)?;
set_location_property(&capture.pipeline, "filesrc", path.as_ref())?;
Ok(Self(capture))
}
/// Starts the video reader pipeline
#[inline]
pub fn start(&mut self) -> Result<(), VideoReaderError> {
self.0.start().map_err(VideoReaderError::StreamCaptureError)
}
/// Pauses the video reader pipeline
#[inline]
pub fn pause(&mut self) -> Result<(), VideoReaderError> {
self.0
.pipeline
.set_state(gstreamer::State::Paused)
.map_err(StreamCaptureError::from)?;
Ok(())
}
/// Close the video reader pipeline
#[inline]
pub fn close(&self) -> Result<(), VideoReaderError> {
self.0.close()?;
Ok(())
}
/// Gets the current FPS of the video
#[inline]
pub fn get_fps(&self) -> Option<f64> {
self.0.get_fps()
}
/// Grabs the last captured image frame.
///
/// # Returns
///
/// An Option containing the last captured Image or None if no image has been captured yet.
#[inline]
pub fn grab_rgb8(&mut self) -> Result<Option<Image<u8, 3>>, VideoReaderError> {
self.0
.grab_rgb8()
.map_err(VideoReaderError::StreamCaptureError)
}
/// Gets the current state of the video pipeline
#[inline]
pub fn get_state(&self) -> StreamerState {
self.0.get_state()
}
/// Gets the current position in the video.
///
/// # Returns
///
/// * `Some(Duration)` - The current position as a Duration from the start of the video in nanoseconds
/// * `None` - If the position could not be determined
pub fn get_pos(&self) -> Option<Duration> {
let clock_time = self
.0
.pipeline
.query_position::<gstreamer::format::ClockTime>()?;
let duration = Duration::from_nanos(clock_time.nseconds());
Some(duration)
}
/// Gets the total duration of the video.
///
/// # Returns
///
/// * `Some(Duration)` - The total duration of the video
/// * `None` - If the video duration could not be determined
pub fn get_duration(&self) -> Option<Duration> {
let clock_time = self
.0
.pipeline
.query_duration::<gstreamer::format::ClockTime>()?;
let duration = Duration::from_nanos(clock_time.nseconds());
Some(duration)
}
/// Seeks to a specific position in the video.
///
/// # Arguments
///
/// * `pos` - The position to seek to, as a Duration from the start of the video.
///
/// # Returns
///
/// * `Ok(())` - If the seek operation was successful.
/// * `Err(VideoReaderError)` - If the seek operation failed.
pub fn seek(
&self,
seek_flags: gstreamer::SeekFlags,
pos: Duration,
) -> Result<(), VideoReaderError> {
let pipeline = &self.0.pipeline;
// Convert the Duration to ClockTime (nanoseconds)
let clock_time = gstreamer::ClockTime::from_nseconds(pos.as_nanos() as u64);
pipeline
.seek_simple(seek_flags, clock_time)
.map_err(|_| VideoReaderError::SeekError)
}
/// Sets the playback speed of the video.
///
/// # Arguments
///
/// * `speed` - The playback speed factor. 1.0 is normal speed, 0.5 is half speed, 2.0 is
/// double speed, etc.
///
/// # Returns
///
/// `true` if the speed change operation was successful, `false` otherwise.
pub fn set_playback_speed(&self, speed: f64) -> Result<(), VideoReaderError> {
if speed <= 0.0 {
return Err(VideoReaderError::InvalidPlaybackSpeed); // Speed must be positive
}
let pipeline = &self.0.pipeline;
// Get current position to maintain the playback position
let position = pipeline
.query_position::<gstreamer::format::ClockTime>()
.ok_or(VideoReaderError::CurrentPosError)?;
// Seek with the new rate
pipeline
.seek(
speed,
gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::ACCURATE,
gstreamer::SeekType::Set,
position,
gstreamer::SeekType::None,
gstreamer::ClockTime::NONE,
)
.map_err(|_| VideoReaderError::SeekError)
}
/// Resets the video to the beginning without changing its state.
///
/// This function seeks the video to the origin (start) but does not stop or start the pipeline.
pub fn reset(&self) -> Result<(), VideoReaderError> {
let pipeline = &self.0.pipeline;
pipeline
.seek_simple(
gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::ACCURATE,
gstreamer::ClockTime::ZERO,
)
.map_err(|_| VideoReaderError::SeekError)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{ImageFormat, VideoCodec, VideoReader, VideoWriter};
use kornia_image::{Image, ImageSize};
/// Regression: paths were quoted into the pipeline string, which rejected `\`
/// (breaking Windows paths). The path must now reach `filesrc` verbatim.
#[test]
fn video_reader_accepts_any_path() -> Result<(), Box<dyn std::error::Error>> {
use gstreamer::prelude::*;
let path = std::path::Path::new(r"C:\my videos\clip ! fakesink name=x.mp4");
let reader = VideoReader::new(path, ImageFormat::Rgb8)?;
let src = reader
.0
.pipeline
.by_name("filesrc")
.ok_or("missing filesrc")?;
assert_eq!(
src.property::<Option<String>>("location").as_deref(),
path.to_str()
);
Ok(())
}
#[ignore = "need gstreamer in CI"]
#[test]
fn video_writer_rgb8u() -> Result<(), Box<dyn std::error::Error>> {
let tmp_dir = tempfile::tempdir()?;
std::fs::create_dir_all(tmp_dir.path())?;
let file_path = tmp_dir.path().join("test.mp4");
let size = ImageSize {
width: 6,
height: 4,
};
let mut writer =
VideoWriter::new(&file_path, VideoCodec::H264, ImageFormat::Rgb8, 30, size)?;
writer.start()?;
let img = Image::<u8, 3>::new(size, vec![0; size.width * size.height * 3])?;
writer.write(&img)?;
writer.close()?;
assert!(file_path.exists(), "File does not exist: {file_path:?}");
Ok(())
}
#[ignore = "need gstreamer in CI"]
#[test]
fn video_writer_mono8u() -> Result<(), Box<dyn std::error::Error>> {
let tmp_dir = tempfile::tempdir()?;
std::fs::create_dir_all(tmp_dir.path())?;
let file_path = tmp_dir.path().join("test.mp4");
let size = ImageSize {
width: 6,
height: 4,
};
let mut writer =
VideoWriter::new(&file_path, VideoCodec::H264, ImageFormat::Mono8, 30, size)?;
writer.start()?;
let img = Image::<u8, 1>::new(size, vec![0; size.width * size.height])?;
writer.write(&img)?;
writer.close()?;
assert!(file_path.exists(), "File does not exist: {file_path:?}");
Ok(())
}
}