refactor(cli,worker): cut liboakengine, link module rlibs directly (M14 R2)
- oak-cli: new engine.rs assembly layer maps every facade call to module Rust APIs (oaknode graph/serializer, oaktimeline commands, oakrender ticket arena, oaktask ExportTask, oakcommon config); the ffi/optional/host layers and build.rs link config are gone - oak-worker: the worker session + POSIX shm transport moved into the crate (oakrender backend + serde_json control plane); no dylib - both binaries carry zero liboakengine references (otool verified); tests green (30 cli / 41 worker)
This commit is contained in:
+116
-249
@@ -18,33 +18,30 @@
|
||||
//! render the first sequence to PPM frames plus a PCM s16 WAV (port of
|
||||
//! `cmd_render()` in cli/main.cpp).
|
||||
//!
|
||||
//! Runs entirely through the C ABI:
|
||||
//! Runs entirely through the module crates (M14 R2):
|
||||
//!
|
||||
//! 1. `oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER)`
|
||||
//! — plus `oakengine_render_manager_init()`, the Rust facade's
|
||||
//! replacement for the C++ engine core's OAKENGINE_INIT_RENDER boot
|
||||
//! (the ticket render path needs the oakrender manager up).
|
||||
//! 2. `oakengine_project_create` + `oakengine_project_load` (the
|
||||
//! 1. `crate::engine::load_project` — the oaknode serializer (the
|
||||
//! process chdirs into the project directory first, like the C++
|
||||
//! CLI, so relative footage paths resolve during rendering).
|
||||
//! 3. Sequence 0's frame rate via `oakengine_sequence_get_frame_rate`
|
||||
//! and geometry via `oakengine_sequence_get_video_params`.
|
||||
//! 4. `oakengine_renderer_create(seq, w, h, f32, fr_num, fr_den, null)`
|
||||
//! → for every frame timestamp in `[start, end)`
|
||||
//! `oakengine_renderer_render_frame` → the `oakengine_frame_*`
|
||||
//! accessors → [`crate::ppm::write_ppm`] (P6, 8-bit RGB).
|
||||
//! 5. The audio range through `oakengine_renderer_render_audio` → the
|
||||
//! `oakengine_audio_*` accessors → [`crate::wav::write_wav`].
|
||||
//! 2. Sequence 0's frame rate and geometry via the sequence behavior.
|
||||
//! 3. `crate::engine::render_manager_init` — the oakrender manager's
|
||||
//! ticket arena drives the render.
|
||||
//! 4. For every frame timestamp in `[start, end)` the video montage at
|
||||
//! that time is submitted as a ticket
|
||||
//! (`crate::engine::render_frame`) → [`crate::ppm::write_ppm`]
|
||||
//! (P6, 8-bit RGB).
|
||||
//! 5. The audio range through the audio montage + ticket
|
||||
//! (`crate::engine::render_audio`) → [`crate::wav::write_wav`].
|
||||
//!
|
||||
//! Exit codes: a renderer-create or per-frame render failure exits 2
|
||||
//! (rendering unavailable, mirroring the C++ code for a missing render
|
||||
//! backend); project/sequence/argument failures exit 1; bad seconds exit
|
||||
//! 64 (usage). Frame progress goes to stderr (`frame N: T s`).
|
||||
//! Exit codes: a frame/audio render failure exits 2 (rendering
|
||||
//! unavailable, mirroring the C++ code for a missing render backend);
|
||||
//! project/sequence/argument failures exit 1; bad seconds exit 64
|
||||
//! (usage). Frame progress goes to stderr (`frame N: T s`).
|
||||
|
||||
use std::path::Path;
|
||||
use oakcore_rs::TimeRange;
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
|
||||
use crate::ffi;
|
||||
use crate::engine;
|
||||
use crate::ppm;
|
||||
use crate::wav;
|
||||
|
||||
@@ -69,39 +66,17 @@ pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &st
|
||||
return EXIT_USAGE;
|
||||
}
|
||||
|
||||
let code = run_render(&project, start, end, out_dir);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
code
|
||||
run_render(&project, start, end, out_dir)
|
||||
}
|
||||
|
||||
/// The render body; the caller owns the engine shutdown.
|
||||
/// The render body.
|
||||
fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
let rc = unsafe {
|
||||
crate::optional::engine_init(
|
||||
crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER,
|
||||
)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: render: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
// The Rust facade's render boot: the ticket render path requires the
|
||||
// oakrender manager (the C++ OAKENGINE_INIT_RENDER equivalent).
|
||||
if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: render: cannot initialize the render manager");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
// Absolute project path first — the C++ CLI chdirs into the project
|
||||
// directory so relative footage paths resolve during rendering.
|
||||
let abs = match std::fs::canonicalize(project) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("error: render: cannot open project \"{project}\": {e}");
|
||||
unsafe { crate::ffi::oakengine_render_manager_shutdown() };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
@@ -109,129 +84,61 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
let _ = std::env::set_current_dir(dir);
|
||||
}
|
||||
|
||||
let handle = unsafe { crate::ffi::oakengine_project_create() };
|
||||
if handle.is_null() {
|
||||
eprintln!("error: render: cannot create project");
|
||||
unsafe { crate::ffi::oakengine_render_manager_shutdown() };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
let path = match std::ffi::CString::new(abs.as_os_str().as_encoded_bytes()) {
|
||||
let project_ref = match engine::load_project(&abs.to_string_lossy()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: render: invalid path (NUL byte)");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
Err(detail) => {
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: render: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: render: {detail}");
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let mut err = [0 as std::ffi::c_char; 4096];
|
||||
let rc = unsafe {
|
||||
crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32)
|
||||
|
||||
// The render manager must be up before any ticket submission (the
|
||||
// facade's OAKENGINE_INIT_RENDER render boot).
|
||||
if let Err(e) = engine::render_manager_init() {
|
||||
eprintln!("error: render: cannot initialize the render manager: {e}");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
let sequences = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
engine::sequence_ids(&guard)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
// SAFETY: the engine NUL-terminates `err` on failure.
|
||||
let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: render: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: render: {detail}");
|
||||
let seq_id = match sequences.first() {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
eprintln!("error: render: project has no sequences");
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
if unsafe { crate::ffi::oakengine_project_sequence_count(handle) } < 1 {
|
||||
eprintln!("error: render: project has no sequences");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
// Borrowed sequence box (no free export); lives for the project.
|
||||
let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, 0) };
|
||||
if seq.is_null() {
|
||||
eprintln!("error: render: sequence 0 unavailable");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let mut fr_num: i32 = 0;
|
||||
let mut fr_den: i32 = 0;
|
||||
unsafe {
|
||||
crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den);
|
||||
}
|
||||
let (fr_num, fr_den, width, height) = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let fr = engine::sequence_frame_rate(&guard, seq_id);
|
||||
let (w, h) = engine::sequence_geometry(&guard, seq_id);
|
||||
(fr.numerator() as i32, fr.denominator() as i32, w, h)
|
||||
};
|
||||
if fr_num <= 0 || fr_den <= 0 {
|
||||
eprintln!("error: render: invalid sequence frame rate {fr_num}/{fr_den}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let mut width: i32 = 0;
|
||||
let mut height: i32 = 0;
|
||||
unsafe {
|
||||
crate::ffi::oakengine_sequence_get_video_params(
|
||||
seq,
|
||||
&mut width,
|
||||
&mut height,
|
||||
&mut 0,
|
||||
&mut 0,
|
||||
);
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
eprintln!("error: render: sequence has no video geometry");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(out_dir) {
|
||||
eprintln!("error: render: cannot create output directory \"{out_dir}\": {e}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let renderer = unsafe {
|
||||
crate::ffi::oakengine_renderer_create(
|
||||
seq,
|
||||
width,
|
||||
height,
|
||||
crate::ffi::PIXEL_FORMAT_F32,
|
||||
fr_num,
|
||||
fr_den,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if renderer.is_null() {
|
||||
// The engine's create path returns NULL without setting the
|
||||
// renderer's last error (invalid geometry/format or no module
|
||||
// backing); report the contract message.
|
||||
eprintln!("error: render: cannot create renderer");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
// Frame loop: timestamps in the sequence time base (1/fr_num s).
|
||||
let start_frames = (start * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let mut index = start_frames;
|
||||
@@ -241,37 +148,26 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
if time >= end {
|
||||
break;
|
||||
}
|
||||
let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, index) };
|
||||
if frame.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
format!("frame at {time:.6} s failed to render")
|
||||
} else {
|
||||
format!("frame at {time:.6} s failed: {err}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
let time_r = oakcore_rs::Rational::new(index * i64::from(fr_den), i64::from(fr_num));
|
||||
let montage = engine::video_montage(&project_ref, seq_id, time_r);
|
||||
let frame = match engine::render_frame(seq_id, time_r, montage, width, height) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let msg = if e.is_empty() {
|
||||
format!("frame at {time:.6} s failed to render")
|
||||
} else {
|
||||
format!("frame at {time:.6} s failed: {e}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
if let Err(msg) = unsafe { write_frame_ppm(frame, out_dir, written) } {
|
||||
};
|
||||
if let Err(msg) = write_frame_ppm(&frame, out_dir, written) {
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
}
|
||||
eprintln!("frame {written}: {time:.6} s");
|
||||
written += 1;
|
||||
index += 1;
|
||||
@@ -279,102 +175,73 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
|
||||
if written == 0 {
|
||||
eprintln!("error: render: empty frame range");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
// Audio range in the sequence time base.
|
||||
let start_ts = (start * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let length_ts = ((end - start) * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, start_ts, length_ts) };
|
||||
if audio.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
"audio render failed".to_string()
|
||||
} else {
|
||||
format!("audio render failed: {err}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
let range = TimeRange::new(
|
||||
oakcore_rs::Rational::new(start_ts * i64::from(fr_den), i64::from(fr_num)),
|
||||
oakcore_rs::Rational::new(
|
||||
(start_ts + length_ts) * i64::from(fr_den),
|
||||
i64::from(fr_num),
|
||||
),
|
||||
);
|
||||
let montage = engine::audio_montage(&project_ref, seq_id, range);
|
||||
let code = match engine::render_audio(seq_id, range, montage) {
|
||||
Ok(audio) => write_audio_wav(&audio, out_dir),
|
||||
Err(e) => {
|
||||
let msg = if e.is_empty() {
|
||||
"audio render failed".to_string()
|
||||
} else {
|
||||
format!("audio render failed: {e}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
EXIT_RENDER_UNAVAILABLE
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
let code = unsafe { write_audio_wav(audio, out_dir) };
|
||||
unsafe {
|
||||
crate::ffi::oakengine_audio_free(audio);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
};
|
||||
engine::render_manager_shutdown();
|
||||
code
|
||||
}
|
||||
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out_dir` (the
|
||||
/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer).
|
||||
///
|
||||
/// `oakengine_frame_channel_count` is not backed by the current engine
|
||||
/// (returns 0); the render module's frames are always in the internal
|
||||
/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a
|
||||
/// zero/negative channel report falls back to 4 channels.
|
||||
unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out_dir: &str, index: u64) -> Result<(), String> {
|
||||
let width = unsafe { crate::ffi::oakengine_frame_width(frame) };
|
||||
let height = unsafe { crate::ffi::oakengine_frame_height(frame) };
|
||||
let format = unsafe { crate::ffi::oakengine_frame_format(frame) };
|
||||
let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) };
|
||||
let channels = if channels > 0 { channels } else { 4 };
|
||||
let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) };
|
||||
let data = unsafe { crate::ffi::oakengine_frame_data(frame) };
|
||||
if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 {
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out_dir`. The render
|
||||
/// frames are always in the internal RGBA layout
|
||||
/// (`VideoParams::k_internal_channel_count == 4`), so 4 channels feed the
|
||||
/// [`crate::ppm`] writer.
|
||||
fn write_frame_ppm(frame: &engine::RenderedFrame, out_dir: &str, index: u64) -> Result<(), String> {
|
||||
if frame.data.is_empty() || frame.width <= 0 || frame.height <= 0 || frame.linesize <= 0 {
|
||||
return Err(format!(
|
||||
"frame {index} has no pixel data ({}x{}, linesize {linesize})",
|
||||
width, height
|
||||
"frame {index} has no pixel data ({}x{}, linesize {})",
|
||||
frame.width, frame.height, frame.linesize
|
||||
));
|
||||
}
|
||||
let len = (linesize as usize)
|
||||
.checked_mul(height as usize)
|
||||
.ok_or_else(|| "frame buffer size overflow".to_string())?;
|
||||
// SAFETY: the engine's frame buffer is valid for linesize * height
|
||||
// bytes for the duration of this call.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
|
||||
let path = Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(&path, width, height, format, channels, linesize, bytes)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
let path = std::path::Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(
|
||||
&path,
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.format,
|
||||
4,
|
||||
frame.linesize,
|
||||
&frame.data,
|
||||
)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Write the rendered audio buffer as `audio.wav` in `out_dir` (the
|
||||
/// `oakengine_audio_*` accessors feed the [`crate::wav`] writer). The
|
||||
/// buffer is interleaved f32; the engine returns the whole buffer base
|
||||
/// for every channel, so channel 0 covers all frames.
|
||||
unsafe fn write_audio_wav(audio: *mut ffi::OakEngineAudioBuffer, out_dir: &str) -> i32 {
|
||||
let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) };
|
||||
let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) };
|
||||
let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) };
|
||||
let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) };
|
||||
if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 {
|
||||
/// buffer is interleaved f32).
|
||||
fn write_audio_wav(audio: &engine::RenderedAudio, out_dir: &str) -> i32 {
|
||||
let rate = audio.sample_rate;
|
||||
let channels = audio.channel_count;
|
||||
if rate <= 0 || channels <= 0 || audio.data.is_empty() {
|
||||
eprintln!("error: render: audio buffer is empty");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
let len = match (samples as usize).checked_mul(channels as usize) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
eprintln!("error: render: audio buffer size overflow");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
};
|
||||
// SAFETY: the engine's audio buffer is valid for samples * channels
|
||||
// floats for the duration of this call.
|
||||
let floats = unsafe { std::slice::from_raw_parts(data, len) };
|
||||
let path = Path::new(out_dir).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) {
|
||||
let samples = (audio.data.len() / channels as usize) as i64;
|
||||
let path = std::path::Path::new(out_dir).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, rate, channels, samples, &audio.data) {
|
||||
eprintln!("error: render: cannot write \"{}\": {e}", path.display());
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user