Merge pull request #796 from vangel76/fix-stream-video-open
#794: open movies in place instead of reading them whole # Conflicts: # crates/media/src/lib.rs
This commit is contained in:
commit
4e8e6e10b1
|
|
@ -40,6 +40,12 @@ fn is_pdf_like(path: &str, bytes: &[u8]) -> bool {
|
|||
effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF"))
|
||||
}
|
||||
|
||||
/// Whether `bytes` (the first 64 KiB suffice) are a Photoshop or vector file [`probe`] handles.
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
pub(crate) fn sniff(path: &str, bytes: &[u8]) -> bool {
|
||||
effectcraft_psd::is_psd(bytes) || is_svg(path, bytes) || is_pdf_like(path, bytes)
|
||||
}
|
||||
|
||||
/// Footage for a Photoshop or vector file (`None` for other formats).
|
||||
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
|
||||
if effectcraft_psd::is_psd(bytes) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub use layered::vector_doc;
|
|||
mod pool;
|
||||
mod probe;
|
||||
mod webp_anim;
|
||||
mod stream;
|
||||
|
||||
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
|
||||
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model};
|
||||
|
|
|
|||
|
|
@ -579,9 +579,15 @@ impl Inner {
|
|||
let slot = lock(&self.sources).entry(path.clone()).or_default().clone();
|
||||
slot.get_or_init(|| {
|
||||
self.opened.fetch_add(1, Ordering::Relaxed);
|
||||
let opened = self.read(path).and_then(|bytes| {
|
||||
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)
|
||||
// Files on disk are read in place (the index, then samples on demand) rather than
|
||||
// whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes.
|
||||
let in_memory = lock(&self.files).contains_key(&**path);
|
||||
let in_place = if in_memory { None } else { crate::stream::open_source(std::path::Path::new(&**path)) };
|
||||
let opened = in_place.unwrap_or_else(|| {
|
||||
self.read(path).and_then(|bytes| {
|
||||
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)
|
||||
})
|
||||
});
|
||||
match opened {
|
||||
Ok(s) => Some(s),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30;
|
|||
/// (`effectcraft_engine::sequence`) and probes their first frame here.
|
||||
pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
let path = path.as_ref();
|
||||
// A movie is read in place (its index), not loaded whole.
|
||||
if let Some(src) = crate::stream::probe_source(path) {
|
||||
return Ok(footage_from_info(&path.to_string_lossy(), src?.info()));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
|
||||
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
//! Movie and audio files read in place: the container's index now, single samples on demand.
|
||||
//!
|
||||
//! Reading a file whole before opening it made a clip cost its full size in memory (twice while
|
||||
//! probing: the file's bytes plus the demuxer's copy), so a 45-minute movie took gigabytes just to
|
||||
//! import. FilmCraft's reader openers (MP4/MOV, Matroska/WebM, MXF, Ogg, MPEG-PS/TS) open from a
|
||||
//! random-access [`ByteReader`]; [`FileReader`] serves them positional reads from the open file.
|
||||
//! Formats decoded in one go (stills, WAV, FLAC, MP3…) still read the whole file, as before.
|
||||
//! Builds without a native file system (wasm32) keep using in-memory bytes.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use filmcraft_media::SharedSource;
|
||||
|
||||
use crate::Result;
|
||||
|
||||
/// A file on disk read in place: it stays open and only the requested ranges are read.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FileReader {
|
||||
file: std::fs::File,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
impl FileReader {
|
||||
pub(crate) fn open(path: &Path) -> std::io::Result<Self> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let m = file.metadata()?;
|
||||
if !m.is_file() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display())));
|
||||
}
|
||||
Ok(Self { file, len: m.len() })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
impl filmcraft_media::ByteReader for FileReader {
|
||||
fn len(&self) -> u64 {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Positional reads: threads decoding different parts of the file share no cursor.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let (mut done, mut at) = (0usize, offset);
|
||||
while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) {
|
||||
match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) {
|
||||
Ok(0) => return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file")),
|
||||
Ok(n) => {
|
||||
done = done.saturating_add(n);
|
||||
at = at.saturating_add(n as u64);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
fn name_of(path: &Path) -> String {
|
||||
path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Open a movie/audio file for the media pool, reading only what its container needs; files the
|
||||
/// reader openers don't handle are read whole and opened from memory, exactly as before. `None`
|
||||
/// when the file can't be opened in place (the caller reads it whole, which reports the error).
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) fn open_source(path: &Path) -> Option<Result<SharedSource>> {
|
||||
let reader = FileReader::open(path).ok()?;
|
||||
let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader);
|
||||
let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers());
|
||||
Some(opened.map_err(Into::into))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn open_source(_path: &Path) -> Option<Result<SharedSource>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Probing in place: the opened container when `path` is a movie/audio container a reader opener
|
||||
/// handles and nothing probed before containers (stills, layered and vector documents, 3D models)
|
||||
/// claims it. `None`: probe the whole file's bytes as before.
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) fn probe_source(path: &Path) -> Option<Result<SharedSource>> {
|
||||
let ext = crate::ext_of(path);
|
||||
if crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::MODEL_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let reader = FileReader::open(path).ok()?;
|
||||
let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader);
|
||||
let head = filmcraft_media::reader::read_range(&*reader, 0, filmcraft_media::reader::HEAD_LEN).ok()?;
|
||||
let name = path.to_string_lossy();
|
||||
if crate::layered::sniff(&name, &head) || image::guess_format(&head).is_ok() {
|
||||
return None;
|
||||
}
|
||||
let name = name_of(path);
|
||||
filmcraft_codecs::reader_openers().into_iter().find_map(|open| open(&name, &head, &reader)).map(|r| r.map_err(Into::into))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn probe_source(_path: &Path) -> Option<Result<SharedSource>> {
|
||||
None
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
//! Opening a long movie reads its index, not the whole file: probing it and decoding a frame and
|
||||
//! some audio must not grow peak memory by anything near the file's size.
|
||||
//!
|
||||
//! The large movie is a small ffmpeg-made MP4 followed by a 256 MiB `free` box (a sparse file, so
|
||||
//! it costs no disk writes). Reading it whole, as before, grew peak RSS by its size; read in place,
|
||||
//! the box is skipped by its header. The only test in this binary, so peak RSS is its own.
|
||||
//! Skips (passes with a note) without ffmpeg or off Linux (peak RSS from `/proc/self/status`).
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_media::{MediaPool, probe};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
const PADDING: u64 = 256 << 20;
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
|
||||
}
|
||||
|
||||
/// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test
|
||||
/// processes never see a partial file). `None` without ffmpeg.
|
||||
fn small_mp4() -> Option<PathBuf> {
|
||||
let out = fixtures().join("in_place_small.mp4");
|
||||
if out.exists() {
|
||||
return Some(out);
|
||||
}
|
||||
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
|
||||
return None;
|
||||
}
|
||||
std::fs::create_dir_all(fixtures()).unwrap();
|
||||
let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id()));
|
||||
let st = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"])
|
||||
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.arg(&tmp)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(st.success(), "ffmpeg failed");
|
||||
std::fs::rename(&tmp, &out).unwrap();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `/proc/self/status` field `key` in KiB.
|
||||
fn status_kib(key: &str) -> Option<u64> {
|
||||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let line = s.lines().find(|l| l.starts_with(key))?;
|
||||
line.split_whitespace().nth(1)?.parse().ok()
|
||||
}
|
||||
|
||||
/// Peak RSS so far in bytes, after resetting it to the current RSS where the kernel allows that.
|
||||
fn reset_peak() -> Option<u64> {
|
||||
let _ = std::fs::write("/proc/self/clear_refs", "5");
|
||||
status_kib("VmHWM:").map(|k| k * 1024)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_movie_is_read_in_place_not_whole() {
|
||||
if !cfg!(target_os = "linux") {
|
||||
eprintln!("peak RSS is read from /proc: skipping");
|
||||
return;
|
||||
}
|
||||
let Some(small) = small_mp4() else {
|
||||
eprintln!("ffmpeg not found: skipping");
|
||||
return;
|
||||
};
|
||||
// The same movie with 256 MiB of padding after its boxes.
|
||||
let large = fixtures().join(format!("in_place_large_{}.mp4", std::process::id()));
|
||||
std::fs::copy(&small, &large).unwrap();
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&large).unwrap();
|
||||
f.write_all(&(8 + PADDING as u32).to_be_bytes()).unwrap();
|
||||
f.write_all(b"free").unwrap();
|
||||
f.set_len(std::fs::metadata(&small).unwrap().len() + 8 + PADDING).unwrap();
|
||||
drop(f);
|
||||
|
||||
// Warm up on the small file (code paths, allocator, decoder tables).
|
||||
let small_footage = probe(&small).unwrap();
|
||||
let pool = MediaPool::new();
|
||||
pool.set_read_ahead(false);
|
||||
let small_frame = pool.frame_at(&small_footage, Tick::ZERO).unwrap();
|
||||
let small_audio = pool.audio_samples(&small_footage, Tick::ZERO, 4800, 48_000);
|
||||
|
||||
let before = reset_peak().unwrap();
|
||||
let footage = probe(&large).unwrap();
|
||||
let frame = pool.frame_at(&footage, Tick::ZERO).unwrap();
|
||||
let audio = pool.audio_samples(&footage, Tick::ZERO, 4800, 48_000);
|
||||
let after = status_kib("VmHWM:").unwrap() * 1024;
|
||||
drop(pool);
|
||||
let _ = std::fs::remove_file(&large);
|
||||
|
||||
let growth = after.saturating_sub(before);
|
||||
eprintln!("peak RSS growth: {} KiB", growth >> 10);
|
||||
assert!(growth < 64 << 20, "probing and decoding a {} MiB movie grew peak RSS by {} MiB", PADDING >> 20, growth >> 20);
|
||||
// Same movie, same results.
|
||||
assert_eq!(footage, effectcraft_project::Footage { path: footage.path.clone(), ..small_footage });
|
||||
assert_eq!(frame, small_frame);
|
||||
assert_eq!(audio, small_audio);
|
||||
assert!(audio.iter().any(|s| s.abs() > 0.1), "the tone decodes");
|
||||
}
|
||||
Loading…
Reference in New Issue