Files
oak-editor/crates/oak-task/src/project/import.rs
T
Mike-Solar 244d5e860f
CI / Build & test (Windows) (push) Failing after 7s
workspace: kebab-case crates, app under crates/oak-app, shared versions
All crates take the oak-* kebab-case naming (oak-audio, oak-codec,
oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin,
oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the
lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: ->
oak_core::, ...) across all 226 referencing files.

The GUI application moves from the workspace root into
crates/oak-app/: src/, build.rs (paths fixed for the new location) and
tests/ travel with it, the root Cargo.toml becomes workspace-only
([workspace] + workspace.package + profiles), and the app package
inherits the workspace version. The screenshots example becomes a
standalone crate examples/simple_player/ with its own Cargo.toml.

Every crate now inherits the single workspace version
(version.workspace = true), and the workflows' crate paths and the
build docs follow the renames.

Validated with a clean cargo check --workspace.
2026-08-22 16:58:37 +08:00

455 lines
14 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! `ProjectImportTask`, mirroring `src/task/src/project/import/import.h`.
//!
//! Imports media files into a folder of a project. Produces an undoable
//! [`oak_undo::undocommand::UndoCommand`] (taken via `take_command()`),
//! tracks per-file import failures, and supports an optional image-sequence
//! confirmation callback.
//!
//! **Single-lib note**: the node-graph manipulation went through the
//! deleted oaknode C ABI; it now goes through the direct oaknode domain
//! operations in [`crate::nodeops`] (folder/footage creation, probing via
//! the oakcodec decoder registry, undo command construction). The folder
//! and project are domain references (`Arc<Mutex<Project>>` + `NodeId`)
//! instead of borrowed `CHandle`s.
//!
//! CPP-PARITY: src/task/src/project/import/import.h
use std::path::Path;
use oak_common::configstore::ConfigStore;
use oak_common::videoparams::VideoType;
use oak_undo::undocommand::UndoCommand;
use crate::error::{Error, Result};
use crate::nodeops::{self, NodeRef, ProjectRef};
use crate::task::{Task, TaskBehavior};
/// Callback used to confirm whether a detected image sequence should be
/// imported as a sequence rather than individual frames, mirroring
/// `ImageSequenceConfirmFn` in import.h.
pub type ImageSequenceConfirmFn = Box<dyn FnMut(&str, &str) -> bool + Send>;
/// A project-import task. Borrows the destination folder and project.
pub struct ProjectImportTask {
/// The shared task base.
pub base: Task,
/// Destination folder (project + folder node id).
pub folder: NodeRef,
/// Destination project.
pub project: ProjectRef,
/// Media filenames to import.
pub filenames: Vec<String>,
/// The undo command produced by the task, taken via `take_command`.
command: Option<UndoCommand>,
/// Files that failed to import.
invalid_files: Vec<String>,
/// Imported footage, taken via `get_imported_footage`.
imported_footage: Vec<NodeRef>,
/// Optional image-sequence confirmation callback.
image_sequence_confirm: Option<ImageSequenceConfirmFn>,
/// Total number of files to import (directories counted recursively).
file_count: usize,
/// Image-sequence files already decided not to be imported as sequences.
image_sequence_ignore_files: Vec<String>,
}
impl ProjectImportTask {
/// Create an import task for the given folder/project and filenames.
/// The old `folder: CHandle` / `project: CHandle` signature is replaced
/// by the domain [`NodeRef`] folder and [`ProjectRef`] project
/// (single-lib unification).
pub fn new(
base: Task,
folder: NodeRef,
project: ProjectRef,
filenames: Vec<String>,
image_sequence_confirm: Option<ImageSequenceConfirmFn>,
file_count: usize,
) -> ProjectImportTask {
ProjectImportTask {
base,
folder,
project,
filenames,
command: None,
invalid_files: Vec::new(),
imported_footage: Vec::new(),
image_sequence_confirm,
file_count,
image_sequence_ignore_files: Vec::new(),
}
}
/// Take ownership of the produced undo command; `Err(Error::State)` if
/// the task has not run yet.
pub fn take_command(&mut self) -> Result<UndoCommand> {
self.command.take().ok_or(Error::State)
}
/// Number of files that failed to import.
pub fn get_invalid_file_count(&self) -> usize {
self.invalid_files.len()
}
/// Whether any file failed to import.
pub fn has_invalid_files(&self) -> bool {
!self.invalid_files.is_empty()
}
/// The imported footage at `index` as a domain [`NodeRef`];
/// `Err(Error::NotFound)` if out of range. (The deleted C ABI handed
/// out addref'd handles; the domain reference is a plain clone.)
pub fn get_imported_footage(&self, index: usize) -> Result<NodeRef> {
self.imported_footage.get(index).cloned().ok_or(Error::NotFound)
}
/// Total number of imported footage entries.
pub fn get_file_count(&self) -> usize {
self.imported_footage.len()
}
/// Set the image-sequence confirmation callback.
pub fn set_image_sequence_confirm_callback(&mut self, cb: ImageSequenceConfirmFn) {
self.image_sequence_confirm = Some(cb);
}
/// The number of files counted at construction (progress denominator).
pub fn file_count(&self) -> usize {
self.file_count
}
fn import(
&mut self,
task: &mut Task,
folder: NodeRef,
entries: &mut Vec<String>,
counter: &mut usize,
parent_command: &mut UndoCommand,
) {
let atom = task.get_cancel_atom();
let mut i = 0;
while i < entries.len() {
if task.is_cancelled() {
break;
}
let file_path = &entries[i];
if Path::new(file_path).is_dir() {
// Create a folder corresponding to the directory and recurse.
let entry_list: Vec<String> = match std::fs::read_dir(file_path) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.map(|e| e.path().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
};
if !entry_list.is_empty() {
if let Some(folder_id) = nodeops::folder_create(&self.project) {
nodeops::set_node_label(&self.project, folder_id, &basename_of(file_path));
self.add_item_to_folder(
folder.clone(),
(self.project.clone(), folder_id),
parent_command,
);
let mut sub_entries = entry_list;
self.import(
task,
(self.project.clone(), folder_id),
&mut sub_entries,
counter,
parent_command,
);
}
}
} else {
let Some(footage) = nodeops::footage_create(&self.project, None) else {
i += 1;
continue;
};
// Mirror the C++ cancel-atom dance around the probe: the
// footage behavior's own cancellation flag tracks the
// task's atom during the probe and is cleared afterwards.
nodeops::footage_set_cancelled(&self.project, footage, true);
nodeops::footage_set_cancelled(&self.project, footage, atom.is_cancelled());
let ok = nodeops::footage_set_filename(&self.project, footage, file_path);
nodeops::footage_set_cancelled(&self.project, footage, false);
if ok && nodeops::footage_is_valid(&self.project, footage) {
nodeops::set_node_label(&self.project, footage, &basename_of(file_path));
// See if this footage is an image sequence.
self.validate_image_sequence(
task,
(self.project.clone(), footage),
entries,
i,
);
// Create the undoable command that adds the item.
self.add_item_to_folder(
folder.clone(),
(self.project.clone(), footage),
parent_command,
);
self.imported_footage.push((self.project.clone(), footage));
} else {
self.invalid_files.push(file_path.clone());
// Remove the invalid footage from the graph; the remove
// command takes ownership on redo and deletes the node
// when the command is destroyed.
let mut remove =
nodeops::remove_node_command(self.project.clone(), footage);
remove.redo_now();
}
*counter += 1;
task.emit_progress(*counter as f64 / self.file_count.max(1) as f64);
}
i += 1;
}
}
fn add_item_to_folder(&self, folder: NodeRef, item: NodeRef, command: &mut UndoCommand) {
let child = nodeops::folder_add_child_command(folder, item);
command.multi_add_child(child);
}
fn validate_image_sequence(
&mut self,
task: &mut Task,
footage: NodeRef,
info_list: &mut Vec<String>,
index: usize,
) {
let filename = nodeops::footage_filename(&footage.0, footage.1);
if filename.is_empty() {
return;
}
// Direct oakcodec calls (single-lib unification).
let digit_count = oak_codec::decoder::get_image_sequence_digit_count(&filename);
if digit_count <= 0 {
return;
}
if self
.image_sequence_ignore_files
.iter()
.any(|f| f == &filename)
{
return;
}
if !self.item_is_still_image_footage_only(&footage) {
return;
}
let Some(mut video_stream) = nodeops::footage_video_params(&footage.0, footage.1, 0)
else {
return;
};
let width = video_stream.width();
let height = video_stream.height();
// Direct oakcodec call (single-lib unification).
let seq_index = oak_codec::decoder::get_image_sequence_index(&filename);
let prev_fn = transform_sequence_filename(&filename, seq_index - 1, digit_count);
let next_fn = transform_sequence_filename(&filename, seq_index + 1, digit_count);
let previous_file = nodeops::footage_create(&self.project, Some(&prev_fn));
let next_file = nodeops::footage_create(&self.project, Some(&next_fn));
let prev_matches = previous_file.is_some_and(|f| {
nodeops::footage_set_filename(&self.project, f, &prev_fn)
&& self.compare_still_image_size(&(self.project.clone(), f), width, height)
});
let next_matches = next_file.is_some_and(|f| {
nodeops::footage_set_filename(&self.project, f, &next_fn)
&& self.compare_still_image_size(&(self.project.clone(), f), width, height)
});
if prev_matches || next_matches {
// Ask the user whether this is really a sequence (default: no).
let is_sequence = match self.image_sequence_confirm.as_mut() {
Some(cb) => cb(&filename, &filename),
None => false,
};
let start_index = get_image_sequence_limit(&filename, seq_index, false, digit_count);
let end_index = get_image_sequence_limit(&filename, seq_index, true, digit_count);
for j in start_index..=end_index {
let entry_fn = transform_sequence_filename(&filename, j, digit_count);
if is_sequence {
// Remove later entries of this sequence from the import
// list (they are imported as one footage). The C++
// `info_list.size()` is re-evaluated after each erase.
let mut k = index + 1;
while k < info_list.len() {
if info_list[k] == entry_fn {
info_list.remove(k);
break;
}
k += 1;
}
} else {
self.image_sequence_ignore_files.push(entry_fn);
}
}
if is_sequence {
video_stream.set_video_type(VideoType::ImageSequence);
// Direct config read (single-lib unification).
if let Ok(rate) = ConfigStore::instance().get(None, "DefaultSequenceFrameRate") {
if let Some((num, den)) = parse_rational(&rate) {
if den != 0 {
video_stream.set_time_base(num, den);
video_stream.set_frame_rate(den, num);
}
}
}
video_stream.set_start_time(start_index);
video_stream.set_duration(end_index - start_index + 1);
nodeops::footage_set_video_params(&footage.0, footage.1, 0, &video_stream);
}
}
// The probe footage was only created for comparison; remove it.
for probe in [previous_file, next_file].into_iter().flatten() {
let mut remove = nodeops::remove_node_command(self.project.clone(), probe);
remove.redo_now();
}
let _ = task;
}
fn item_is_still_image_footage_only(&self, footage: &NodeRef) -> bool {
// The oaknode domain footage records probed streams; a single
// stream is the closest domain equivalent of the C++
// `total_stream_count == 1` check. The oaknode video params carry
// no `video_type`, so the `kVideoTypeStill` check collapses to
// "one stream with valid dimensions".
if nodeops::footage_total_stream_count(&footage.0, footage.1) != 1 {
return false;
}
let Some(vp) = nodeops::footage_video_params(&footage.0, footage.1, 0) else {
return false;
};
vp.is_valid() && vp.video_type() == VideoType::Video
}
fn compare_still_image_size(&self, footage: &NodeRef, width: i32, height: i32) -> bool {
if !self.item_is_still_image_footage_only(footage) {
return false;
}
let Some(stream) = nodeops::footage_video_params(&footage.0, footage.1, 0) else {
return false;
};
stream.width() == width && stream.height() == height
}
}
impl TaskBehavior for ProjectImportTask {
/// Import each filename via the direct oaknode domain operations
/// ([`crate::nodeops`]), collecting an undo command, imported footage,
/// and per-file failures.
fn run(&mut self, task: &mut Task) -> Result<()> {
let mut command = UndoCommand::multi();
let mut counter = 0;
let mut entries = self.filenames.clone();
let folder = (self.folder.0.clone(), self.folder.1);
self.import(task, folder, &mut entries, &mut counter, &mut command);
if task.is_cancelled() {
self.command = None;
return Err(Error::Cancelled);
}
self.command = Some(command);
Ok(())
}
}
/// Count files recursively (directories recurse; anything else counts 1),
/// mirroring `count_files_recursive` in import.cpp.
fn basename_of(path: &str) -> String {
Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Substitute `number` into the trailing-digit field of an image-sequence
/// filename (mirrors `oakcodec_decoder_transform_image_sequence_file_name`).
fn transform_sequence_filename(filename: &str, number: i64, digit_count: i32) -> String {
let width = digit_count.max(0) as usize;
match filename.rfind('.') {
Some(dot) => {
// The digits are the `width` chars immediately before the
// extension; they are replaced by `number`.
let digits_start = dot.saturating_sub(width);
let prefix = &filename[..digits_start];
format!(
"{prefix}{:0width$}{}",
number,
&filename[dot..],
width = width
)
}
None => format!("{filename}{:0width$}", number, width = width),
}
}
/// Walk the index from `start` in the given direction while the transformed
/// filename exists (mirrors `get_image_sequence_limit` in import.cpp).
fn get_image_sequence_limit(start_fn: &str, start: i64, up: bool, digit_count: i32) -> i64 {
let mut current = start;
loop {
let test_index = if up { current + 1 } else { current - 1 };
let test_filename = transform_sequence_filename(start_fn, test_index, digit_count);
if !Path::new(&test_filename).exists() {
break;
}
current = test_index;
}
current
}
/// Parse a "num/den" frame-rate string.
fn parse_rational(s: &str) -> Option<(i32, i32)> {
let mut parts = s.trim().split('/');
let num: i32 = parts.next()?.trim().parse().ok()?;
let den: i32 = parts.next()?.trim().parse().ok()?;
Some((num, den))
}