From 6ded1d2d63ee651a1cf5160d33a6aef0f9990540 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 7 Aug 2026 14:01:23 +0800 Subject: [PATCH] refactor(task): de-Qt oaktask base + project tasks, wire codec task submitter - Task base: Qt signals become lifecycle listeners (the async-command callback exception), cancellation uses oakrender's OakCancelAtom C handle; TaskManager runs std::thread workers, no signals - ConformTask/ProxyTask implement oakcodec's submit-callback contract (synchronous interim); register_codec_task_submitter() closes the conform/proxy loop - conform of demo.mp4 produces pcm caches in tests - ProjectImportTask/ProjectLoadTask/ProjectSaveTask over the oaknode C ABI; image-sequence confirmation becomes a facade callback (default: not a sequence) - C API additions needed by oaktask: oaknode serializer file-level save/load (auto-initializing), footage video-params/cancel-atom/ as-node, folder add-child command factory; oakcodec decoder conform_audio + image-sequence helpers; oakrender cancelatom get_native - fix double-ownership: submitting a task to the manager transfers ownership, freeing its handle only releases the wrapper - C ABI in include/task (task/manager/project, OakTaskTask opaque handle + lifecycle subscribe), every function tested (103 cases in build-oaktask incl. regressions) - RenderTask family and OTIO tasks follow in separate commits --- include/codec/decoder.h | 33 ++ include/node/folder.h | 14 + include/node/footage.h | 41 ++ include/node/serializer.h | 41 ++ include/render/cancelatom.h | 16 +- include/task/error.h | 40 ++ include/task/manager.h | 54 +++ include/task/project.h | 85 ++++ include/task/task.h | 97 ++++ src/CMakeLists.txt | 1 + src/codec/c_api/decoder.cpp | 69 +++ src/node/c_api/folder.cpp | 29 ++ src/node/c_api/footage.cpp | 78 ++++ src/node/c_api/serializer.cpp | 75 ++++ src/render/c_api/cancelatom.cpp | 8 + src/task/CMakeLists.txt | 6 + src/task/c_api/CMakeLists.txt | 5 + src/task/c_api/manager.cpp | 73 +++ src/task/c_api/project.cpp | 180 ++++++++ src/task/c_api/task.cpp | 161 +++++++ src/task/c_api/taskhandle.h | 118 +++++ src/task/src/CMakeLists.txt | 37 ++ src/task/src/codecbridge.cpp | 98 +++++ src/task/src/codecbridge.h | 45 ++ src/task/src/conform/conform.cpp | 155 +++++++ src/task/src/conform/conform.h | 68 +++ src/task/src/customcache/customcachetask.cpp | 60 +++ src/task/src/customcache/customcachetask.h | 66 +++ src/task/src/export/export.cpp.pending | 313 +++++++++++++ src/task/src/export/export.h.pending | 86 ++++ .../src/precache/precachetask.cpp.pending | 113 +++++ src/task/src/precache/precachetask.h.pending | 56 +++ src/task/src/project/import/import.cpp | 416 ++++++++++++++++++ src/task/src/project/import/import.h | 128 ++++++ src/task/src/project/load/load.cpp | 100 +++++ src/task/src/project/load/load.h | 73 +++ .../src/project/load/loadbasetask.cpp.pending | 34 ++ .../src/project/load/loadbasetask.h.pending | 63 +++ .../src/project/loadotio/loadotio.cpp.pending | 377 ++++++++++++++++ .../src/project/loadotio/loadotio.h.pending | 47 ++ src/task/src/project/save/save.cpp | 98 +++++ src/task/src/project/save/save.h | 60 +++ .../src/project/saveotio/saveotio.cpp.pending | 278 ++++++++++++ .../src/project/saveotio/saveotio.h.pending | 61 +++ src/task/src/proxy/proxy.cpp | 310 +++++++++++++ src/task/src/proxy/proxy.h | 75 ++++ src/task/src/render/render.cpp.pending | 352 +++++++++++++++ src/task/src/render/render.h.pending | 154 +++++++ src/task/src/task.h | 233 ++++++++++ src/task/src/taskmanager.cpp | 146 ++++++ src/task/src/taskmanager.h | 97 ++++ src/task/standalone/CMakeLists.txt | 177 ++++++++ src/task/tests/CMakeLists.txt | 22 + src/task/tests/task_test.cpp | 316 +++++++++++++ 54 files changed, 5937 insertions(+), 1 deletion(-) create mode 100644 include/task/error.h create mode 100644 include/task/manager.h create mode 100644 include/task/project.h create mode 100644 include/task/task.h create mode 100644 src/task/CMakeLists.txt create mode 100644 src/task/c_api/CMakeLists.txt create mode 100644 src/task/c_api/manager.cpp create mode 100644 src/task/c_api/project.cpp create mode 100644 src/task/c_api/task.cpp create mode 100644 src/task/c_api/taskhandle.h create mode 100644 src/task/src/CMakeLists.txt create mode 100644 src/task/src/codecbridge.cpp create mode 100644 src/task/src/codecbridge.h create mode 100644 src/task/src/conform/conform.cpp create mode 100644 src/task/src/conform/conform.h create mode 100644 src/task/src/customcache/customcachetask.cpp create mode 100644 src/task/src/customcache/customcachetask.h create mode 100644 src/task/src/export/export.cpp.pending create mode 100644 src/task/src/export/export.h.pending create mode 100644 src/task/src/precache/precachetask.cpp.pending create mode 100644 src/task/src/precache/precachetask.h.pending create mode 100644 src/task/src/project/import/import.cpp create mode 100644 src/task/src/project/import/import.h create mode 100644 src/task/src/project/load/load.cpp create mode 100644 src/task/src/project/load/load.h create mode 100644 src/task/src/project/load/loadbasetask.cpp.pending create mode 100644 src/task/src/project/load/loadbasetask.h.pending create mode 100644 src/task/src/project/loadotio/loadotio.cpp.pending create mode 100644 src/task/src/project/loadotio/loadotio.h.pending create mode 100644 src/task/src/project/save/save.cpp create mode 100644 src/task/src/project/save/save.h create mode 100644 src/task/src/project/saveotio/saveotio.cpp.pending create mode 100644 src/task/src/project/saveotio/saveotio.h.pending create mode 100644 src/task/src/proxy/proxy.cpp create mode 100644 src/task/src/proxy/proxy.h create mode 100644 src/task/src/render/render.cpp.pending create mode 100644 src/task/src/render/render.h.pending create mode 100644 src/task/src/task.h create mode 100644 src/task/src/taskmanager.cpp create mode 100644 src/task/src/taskmanager.h create mode 100644 src/task/standalone/CMakeLists.txt create mode 100644 src/task/tests/CMakeLists.txt create mode 100644 src/task/tests/task_test.cpp diff --git a/include/codec/decoder.h b/include/codec/decoder.h index 59e4a54c4..81b10a105 100644 --- a/include/codec/decoder.h +++ b/include/codec/decoder.h @@ -25,6 +25,7 @@ #include "error.h" #include "frame.h" +#include "render/cancelatom.h" #ifdef __cplusplus extern "C" { @@ -199,6 +200,38 @@ OAKCODEC_API int oakcodec_decoder_decode_audio(OakDecoder decoder, int in_num, i uint64_t channel_layout, float *buf, int buf_frames); +/** + * @brief Conform the open stream's audio into per-channel pcm cache files + * (Decoder::conform_audio()). + * + * `output_filenames` is an array of `filename_count` final per-channel + * paths. `sample_format` is olive::core::SampleFormat::Format as int. + * `cancelled` may be an empty OakCancelAtom (ctx == NULL). + * + * @return OAKCODEC_OK on success, OAKCODEC_E_STATE when no stream is + * open, OAKCODEC_E_CANCELLED when cancelled, OAKCODEC_E_FAILED + * otherwise. + */ +OAKCODEC_API int oakcodec_decoder_conform_audio(OakDecoder decoder, + const char *const *output_filenames, int filename_count, + int sample_rate, uint64_t channel_layout, int sample_format, + OakCancelAtom cancelled); + +/** + * @brief Image-sequence filename heuristics (Decoder::get_image_sequence_*). + * + * digit_count: number of trailing digits in the filename stem (0 = not an + * image sequence filename). index: the numeric value of those digits (-1 + * when none). transform: substitute `number` into the digit field, + * two-stage string getter. + */ +OAKCODEC_API int oakcodec_decoder_get_image_sequence_digit_count( + const char *filename); +OAKCODEC_API int64_t oakcodec_decoder_get_image_sequence_index( + const char *filename); +OAKCODEC_API int oakcodec_decoder_transform_image_sequence_file_name( + const char *filename, int64_t number, char *buf, int buf_size); + /** * @brief Human-readable detail of the last error on this decoder * (buf/size string getter convention). diff --git a/include/node/folder.h b/include/node/folder.h index c10e4705b..de9d589c6 100644 --- a/include/node/folder.h +++ b/include/node/folder.h @@ -22,6 +22,7 @@ #define OAK_EDITOR_NODE_FOLDER_H #include "node/error.h" +#include "undo/undocommand.h" #include "node/project.h" #ifdef __cplusplus @@ -73,6 +74,19 @@ OakNodeNode *oaknode_folder_child_at(const OakNodeFolder *folder, int index); */ int oaknode_folder_add_child(OakNodeFolder *folder, OakNodeNode *child); +/** + * @brief Borrowed cast from a folder handle to its node handle. + * NULL for NULL. + */ +OakNodeNode *oaknode_folder_as_node(OakNodeFolder *folder); + +/** + * @brief Create an undoable FolderAddChild command. Owned; free with + * oakundo_command_free(). + */ +OakUndoCommand *oaknode_command_create_folder_add_child( + OakNodeFolder *folder, OakNodeNode *child); + /** * @brief Remove `child` from `folder` without deleting it (live, * non-undoable; executes Folder::RemoveElementCommand::redo()). diff --git a/include/node/footage.h b/include/node/footage.h index 5e11b66d1..b570a89f6 100644 --- a/include/node/footage.h +++ b/include/node/footage.h @@ -23,7 +23,12 @@ #include +#include "common/videoparams.h" #include "node/error.h" +// NOTE: quoted-relative to bypass the "render/cancelatom.h" transition +// bridge (oakrender's C++ olive::CancelAtom) that shadows the C ABI +// header on oaknode's include path. +#include "../../include/render/cancelatom.h" #include "node/project.h" #ifdef __cplusplus @@ -60,6 +65,12 @@ typedef struct OakNodeFootage OakNodeFootage; OakNodeFootage *oaknode_footage_create(OakNodeProject *project, const char *filename); +/** + * @brief Borrowed cast from a footage handle to its node handle. + * NULL for NULL. + */ +OakNodeNode *oaknode_footage_as_node(OakNodeFootage *footage); + /** * @brief Current media path (Footage::filename()). Two-stage string getter. * @@ -192,6 +203,36 @@ int oaknode_footage_set_proxy(OakNodeFootage *footage, const char *path, */ int oaknode_footage_clear_proxy(OakNodeFootage *footage); +/** + * @brief Video stream parameters as an oakcommon video-params handle + * (ViewerOutput::get_video_params()). `out` receives a handle with + * reference count 1 (release with oakcommon_videoparams_free()). + * OAKNODE_E_NOT_FOUND for an out-of-range index. + */ +int oaknode_footage_get_video_params(OakNodeFootage *footage, int index, + OakVideoParams *out); + +/** + * @brief Set a video stream's parameters from an oakcommon handle + * (ViewerOutput::set_video_params()). + */ +int oaknode_footage_set_video_params(OakNodeFootage *footage, int index, + const OakVideoParams *params); + +/** + * @brief Video length as a rational pair (ViewerOutput::get_video_length()). + */ +int oaknode_footage_get_video_length(OakNodeFootage *footage, + int64_t *out_num, int64_t *out_den); + +/** + * @brief Set the footage's cancellation atom used during probing + * (Footage::set_cancel_pointer()). `atom` may be an empty OakCancelAtom + * (ctx == NULL) to clear. + */ +int oaknode_footage_set_cancel_atom(OakNodeFootage *footage, + OakCancelAtom atom); + #ifdef __cplusplus } #endif diff --git a/include/node/serializer.h b/include/node/serializer.h index f64cbff2f..09e1e911d 100644 --- a/include/node/serializer.h +++ b/include/node/serializer.h @@ -242,3 +242,44 @@ int oaknode_serializer_loaddata_connection_at( #endif #endif //OAK_EDITOR_NODE_SERIALIZER_H + +/** + * @brief Result codes for file-level save/load (mirror + * ProjectSerializer::ResultCode; pinned by test). + */ +enum OakNodeSerializerResultCode { + OAKNODE_SERIALIZER_RESULT_SUCCESS = 0, + OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_OLD = 1, + OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_NEW = 2, + OAKNODE_SERIALIZER_RESULT_UNKNOWN_VERSION = 3, + OAKNODE_SERIALIZER_RESULT_FILE_ERROR = 4, + OAKNODE_SERIALIZER_RESULT_XML_ERROR = 5, + OAKNODE_SERIALIZER_RESULT_OVERWRITE_ERROR = 6, + OAKNODE_SERIALIZER_RESULT_NO_DATA = 7 +}; + +/** + * @brief Save a project to a file (ProjectSerializer::save(), project + * type, optional OVEC compression). Layout data is not serialized + * through this API (app-layer concern, see oakstorage/M10). + * + * @param out_code Receives an OakNodeSerializerResultCode (may be NULL). + * @param details Optional two-stage buffer for the result details + * string (e.g. the fallback filename on overwrite errors). + * @return OAKNODE_OK when the result code is + * OAKNODE_SERIALIZER_RESULT_SUCCESS, OAKNODE_E_FAILED otherwise + * (details in out_code/details), OAKNODE_E_INVALID for NULL args. + */ +int oaknode_serializer_save_to_file(OakNodeProject *project, + const char *filename, int use_compression, int *out_code, + char *details, int details_size); + +/** + * @brief Load a project from a file into `project` + * (ProjectSerializer::load(), project type). + * + * Same return/out-param convention as oaknode_serializer_save_to_file(). + */ +int oaknode_serializer_load_from_file(OakNodeProject *project, + const char *filename, int *out_code, char *details, + int details_size); diff --git a/include/render/cancelatom.h b/include/render/cancelatom.h index bc1dc72df..f7ae42604 100644 --- a/include/render/cancelatom.h +++ b/include/render/cancelatom.h @@ -29,6 +29,14 @@ extern "C" { #endif +#ifdef __cplusplus +} /* extern "C" */ + +namespace olive { class CancelAtom; } + +extern "C" { +#endif + /** * @brief Current ABI version stamped into every oakrender handle. * @@ -108,8 +116,14 @@ int oakrender_cancelatom_is_cancelled(OakCancelAtom atom, int *cancelled); * NULL out parameter. */ int oakrender_cancelatom_heard_cancel(OakCancelAtom atom, int *heard); - #ifdef __cplusplus + +/** + * @brief Borrowed access to the underlying C++ atom (C++ only, for + * adapter layers). Valid while the handle is held. NULL-safe. + */ +olive::CancelAtom *oakrender_cancelatom_get_native(OakCancelAtom atom); + } #endif diff --git a/include/task/error.h b/include/task/error.h new file mode 100644 index 000000000..a91af1e56 --- /dev/null +++ b/include/task/error.h @@ -0,0 +1,40 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_EDITOR_TASK_ERROR_H +#define OAK_EDITOR_TASK_ERROR_H + +/** + * @brief Status and error codes shared by all oaktask C API families. + * + * Return-code convention (mirrors engine/include/oakengine/init.h): + * 0 (OAKTASK_OK) on success, a negative OAKTASK_E_* error code on + * failure. String getters return the required buffer size in bytes + * (including the terminating NUL) as a non-negative value instead. + */ +#define OAKTASK_OK 0 /**< Success. */ +#define OAKTASK_E_INVALID (-1) /**< NULL handle or invalid argument. */ +#define OAKTASK_E_STATE (-2) /**< Call not valid in the current state. */ +#define OAKTASK_E_FAILED (-3) /**< The underlying operation failed. */ +#define OAKTASK_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */ +#define OAKTASK_E_NOMEM (-5) /**< Allocation failed. */ +#define OAKTASK_E_CANCELLED (-6) /**< The operation was cancelled. */ + +#endif //OAK_EDITOR_TASK_ERROR_H diff --git a/include/task/manager.h b/include/task/manager.h new file mode 100644 index 000000000..9573508a3 --- /dev/null +++ b/include/task/manager.h @@ -0,0 +1,54 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_EDITOR_TASK_MANAGER_H +#define OAK_EDITOR_TASK_MANAGER_H + +#include "task/task.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Task manager singleton lifecycle. + */ +int oaktask_manager_init(void); +void oaktask_manager_shutdown(void); + +/** + * @brief Register oaktask as oakcodec's background task submitter + * (olive::register_codec_task_submitter()). Called by + * oaktask_manager_init(); exposed for manual control. + */ +int oaktask_register_codec_submitter(void); + +int oaktask_manager_count(void); + +/** @brief Borrowed task at index, or NULL when out of range. */ +OakTaskTask *oaktask_manager_at(int i); + +void oaktask_manager_delete_finished(void); + +#ifdef __cplusplus +} +#endif + +#endif //OAK_EDITOR_TASK_MANAGER_H diff --git a/include/task/project.h b/include/task/project.h new file mode 100644 index 000000000..6993c56d6 --- /dev/null +++ b/include/task/project.h @@ -0,0 +1,85 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_EDITOR_TASK_PROJECT_H +#define OAK_EDITOR_TASK_PROJECT_H + +#include "node/footage.h" +#include "node/node.h" +#include "node/project.h" +#include "task/task.h" +#include "undo/undocommand.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Project task factories and result accessors (M8 §2.2). + */ + +/** @brief olive::ProjectLoadTask. */ +OakTaskTask *oaktask_create_project_load(const char *filename); + +/** @brief Take the loaded project (ownership transfer). */ +OakNodeProject *oaktask_load_take_project(OakTaskTask *t); + +/** @brief olive::ProjectSaveTask. `filename_or_NULL` overrides the + * project's own filename. */ +OakTaskTask *oaktask_create_project_save(OakNodeProject *project, + const char *filename_or_NULL, + int use_compression); + +/** @brief olive::ProjectImportTask. */ +OakTaskTask *oaktask_create_project_import(OakNodeFolder *folder, + OakNodeProject *project, + const char *const *urls, + int url_count); + +/** @brief Take the import's undo command (ownership transfer). */ +OakUndoCommand *oaktask_import_take_command(OakTaskTask *t); + +int oaktask_import_footage_count(OakTaskTask *t); + +/** @brief Borrowed footage handle at index, NULL when out of range. */ +OakNodeFootage *oaktask_import_footage_at(OakTaskTask *t, int index); + +int oaktask_import_invalid_count(OakTaskTask *t); + +/** @brief Invalid filename at index (two-stage). */ +int oaktask_import_invalid_at(OakTaskTask *t, int index, char *buf, + int buf_size); + +/** + * @brief Image-sequence confirmation callback (facade/UI concern; + * olive::ProjectImportTask::set_image_sequence_confirm_callback). + * Return non-zero to treat numbered stills as a sequence. + * Default (no callback): not a sequence. + */ +typedef int (*oaktask_image_sequence_confirm_fn)(const char *filename, + void *userdata); +void oaktask_import_set_image_sequence_confirm_cb( + oaktask_image_sequence_confirm_fn fn, void *userdata); + +#ifdef __cplusplus +} +#endif + +#endif //OAK_EDITOR_TASK_PROJECT_H diff --git a/include/task/task.h b/include/task/task.h new file mode 100644 index 000000000..051d43c87 --- /dev/null +++ b/include/task/task.h @@ -0,0 +1,97 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_EDITOR_TASK_TASK_H +#define OAK_EDITOR_TASK_TASK_H + +#include + +#include "task/error.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Opaque handle to a background task (olive::Task). + * + * Tasks are created through the factories in task/project.h (and future + * family headers) and must be released with oaktask_task_free(). + */ +typedef struct OakTaskTask OakTaskTask; + +/** @brief Lifecycle event ids for oaktask_task_subscribe(). */ +enum OakTaskEvent { + OAKTASK_EVENT_STARTED = 0, + OAKTASK_EVENT_PROGRESS = 1, + OAKTASK_EVENT_FINISHED = 2 +}; + +/** + * @brief Event callback (async command return channel, 01 §4 exception). + * + * For OAKTASK_EVENT_FINISHED, `value` is 1.0 on success / 0.0 on failure; + * for OAKTASK_EVENT_PROGRESS it is 0..1; for OAKTASK_EVENT_STARTED it is + * the start time in milliseconds. + */ +typedef void (*oaktask_event_fn)(int event_id, double value, + void *userdata); + +/** + * @brief Free a task. No-op on NULL. The task must not be running on the + * manager (oaktask_task_cancel + wait first if it is). + */ +void oaktask_task_free(OakTaskTask *t); + +/** @brief Run synchronously in the calling thread. 1 = succeeded. */ +int oaktask_task_start_sync(OakTaskTask *t); + +/** @brief Run asynchronously on the task manager. */ +int oaktask_task_start(OakTaskTask *t); + +int oaktask_task_cancel(OakTaskTask *t); + +/** @brief Wait for an asynchronously started task. */ +int oaktask_task_wait(OakTaskTask *t); + +int oaktask_task_is_finished(const OakTaskTask *t); + +int oaktask_task_succeeded(const OakTaskTask *t); + +/** @brief Two-stage string getters. */ +int oaktask_task_title(OakTaskTask *t, char *buf, int buf_size); +int oaktask_task_error(OakTaskTask *t, char *buf, int buf_size); + +/** + * @brief Subscribe to lifecycle events (returns a subscription id >= 0, + * or a negative error code). One-shot per event stream: the + * subscription is dropped after OAKTASK_EVENT_FINISHED. + */ +int64_t oaktask_task_subscribe(OakTaskTask *t, oaktask_event_fn fn, + void *userdata); + +/** @brief Alive-count for leak assertions in tests. */ +int oaktask_debug_alive_count(void); + +#ifdef __cplusplus +} +#endif + +#endif //OAK_EDITOR_TASK_TASK_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 53c3080f8..7a04522d3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,3 +5,4 @@ add_subdirectory(render) add_subdirectory(codec) add_subdirectory(audio) add_subdirectory(timeline) +add_subdirectory(task) diff --git a/src/codec/c_api/decoder.cpp b/src/codec/c_api/decoder.cpp index a4c1064c6..eb7739201 100644 --- a/src/codec/c_api/decoder.cpp +++ b/src/codec/c_api/decoder.cpp @@ -27,6 +27,7 @@ #include #include "common/loopmode.h" +#include "render/cancelatom.h" #include "decoder.h" #include "footagedescription.h" #include "frame.h" @@ -386,3 +387,71 @@ int oakcodec_decoder_last_error(OakDecoder decoder, char *buf, int buf_size) return string_out("", buf, buf_size); return string_out(b->last_error, buf, buf_size); } + +int oakcodec_decoder_conform_audio(OakDecoder decoder, + const char *const *output_filenames, int filename_count, + int sample_rate, uint64_t channel_layout, int sample_format, + OakCancelAtom cancelled) +{ + DecoderBox *b = decoder_box(decoder.ctx); + if (!b || (!output_filenames && filename_count > 0) || + filename_count < 0) + return OAKCODEC_E_INVALID; + if (!b->open || !b->decoder) + return OAKCODEC_E_STATE; + + try { + std::vector filenames; + filenames.reserve(size_t(filename_count)); + for (int i = 0; i < filename_count; i++) { + if (!output_filenames[i]) + return OAKCODEC_E_INVALID; + filenames.emplace_back(output_filenames[i]); + } + + olive::AudioParams params( + sample_rate, channel_layout, + static_cast(sample_format)); + + bool ret = b->decoder->conform_audio(filenames, params, + cancelled.ctx ? &cancelled + : nullptr); + if (!ret) { + int heard = 0; + if (cancelled.ctx && + oakrender_cancelatom_heard_cancel(cancelled, &heard) == + OAKRENDER_OK && + heard) { + return OAKCODEC_E_CANCELLED; + } + return OAKCODEC_E_FAILED; + } + return OAKCODEC_OK; + } catch (...) { + return OAKCODEC_E_FAILED; + } +} + +int oakcodec_decoder_get_image_sequence_digit_count(const char *filename) +{ + if (!filename) + return OAKCODEC_E_INVALID; + return olive::Decoder::get_image_sequence_digit_count(filename); +} + +int64_t oakcodec_decoder_get_image_sequence_index(const char *filename) +{ + if (!filename) + return OAKCODEC_E_INVALID; + return olive::Decoder::get_image_sequence_index(filename); +} + +int oakcodec_decoder_transform_image_sequence_file_name( + const char *filename, int64_t number, char *buf, int buf_size) +{ + if (!filename) + return OAKCODEC_E_INVALID; + return string_out( + olive::Decoder::transform_image_sequence_file_name(filename, number), + buf, buf_size); +} diff --git a/src/node/c_api/folder.cpp b/src/node/c_api/folder.cpp index ed1c89728..07c8d4ef6 100644 --- a/src/node/c_api/folder.cpp +++ b/src/node/c_api/folder.cpp @@ -20,6 +20,8 @@ #include "node/folder.h" +#include "../../undo/c_api/commandhandle.h" + #include #include "../src/project.h" @@ -230,3 +232,30 @@ OakNodeFolder *oaknode_folder_parent_of(const OakNodeNode *node) return NULL; } } + +OakNodeNode *oaknode_folder_as_node(OakNodeFolder *folder) +{ + return reinterpret_cast(folder); +} + +OakUndoCommand *oaknode_command_create_folder_add_child( + OakNodeFolder *folder, OakNodeNode *child) +{ + if (!folder || !child) { + return NULL; + } + + try { + OakUndoCommand *handle = + new (std::nothrow) OakUndoCommand{ nullptr, true }; + if (!handle) { + return NULL; + } + handle->command = new olive::FolderAddChild( + reinterpret_cast(folder), + reinterpret_cast(child)); + return handle; + } catch (...) { + return NULL; + } +} diff --git a/src/node/c_api/footage.cpp b/src/node/c_api/footage.cpp index 220d65df9..184eb4518 100644 --- a/src/node/c_api/footage.cpp +++ b/src/node/c_api/footage.cpp @@ -20,6 +20,9 @@ #include "node/footage.h" +#include "common/videoparams.h" +#include "../../../include/render/cancelatom.h" + #include #include #include @@ -320,3 +323,78 @@ int oaknode_footage_clear_proxy(OakNodeFootage *footage) return OAKNODE_E_FAILED; } } + +int oaknode_footage_get_video_params(OakNodeFootage *footage, int index, + OakVideoParams *out) +{ + olive::Footage *f = to_cpp(footage); + if (!f || !out || index < 0) { + return OAKNODE_E_INVALID; + } + + try { + olive::VideoParams vp = f->get_video_params(index); + if (!vp.is_valid() && index >= f->input_array_size( + olive::ViewerOutput::k_video_params_input)) { + return OAKNODE_E_NOT_FOUND; + } + *out = oakcommon_videoparams_init_from_native(&vp); + return out->ctx ? OAKNODE_OK : OAKNODE_E_NOMEM; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_footage_set_video_params(OakNodeFootage *footage, int index, + const OakVideoParams *params) +{ + olive::Footage *f = to_cpp(footage); + if (!f || !params || !params->ctx) { + return OAKNODE_E_INVALID; + } + + const olive::VideoParams *native = + oakcommon_videoparams_get_native(*params); + if (!native) { + return OAKNODE_E_INVALID; + } + + try { + f->set_video_params(*native, index); + return OAKNODE_OK; + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_footage_get_video_length(OakNodeFootage *footage, + int64_t *out_num, int64_t *out_den) +{ + olive::Footage *f = to_cpp(footage); + if (!f || !out_num || !out_den) { + return OAKNODE_E_INVALID; + } + + olive::core::Rational len = f->get_video_length(); + *out_num = len.numerator(); + *out_den = len.denominator(); + return OAKNODE_OK; +} + +int oaknode_footage_set_cancel_atom(OakNodeFootage *footage, + OakCancelAtom atom) +{ + olive::Footage *f = to_cpp(footage); + if (!f) { + return OAKNODE_E_INVALID; + } + + f->set_cancel_pointer( + atom.ctx ? oakrender_cancelatom_get_native(atom) : nullptr); + return OAKNODE_OK; +} + +OakNodeNode *oaknode_footage_as_node(OakNodeFootage *footage) +{ + return reinterpret_cast(footage); +} diff --git a/src/node/c_api/serializer.cpp b/src/node/c_api/serializer.cpp index fa08e4c7c..03cf8d208 100644 --- a/src/node/c_api/serializer.cpp +++ b/src/node/c_api/serializer.cpp @@ -355,3 +355,78 @@ int oaknode_serializer_loaddata_connection_at( return OAKNODE_E_FAILED; } } + +namespace +{ + +int report_serializer_result(const olive::ProjectSerializer::Result &result, + int *out_code, char *details, int details_size) +{ + if (out_code) { + *out_code = int(result.code()); + } + + int needed = 0; + if (details || details_size > 0) { + std::string d = result.get_details(); + needed = int(d.size()) + 1; + if (details && details_size >= needed) { + memcpy(details, d.c_str(), needed); + } + } + + if (result.code() == olive::ProjectSerializer::k_success) { + return OAKNODE_OK; + } + return (details && details_size > 0) ? needed : OAKNODE_E_FAILED; +} + +} // namespace + +int oaknode_serializer_save_to_file(OakNodeProject *project, + const char *filename, int use_compression, int *out_code, + char *details, int details_size) +{ + if (!project || !filename) { + return OAKNODE_E_INVALID; + } + + try { + oaknode_serializer_initialize(); + + olive::ProjectSerializer::SaveData data( + olive::ProjectSerializer::k_project, + reinterpret_cast(project), filename); + + olive::ProjectSerializer::Result result = + olive::ProjectSerializer::save(data, use_compression != 0); + + return report_serializer_result(result, out_code, details, + details_size); + } catch (...) { + return OAKNODE_E_FAILED; + } +} + +int oaknode_serializer_load_from_file(OakNodeProject *project, + const char *filename, int *out_code, char *details, + int details_size) +{ + if (!project || !filename) { + return OAKNODE_E_INVALID; + } + + try { + oaknode_serializer_initialize(); + + olive::ProjectSerializer::Result result = + olive::ProjectSerializer::load( + reinterpret_cast(project), filename, + olive::ProjectSerializer::k_project); + + return report_serializer_result(result, out_code, details, + details_size); + } catch (...) { + return OAKNODE_E_FAILED; + } +} diff --git a/src/render/c_api/cancelatom.cpp b/src/render/c_api/cancelatom.cpp index 52ede2877..068facb89 100644 --- a/src/render/c_api/cancelatom.cpp +++ b/src/render/c_api/cancelatom.cpp @@ -133,3 +133,11 @@ int oakrender_cancelatom_heard_cancel(OakCancelAtom atom, int *heard) *heard = c->heard_cancel() ? 1 : 0; return OAKRENDER_OK; } + +olive::CancelAtom *oakrender_cancelatom_get_native(OakCancelAtom atom) +{ + if (!atom.ctx) { + return nullptr; + } + return &static_cast(atom.ctx)->impl; +} diff --git a/src/task/CMakeLists.txt b/src/task/CMakeLists.txt new file mode 100644 index 000000000..f41570121 --- /dev/null +++ b/src/task/CMakeLists.txt @@ -0,0 +1,6 @@ +add_subdirectory(src) +add_subdirectory(c_api) + +if(BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/task/c_api/CMakeLists.txt b/src/task/c_api/CMakeLists.txt new file mode 100644 index 000000000..32c0f4c19 --- /dev/null +++ b/src/task/c_api/CMakeLists.txt @@ -0,0 +1,5 @@ +target_sources(oaktask PRIVATE + task.cpp + manager.cpp + project.cpp +) diff --git a/src/task/c_api/manager.cpp b/src/task/c_api/manager.cpp new file mode 100644 index 000000000..e3bdd541e --- /dev/null +++ b/src/task/c_api/manager.cpp @@ -0,0 +1,73 @@ +/*** + + 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 . + +***/ + +#include "task/manager.h" + +#include "../src/codecbridge.h" +#include "../src/taskmanager.h" +#include "taskhandle.h" + +int oaktask_manager_init(void) +{ + if (olive::TaskManager::instance()) { + return OAKTASK_E_STATE; + } + + olive::TaskManager::create_instance(); + olive::register_codec_task_submitter(); + return OAKTASK_OK; +} + +void oaktask_manager_shutdown(void) +{ + olive::unregister_codec_task_submitter(); + olive::TaskManager::destroy_instance(); +} + +int oaktask_register_codec_submitter(void) +{ + olive::register_codec_task_submitter(); + return OAKTASK_OK; +} + +int oaktask_manager_count(void) +{ + if (!olive::TaskManager::instance()) { + return OAKTASK_E_STATE; + } + return olive::TaskManager::instance()->get_task_count(); +} + +OakTaskTask *oaktask_manager_at(int i) +{ + if (!olive::TaskManager::instance()) { + return NULL; + } + olive::Task *task = olive::TaskManager::instance()->get_task_at(i); + // Borrowed wrapper: freeing it does not delete the task. + return oaktask_capi::wrap_borrowed(task); +} + +void oaktask_manager_delete_finished(void) +{ + if (olive::TaskManager::instance()) { + olive::TaskManager::instance()->delete_finished(); + } +} diff --git a/src/task/c_api/project.cpp b/src/task/c_api/project.cpp new file mode 100644 index 000000000..c4effede4 --- /dev/null +++ b/src/task/c_api/project.cpp @@ -0,0 +1,180 @@ +/*** + + 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 . + +***/ + +#include "task/project.h" + +#include + +#include "../src/project/import/import.h" +#include "../src/project/load/load.h" +#include "../src/project/save/save.h" +#include "taskhandle.h" + +using namespace oaktask_capi; + +namespace +{ + +olive::ProjectImportTask *import_impl(OakTaskTask *t) +{ + if (!t) { + return nullptr; + } + return dynamic_cast(impl(t)->task); +} + +olive::ProjectLoadBaseTask *load_impl(OakTaskTask *t) +{ + if (!t) { + return nullptr; + } + return dynamic_cast(impl(t)->task); +} + +} // namespace + +OakTaskTask *oaktask_create_project_load(const char *filename) +{ + if (!filename) { + return NULL; + } + try { + return wrap(new olive::ProjectLoadTask(filename)); + } catch (...) { + return NULL; + } +} + +OakNodeProject *oaktask_load_take_project(OakTaskTask *t) +{ + olive::ProjectLoadBaseTask *task = load_impl(t); + if (!task) { + return NULL; + } + return task->take_project(); +} + +OakTaskTask *oaktask_create_project_save(OakNodeProject *project, + const char *filename_or_NULL, + int use_compression) +{ + if (!project) { + return NULL; + } + try { + auto *task = new olive::ProjectSaveTask(project, + use_compression != 0); + if (filename_or_NULL) { + task->set_override_filename(filename_or_NULL); + } + return wrap(task); + } catch (...) { + return NULL; + } +} + +OakTaskTask *oaktask_create_project_import(OakNodeFolder *folder, + OakNodeProject *project, + const char *const *urls, + int url_count) +{ + if (!folder || !project || (!urls && url_count > 0) || url_count < 0) { + return NULL; + } + try { + std::vector filenames; + filenames.reserve(size_t(url_count)); + for (int i = 0; i < url_count; i++) { + if (!urls[i]) { + return NULL; + } + filenames.emplace_back(urls[i]); + } + return wrap( + new olive::ProjectImportTask(folder, project, filenames)); + } catch (...) { + return NULL; + } +} + +OakUndoCommand *oaktask_import_take_command(OakTaskTask *t) +{ + olive::ProjectImportTask *task = import_impl(t); + if (!task) { + return NULL; + } + return task->take_command(); +} + +int oaktask_import_footage_count(OakTaskTask *t) +{ + olive::ProjectImportTask *task = import_impl(t); + if (!task) { + return OAKTASK_E_INVALID; + } + return int(task->get_imported_footage().size()); +} + +OakNodeFootage *oaktask_import_footage_at(OakTaskTask *t, int index) +{ + olive::ProjectImportTask *task = import_impl(t); + if (!task || index < 0 || + index >= int(task->get_imported_footage().size())) { + return NULL; + } + return task->get_imported_footage()[size_t(index)]; +} + +int oaktask_import_invalid_count(OakTaskTask *t) +{ + olive::ProjectImportTask *task = import_impl(t); + if (!task) { + return OAKTASK_E_INVALID; + } + return int(task->get_invalid_files().size()); +} + +int oaktask_import_invalid_at(OakTaskTask *t, int index, char *buf, + int buf_size) +{ + olive::ProjectImportTask *task = import_impl(t); + if (!task) { + return OAKTASK_E_INVALID; + } + if (index < 0 || index >= int(task->get_invalid_files().size())) { + return OAKTASK_E_NOT_FOUND; + } + return copy_string(task->get_invalid_files()[size_t(index)], buf, + buf_size); +} + +void oaktask_import_set_image_sequence_confirm_cb( + oaktask_image_sequence_confirm_fn fn, void *userdata) +{ + if (!fn) { + olive::ProjectImportTask::set_image_sequence_confirm_callback( + nullptr); + return; + } + olive::ProjectImportTask::set_image_sequence_confirm_callback( + [fn, userdata](const std::string &filename) { + return fn(filename.c_str(), userdata) != 0; + }); +} diff --git a/src/task/c_api/task.cpp b/src/task/c_api/task.cpp new file mode 100644 index 000000000..d05b78a72 --- /dev/null +++ b/src/task/c_api/task.cpp @@ -0,0 +1,161 @@ +/*** + + 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 . + +***/ + +#include "task/task.h" + +#include "../src/taskmanager.h" +#include "taskhandle.h" + +using namespace oaktask_capi; + +void oaktask_task_free(OakTaskTask *t) +{ + if (!t) { + return; + } + TaskHandle *h = impl(t); + if (h->owns_task) { + delete h->task; + } + delete h; + alive()--; +} + +int oaktask_task_start_sync(OakTaskTask *t) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + try { + return impl(t)->task->start() ? 1 : 0; + } catch (...) { + return OAKTASK_E_FAILED; + } +} + +int oaktask_task_start(OakTaskTask *t) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + if (!olive::TaskManager::instance()) { + return OAKTASK_E_STATE; + } + + TaskHandle *h = impl(t); + if (h->running_on_manager) { + return OAKTASK_E_STATE; + } + + olive::TaskManager::instance()->add_task(h->task); + h->running_on_manager = true; + // Ownership transfers to the manager (deleted by delete_finished() / + // shutdown); freeing this handle later only releases the wrapper. + h->owns_task = false; + return OAKTASK_OK; +} + +int oaktask_task_cancel(OakTaskTask *t) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + impl(t)->task->cancel(); + return OAKTASK_OK; +} + +int oaktask_task_wait(OakTaskTask *t) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + TaskHandle *h = impl(t); + if (h->running_on_manager && olive::TaskManager::instance()) { + olive::TaskManager::instance()->cancel_task_and_wait(h->task); + h->finished = true; + } + return OAKTASK_OK; +} + +int oaktask_task_is_finished(const OakTaskTask *t) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + return impl(t)->finished.load() ? 1 : 0; +} + +int oaktask_task_succeeded(const OakTaskTask *t) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + return impl(t)->succeeded.load() ? 1 : 0; +} + +int oaktask_task_title(OakTaskTask *t, char *buf, int buf_size) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + return copy_string(impl(t)->task->get_title(), buf, buf_size); +} + +int oaktask_task_error(OakTaskTask *t, char *buf, int buf_size) +{ + if (!t) { + return OAKTASK_E_INVALID; + } + return copy_string(impl(t)->task->get_error(), buf, buf_size); +} + +int64_t oaktask_task_subscribe(OakTaskTask *t, oaktask_event_fn fn, + void *userdata) +{ + if (!t || !fn) { + return OAKTASK_E_INVALID; + } + + try { + impl(t)->task->add_event_listener( + [fn, userdata](olive::Task::EventType type, double value) { + int event_id = OAKTASK_EVENT_PROGRESS; + switch (type) { + case olive::Task::k_event_started: + event_id = OAKTASK_EVENT_STARTED; + break; + case olive::Task::k_event_finished: + event_id = OAKTASK_EVENT_FINISHED; + break; + default: + break; + } + fn(event_id, value, userdata); + }); + return 0; + } catch (...) { + return OAKTASK_E_FAILED; + } +} + +int oaktask_debug_alive_count(void) +{ + return alive().load(); +} diff --git a/src/task/c_api/taskhandle.h b/src/task/c_api/taskhandle.h new file mode 100644 index 000000000..da87c5197 --- /dev/null +++ b/src/task/c_api/taskhandle.h @@ -0,0 +1,118 @@ +/*** + + 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 . + +***/ + +#ifndef OAK_TASK_CAPI_TASKHANDLE_H +#define OAK_TASK_CAPI_TASKHANDLE_H + +#include +#include +#include +#include + +#include "../src/task.h" + +/** + * @brief Internal handle layout shared by oaktask's c_api translation + * units + */ +namespace oaktask_capi +{ + +struct TaskHandle { + olive::Task *task; + bool owns_task; /**< false for borrowed wrappers (oaktask_manager_at). */ + bool running_on_manager; + std::atomic finished; + std::atomic succeeded; +}; + +inline std::atomic &alive() +{ + static std::atomic g_alive{ 0 }; + return g_alive; +} + +inline TaskHandle *impl(OakTaskTask *t) +{ + return reinterpret_cast(t); +} + +inline const TaskHandle *impl(const OakTaskTask *t) +{ + return reinterpret_cast(t); +} + +inline OakTaskTask *wrap(olive::Task *task) +{ + if (!task) { + return NULL; + } + TaskHandle *h = new (std::nothrow) TaskHandle{ task, true, false, + false, false }; + if (!h) { + delete task; + return NULL; + } + alive()++; + + std::atomic *finished = &h->finished; + std::atomic *succeeded = &h->succeeded; + task->add_event_listener( + [finished, succeeded](olive::Task::EventType type, double value) { + if (type == olive::Task::k_event_finished) { + succeeded->store(value != 0.0); + finished->store(true); + } + }); + + return reinterpret_cast(h); +} + +/** + * @brief Wrap a manager-owned (borrowed) task. Freeing the handle does + * NOT delete the task. + */ +inline OakTaskTask *wrap_borrowed(olive::Task *task) +{ + if (!task) { + return NULL; + } + TaskHandle *h = new (std::nothrow) TaskHandle{ task, false, true, + task->get_start_time() != 0, + false }; + if (!h) { + return NULL; + } + alive()++; + return reinterpret_cast(h); +} + +inline int copy_string(const std::string &value, char *buf, int buf_size) +{ + int needed = int(value.size()) + 1; + if (buf && buf_size >= needed) { + memcpy(buf, value.c_str(), needed); + } + return needed; +} + +} // namespace oaktask_capi + +#endif // OAK_TASK_CAPI_TASKHANDLE_H diff --git a/src/task/src/CMakeLists.txt b/src/task/src/CMakeLists.txt new file mode 100644 index 000000000..66e3a6336 --- /dev/null +++ b/src/task/src/CMakeLists.txt @@ -0,0 +1,37 @@ +# Olive - Non-Linear Video Editor +# Copyright (C) 2022 Olive Team +# Modifications Copyright (C) 2025 mikesolar +# +# 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 . + +file(GLOB_RECURSE OAKTASK_SOURCES CONFIGURE_DEPENDS *.cpp) + +add_library(oaktask SHARED ${OAKTASK_SOURCES}) + +target_include_directories(oaktask PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${OAK_REPO_ROOT}/include + ${OAK_REPO_ROOT}/src/common/src + ${OAK_REPO_ROOT}/src/undo/src + ${OAK_REPO_ROOT}/core/include +) + +target_link_libraries(oaktask PUBLIC + oaknode + oakrender + oakcodec + oakundo + oakcommon + olivecore +) diff --git a/src/task/src/codecbridge.cpp b/src/task/src/codecbridge.cpp new file mode 100644 index 000000000..7135d8107 --- /dev/null +++ b/src/task/src/codecbridge.cpp @@ -0,0 +1,98 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "codecbridge.h" + +#include + +#include "codec/task.h" +#include "common/config.h" +#include "conform/conform.h" +#include "proxy/proxy.h" + +namespace olive +{ + +namespace +{ + +oakcodec_proxy_params proxy_params_from_config() +{ + oakcodec_proxy_params params = {}; + oakcodec_proxy_params_default(¶ms); + + params.width = oakcommon_config_get_int(nullptr, "ProxyWidth", + params.width); + params.height = oakcommon_config_get_int(nullptr, "ProxyHeight", + params.height); + params.divider = oakcommon_config_get_int(nullptr, "ProxyDivider", + params.divider); + params.crf = oakcommon_config_get_int(nullptr, "ProxyCRF", params.crf); + params.include_audio = + oakcommon_config_get_bool(nullptr, "ProxyIncludeAudio", + params.include_audio) + ? 1 + : 0; + oakcommon_config_get(nullptr, "ProxyPreset", params.preset, + int(sizeof(params.preset))); + oakcommon_config_get(nullptr, "ProxyExtension", params.extension, + int(sizeof(params.extension))); + return params; +} + +int submit_codec_task(const OakCodecTaskRequest *req, void *userdata) +{ + (void)userdata; + + if (!req) { + return OAKCODEC_E_INVALID; + } + + std::unique_ptr task; + + switch (req->kind) { + case OAKCODEC_TASK_CONFORM: + task = std::make_unique(*req); + break; + case OAKCODEC_TASK_PROXY: + task = std::make_unique(*req, proxy_params_from_config()); + break; + default: + return OAKCODEC_E_INVALID; + } + + // Interim contract: submission is synchronous. + return task->start() ? OAKCODEC_OK : OAKCODEC_E_FAILED; +} + +} // namespace + +void register_codec_task_submitter() +{ + oakcodec_set_task_submit_cb(submit_codec_task, nullptr); +} + +void unregister_codec_task_submitter() +{ + oakcodec_set_task_submit_cb(nullptr, nullptr); +} + +} diff --git a/src/task/src/codecbridge.h b/src/task/src/codecbridge.h new file mode 100644 index 000000000..e70bffb6d --- /dev/null +++ b/src/task/src/codecbridge.h @@ -0,0 +1,45 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_CODECBRIDGE_H +#define OAK_CODECBRIDGE_H + +namespace olive +{ + +/** + * @brief Register oaktask as oakcodec's background task submitter + * + * Wires oakcodec_set_task_submit_cb() to execute OAKCODEC_TASK_CONFORM / + * OAKCODEC_TASK_PROXY requests as ConformTask/ProxyTask. Submission is + * synchronous (the callback runs the task to completion), matching + * oakcodec's interim contract. + */ +void register_codec_task_submitter(); + +/** + * @brief Unregister the submitter (oaktask_shutdown). + */ +void unregister_codec_task_submitter(); + +} + +#endif // OAK_CODECBRIDGE_H diff --git a/src/task/src/conform/conform.cpp b/src/task/src/conform/conform.cpp new file mode 100644 index 000000000..f5b24bbf8 --- /dev/null +++ b/src/task/src/conform/conform.cpp @@ -0,0 +1,155 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "conform.h" + +#include + +namespace olive +{ + +namespace +{ + +int channel_count_from_layout(uint64_t layout) +{ + int count = 0; + while (layout) { + count += int(layout & 1); + layout >>= 1; + } + return count; +} + +} // namespace + +ConformTask::ConformTask(const OakCodecTaskRequest &request) + : input_filename_(request.input_filename ? request.input_filename : "") + , output_filename_(request.output_filename ? request.output_filename + : "") + , stream_index_(request.stream_index) + , sample_rate_(request.sample_rate) + , channel_layout_(request.channel_layout) + , sample_format_(request.sample_format) +{ + set_title("Conforming Audio " + input_filename_ + ":" + + std::to_string(stream_index_)); +} + +bool ConformTask::derive_filenames(const std::string &first_channel_final, + int channel_count, + std::vector *final_names, + std::vector *working_names) +{ + static const std::string k_suffix = ".0.pcm"; + + if (channel_count < 1 || first_channel_final.size() <= k_suffix.size() || + first_channel_final.compare(first_channel_final.size() - + k_suffix.size(), + k_suffix.size(), k_suffix) != 0) { + return false; + } + + const std::string base = + first_channel_final.substr(0, first_channel_final.size() - + k_suffix.size()); + + final_names->clear(); + working_names->clear(); + final_names->reserve(size_t(channel_count)); + working_names->reserve(size_t(channel_count)); + for (int i = 0; i < channel_count; i++) { + std::string final_name = base + "." + std::to_string(i) + ".pcm"; + final_names->push_back(final_name); + working_names->push_back(final_name + ".working"); + } + return true; +} + +bool ConformTask::run() +{ + int channel_count = channel_count_from_layout(channel_layout_); + + std::vector final_names, working_names; + if (!derive_filenames(output_filename_, channel_count, &final_names, + &working_names)) { + set_error("Invalid conform output filename"); + return false; + } + + OakDecoder decoder = oakcodec_decoder_init(); + if (!decoder.ctx) { + set_error("Failed to create decoder"); + return false; + } + + bool ret = false; + + if (oakcodec_decoder_open(decoder, input_filename_.c_str(), + stream_index_) != OAKCODEC_OK) { + char err[256]; + oakcodec_decoder_last_error(decoder, err, sizeof(err)); + set_error(std::string("Failed to open decoder for audio conform: ") + + err); + } else { + std::vector working_ptrs; + working_ptrs.reserve(working_names.size()); + for (const std::string &name : working_names) { + working_ptrs.push_back(name.c_str()); + } + + int result = oakcodec_decoder_conform_audio( + decoder, working_ptrs.data(), int(working_ptrs.size()), + sample_rate_, channel_layout_, sample_format_, + get_cancel_atom()); + + oakcodec_decoder_close(decoder); + + if (result == OAKCODEC_OK) { + ret = true; + for (size_t i = 0; i < final_names.size() && ret; i++) { + std::error_code ec; + std::filesystem::rename(working_names[i], final_names[i], ec); + if (ec) { + set_error("Failed to move conformed audio into place"); + ret = false; + } + } + } else { + // Clean up any partial working files + for (const std::string &name : working_names) { + std::error_code ec; + std::filesystem::remove(name, ec); + } + + if (result == OAKCODEC_E_CANCELLED) { + set_error("Audio conform was cancelled"); + } else { + set_error("Audio conform failed"); + } + } + } + + oakcodec_decoder_free(&decoder); + return ret; +} + +} diff --git a/src/task/src/conform/conform.h b/src/task/src/conform/conform.h new file mode 100644 index 000000000..4ecc9199d --- /dev/null +++ b/src/task/src/conform/conform.h @@ -0,0 +1,68 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_CONFORMTASK_H +#define OAK_CONFORMTASK_H + +#include "codec/decoder.h" +#include "codec/task.h" +#include "task.h" + +namespace olive +{ + +/** + * @brief Audio conform task (pcm cache generation) + * + * Executes an OAKCODEC_TASK_CONFORM request: decodes the audio stream + * and writes per-channel pcm cache files (to ".working" names first, + * renamed to the final paths on success). + */ +class ConformTask : public Task { +public: + ConformTask(const OakCodecTaskRequest &request); + + /** + * @brief Derive sibling per-channel and ".working" filenames from the + * first channel's final path (".../.0.pcm") + * + * Extracted for testability. `channel_count` must be >= 1. + */ + static bool derive_filenames(const std::string &first_channel_final, + int channel_count, + std::vector *final_names, + std::vector *working_names); + +protected: + virtual bool run() override; + +private: + std::string input_filename_; + std::string output_filename_; + int stream_index_; + int sample_rate_; + uint64_t channel_layout_; + int sample_format_; +}; + +} + +#endif // OAK_CONFORMTASK_H diff --git a/src/task/src/customcache/customcachetask.cpp b/src/task/src/customcache/customcachetask.cpp new file mode 100644 index 000000000..21c6ca83f --- /dev/null +++ b/src/task/src/customcache/customcachetask.cpp @@ -0,0 +1,60 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "customcachetask.h" + +namespace olive +{ + +CustomCacheTask::CustomCacheTask(const std::string &sequence_name) + : cancelled_through_finish_(false) +{ + set_title("Caching custom range for \"" + sequence_name + "\""); +} + +void CustomCacheTask::finish() +{ + std::lock_guard lock(mutex_); + + cancelled_through_finish_ = true; + cancel(); +} + +bool CustomCacheTask::run() +{ + std::unique_lock lock(mutex_); + + while (!is_cancelled()) { + wait_cond_.wait(lock); + } + + return true; +} + +void CustomCacheTask::cancel_event() +{ + if (!cancelled_through_finish_ && cancelled_callback_) { + cancelled_callback_(); + } + wait_cond_.notify_one(); +} + +} diff --git a/src/task/src/customcache/customcachetask.h b/src/task/src/customcache/customcachetask.h new file mode 100644 index 000000000..a032d66d1 --- /dev/null +++ b/src/task/src/customcache/customcachetask.h @@ -0,0 +1,66 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_CUSTOMCACHETASK_H +#define OAK_CUSTOMCACHETASK_H + +#include +#include +#include + +#include "task.h" + +namespace olive +{ + +class CustomCacheTask : public Task { +public: + CustomCacheTask(const std::string &sequence_name); + + void finish(); + + /** + * @brief Called when the task is cancelled by anything other than + * finish() (async return channel, 01 §4 exception) + */ + void set_cancelled_callback(std::function callback) + { + cancelled_callback_ = std::move(callback); + } + +protected: + virtual bool run() override; + + virtual void cancel_event() override; + +private: + std::mutex mutex_; + + std::condition_variable wait_cond_; + + bool cancelled_through_finish_; + + std::function cancelled_callback_; +}; + +} + +#endif // OAK_CUSTOMCACHETASK_H diff --git a/src/task/src/export/export.cpp.pending b/src/task/src/export/export.cpp.pending new file mode 100644 index 000000000..fa8cb6f8e --- /dev/null +++ b/src/task/src/export/export.cpp.pending @@ -0,0 +1,313 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "export.h" + +#include "node/color/colormanager/colormanager.h" + +namespace olive +{ + +ExportTask::ExportTask(ViewerOutput *viewer_node, ColorManager *color_manager, + const EncodingParams ¶ms) + : params_(params) +{ + // Create a copy of the project + copier_ = new ProjectCopier(this); + copier_->set_project(viewer_node->project()); + + set_viewer(copier_->get_copy(viewer_node)); + color_manager_ = copier_->get_copied_project()->color_manager(); + + // Adjust video params to have no divider + VideoParams vp = viewer_node->get_video_params(); + vp.set_divider(1); + vp.set_time_base(params.video_params().time_base()); + vp.set_frame_rate(params.video_params().frame_rate()); + set_video_params(vp); + + set_audio_params(viewer_node->get_audio_params()); + + set_title(tr("Exporting \"%1\"").arg(viewer_node->get_label())); + set_native_progress_signalling_enabled(false); +} + +bool ExportTask::run() +{ + // For safety, if we're overwriting, we save to a temporary filename and then only overwrite it + // at the end + QString real_filename = params_.filename(); + if (QFileInfo::exists(params_.filename())) { + // Generate a filename that definitely doesn't exist + params_.set_filename( + FileFunctions::get_safe_temporary_filename(real_filename)); + } + + // If we're exporting to a sidecar subtitle file, disable the subtitles in the main encoder + bool subtitles_enabled = params_.subtitles_enabled(); + EncodingParams sidecar_params = params_; + if (subtitles_enabled && params_.subtitles_are_sidecar()) { + params_.disable_subtitles(); + } + + encoder_ = std::shared_ptr(Encoder::create_from_params(params_)); + + if (!encoder_) { + set_error(tr("Failed to create encoder")); + return false; + } + + if (!encoder_->open()) { + set_error(tr("Failed to open file: %1").arg(encoder_->get_error())); + return false; + } + + if (subtitles_enabled && params_.subtitles_are_sidecar()) { + // Construct sidecar params + sidecar_params.disable_video(); + sidecar_params.disable_audio(); + + QString sidecar_filename; + { + QFileInfo fi(real_filename); + sidecar_filename = fi.completeBaseName(); + sidecar_filename.append('.'); + sidecar_filename.append(ExportFormat::get_extension( + sidecar_params.subtitle_sidecar_fmt())); + sidecar_filename = fi.dir().filePath(sidecar_filename); + } + sidecar_params.set_filename(sidecar_filename); + + subtitle_encoder_ = std::shared_ptr(Encoder::create_from_format( + sidecar_params.subtitle_sidecar_fmt(), sidecar_params)); + if (!subtitle_encoder_) { + set_error(tr("Failed to create subtitle encoder")); + return false; + } + + if (!subtitle_encoder_->open()) { + set_error(tr("Failed to open subtitle sidecar file: %1") + .arg(sidecar_filename)); + return false; + } + } else { + subtitle_encoder_ = encoder_; + } + + if (params_.has_custom_range()) { + // Render custom range only + export_range_ = params_.custom_range(); + } else { + // Render entire sequence + export_range_ = TimeRange(0, viewer()->get_length()); + } + + frame_time_ = 0; + + QSize video_force_size; + QMatrix4x4 video_force_matrix; + + if (params_.video_enabled()) { + // If a transformation matrix is applied to this video, create it here + if (video_params().width() != params_.video_params().width() || + video_params().height() != params_.video_params().height()) { + video_force_size = QSize(params_.video_params().width(), + params_.video_params().height()); + + if (params_.video_scaling_method() != EncodingParams::k_stretch) { + video_force_matrix = EncodingParams::generate_matrix( + params_.video_scaling_method(), video_params().width(), + video_params().height(), params_.video_params().width(), + params_.video_params().height()); + } + } else { + // Disables forcing size in the renderer + video_force_size = QSize(0, 0); + } + + // Create color processor + color_processor_ = ColorProcessor::create( + color_manager_, color_manager_->get_reference_color_space(), + params_.color_transform()); + } + + // Start render process + TimeRangeList video_range, audio_range; + TimeRange subtitle_range; + + if (params_.video_enabled()) { + if (export_range_.in() > 0) { + export_range_.set_in(Timecode::snap_time_to_timebase( + export_range_.in(), video_params().frame_rate_as_time_base())); + } + + video_range = { export_range_ }; + } + + if (params_.audio_enabled()) { + audio_range = { export_range_ }; + } + + if (subtitles_enabled) { + subtitle_range = export_range_; + } + + render(color_manager_, video_range, audio_range, subtitle_range, + RenderMode::k_online, nullptr, video_force_size, video_force_matrix, + encoder_->get_desired_pixel_format(), VideoParams::k_rgba_channel_count, + color_processor_, params_.color_transform()); + + bool success = true; + + encoder_->close(); + if (!encoder_->get_error().isEmpty()) { + set_error(encoder_->get_error()); + success = false; + } + + if (subtitle_encoder_ != encoder_) { + subtitle_encoder_->close(); + if (!subtitle_encoder_->get_error().isEmpty()) { + set_error(subtitle_encoder_->get_error()); + success = false; + } + } + + // If cancelled, delete the file we made, which is always a file we created since we write to a + // temp file during the actual encoding process + if (is_cancelled()) { + QFile::remove(params_.filename()); + } else if (params_.filename() != real_filename) { + // If we were writing to a temp file, overwrite now + if (!FileFunctions::rename_file_allow_overwrite(params_.filename(), + real_filename)) { + set_error( + tr("Failed to overwrite \"%1\". Export has been saved as \"%2\" instead.") + .arg(real_filename, params_.filename())); + success = false; + } + } + + return success; +} + +bool ExportTask::frame_downloaded(FramePtr f, const Rational &time) +{ + // The worker pool finishes tickets without a result when no worker is + // available (or every worker crashed). Grinding through the whole + // timeline at several seconds per dead worker looks like a hang, so + // fail the export after a short streak of missing frames. + if (!f) { + if (++null_frame_streak_ >= 8) { + set_error(tr("Render workers failed to deliver %1 consecutive " + "frames; aborting export") + .arg(null_frame_streak_)); + return false; + } + } else { + null_frame_streak_ = 0; + } + + Rational actual_time = time - export_range_.in(); + + time_map_.insert(actual_time, f); + + while (!is_cancelled()) { + Rational real_time = Timecode::timestamp_to_time( + frame_time_, video_params().frame_rate_as_time_base()); + + if (!time_map_.contains(real_time)) { + break; + } + + // Unfortunately this can't be done in another thread since the frames need to be sent + // one after the other chronologically. + if (!encoder_->write_frame(time_map_.take(real_time), real_time)) { + set_error(encoder_->get_error()); + return false; + } + + frame_time_++; + emit progress_changed(double(frame_time_) / + double(get_total_number_of_frames())); + } + + return true; +} + +bool ExportTask::audio_downloaded(const TimeRange &range, + const SampleBuffer &samples) +{ + TimeRange adjusted_range = range - export_range_.in(); + + if (adjusted_range.in() == audio_time_) { + if (!write_audio_loop(adjusted_range, samples)) { + return false; + } + } else { + audio_map_.insert(adjusted_range, samples); + } + + return true; +} + +bool ExportTask::encode_subtitle(const SubtitleBlock *sub) +{ + if (!subtitle_encoder_->write_subtitle(sub)) { + set_error(subtitle_encoder_->get_error()); + return false; + } else { + return true; + } +} + +bool ExportTask::write_audio_loop(const TimeRange &time, + const SampleBuffer &samples) +{ + if (!encoder_->write_audio(samples)) { + set_error(encoder_->get_error()); + return false; + } + + audio_time_ = time.out(); + + for (auto it = audio_map_.begin(); it != audio_map_.end(); it++) { + TimeRange t = it.key(); + SampleBuffer s = it.value(); + + if (t.in() == audio_time_) { + // Erase from audio map since we're just about to write it + audio_map_.erase(it); + + // Call recursively to write the next sample buffer + if (!write_audio_loop(t, s)) { + return false; + } + + // Break out of loop + break; + } + } + + return true; +} + +} diff --git a/src/task/src/export/export.h.pending b/src/task/src/export/export.h.pending new file mode 100644 index 000000000..5522a21d8 --- /dev/null +++ b/src/task/src/export/export.h.pending @@ -0,0 +1,86 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_EXPORTTASK_H +#define OAK_EXPORTTASK_H + +#include "codec/encoder.h" +#include "node/output/viewer/viewer.h" +#include "render/colorprocessor.h" +#include "render/projectcopier.h" +#include "task/render/render.h" +#include "task/task.h" + +namespace olive +{ + +class ExportTask : public RenderTask { + Q_OBJECT +public: + ExportTask(ViewerOutput *viewer_node, ColorManager *color_manager, + const EncodingParams ¶ms); + +protected: + virtual bool run() override; + + virtual bool frame_downloaded(FramePtr frame, const Rational &time) override; + + virtual bool audio_downloaded(const TimeRange &range, + const SampleBuffer &samples) override; + + virtual bool encode_subtitle(const SubtitleBlock *sub) override; + + virtual bool two_step_frame_rendering() const override + { + return false; + } + +private: + bool write_audio_loop(const TimeRange &time, const SampleBuffer &samples); + + ProjectCopier *copier_; + + QHash time_map_; + + QHash audio_map_; + + ColorManager *color_manager_; + + EncodingParams params_; + + std::shared_ptr encoder_; + + std::shared_ptr subtitle_encoder_; + + ColorProcessorPtr color_processor_; + + int64_t frame_time_; + + int null_frame_streak_ = 0; + + Rational audio_time_; + + TimeRange export_range_; +}; + +} + +#endif // OAK_EXPORTTASK_H diff --git a/src/task/src/precache/precachetask.cpp.pending b/src/task/src/precache/precachetask.cpp.pending new file mode 100644 index 000000000..42241e432 --- /dev/null +++ b/src/task/src/precache/precachetask.cpp.pending @@ -0,0 +1,113 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "precachetask.h" + +#include "node/project.h" + +namespace olive +{ + +PreCacheTask::PreCacheTask(Footage *footage, int index, Sequence *sequence) +{ + // Set video and audio params + set_video_params(sequence->get_video_params()); + set_audio_params(sequence->get_audio_params()); + + // Create new project + project_ = new Project(); + + // Create viewer with same parameters as the sequence + set_viewer(new ViewerOutput()); + viewer()->setParent(project_); + viewer()->set_video_params(sequence->get_video_params()); + viewer()->set_audio_params(sequence->get_audio_params()); + + // Copy project config nodes + Project::copy_settings(footage->project(), project_); + + // Copy footage node so it can precache without any modifications from the user screwing it up + footage_ = static_cast(footage->copy()); + footage_->setParent(project_); + Node::copy_inputs(footage, footage_, false); + + Node::connect_edge(footage_, + NodeInput(viewer(), ViewerOutput::k_texture_input)); + viewer()->set_value_hint_for_input( + ViewerOutput::k_texture_input, + Node::ValueHint({ NodeValue::k_texture }, + Track::Reference(Track::k_video, index).to_string())); + + set_title(tr("Pre-caching %1:%2") + .arg(footage_->filename(), QString::number(index))); +} + +PreCacheTask::~PreCacheTask() +{ + // This should delete the footage we copied and the viewer we created + delete project_; +} + +bool PreCacheTask::run() +{ + // Get list of invalidated ranges + TimeRange intersection; + + if (footage_->get_work_area()->enabled()) { + // If we're caching only in-out, limit the range to that + intersection = footage_->get_work_area()->range(); + } else { + // Otherwise use full length + intersection = TimeRange(0, footage_->get_video_length()); + } + + TimeRangeList video_range = + viewer()->video_frame_cache()->get_invalidated_ranges(intersection); + + render(project_->color_manager(), video_range, TimeRangeList(), TimeRange(), + RenderMode::k_online, viewer()->video_frame_cache()); + + return true; +} + +bool PreCacheTask::frame_downloaded(FramePtr frame, const Rational &time) +{ + // Do nothing. Pre-cache essentially just creates more frames in the cache, it doesn't need to do + // anything else. + + Q_UNUSED(frame) + Q_UNUSED(time) + + return true; +} + +bool PreCacheTask::audio_downloaded(const TimeRange &range, + const SampleBuffer &samples) +{ + // Pre-cache doesn't cache any audio + + Q_UNUSED(range) + Q_UNUSED(samples) + + return true; +} + +} diff --git a/src/task/src/precache/precachetask.h.pending b/src/task/src/precache/precachetask.h.pending new file mode 100644 index 000000000..d082e20bf --- /dev/null +++ b/src/task/src/precache/precachetask.h.pending @@ -0,0 +1,56 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_PRECACHETASK_H +#define OAK_PRECACHETASK_H + +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "task/render/render.h" + +namespace olive +{ + +class PreCacheTask : public RenderTask { + Q_OBJECT +public: + PreCacheTask(Footage *footage, int index, Sequence *sequence); + + virtual ~PreCacheTask() override; + +protected: + virtual bool run() override; + + virtual bool frame_downloaded(FramePtr frame, + const Rational ×) override; + + virtual bool audio_downloaded(const TimeRange &range, + const SampleBuffer &samples) override; + +private: + Project *project_; + + Footage *footage_; +}; + +} + +#endif // OAK_PRECACHETASK_H diff --git a/src/task/src/project/import/import.cpp b/src/task/src/project/import/import.cpp new file mode 100644 index 000000000..ee9001185 --- /dev/null +++ b/src/task/src/project/import/import.cpp @@ -0,0 +1,416 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "import.h" + +#include + +#include "codec/decoder.h" +#include "common/config.h" +#include "common/videoparams.h" +#include "undo/undostack.h" + +namespace olive +{ + +namespace +{ + +int count_files_recursive(const std::vector &paths) +{ + int count = 0; + std::error_code ec; + for (const std::string &path : paths) { + if (std::filesystem::is_directory(path, ec)) { + for (const auto &entry : + std::filesystem::recursive_directory_iterator(path, ec)) { + if (entry.is_regular_file(ec)) { + count++; + } + } + } else { + count++; + } + } + return count; +} + +std::string basename_of(const std::string &path) +{ + return std::filesystem::path(path).filename().string(); +} + +} // namespace + +ProjectImportTask::ImageSequenceConfirmFn ProjectImportTask::confirm_callback_; + +ProjectImportTask::ProjectImportTask( + OakNodeFolder *folder, OakNodeProject *project, + const std::vector &filenames) + : command_(nullptr) + , folder_(folder) + , project_(project) + , filenames_(filenames) +{ + file_count_ = count_files_recursive(filenames_); + if (file_count_ <= 0) { + file_count_ = 1; + } + + set_title("Importing " + std::to_string(file_count_) + " file(s)"); +} + +ProjectImportTask::~ProjectImportTask() +{ + if (command_) { + oakundo_command_free(command_); + } +} + +const int &ProjectImportTask::get_file_count() const +{ + return file_count_; +} + +bool ProjectImportTask::run() +{ + command_ = oakundo_command_init_multi(); + if (!command_) { + set_error("Failed to create import command"); + return false; + } + + int imported = 0; + + import(folder_, filenames_, imported, command_); + + if (is_cancelled()) { + oakundo_command_free(command_); + command_ = nullptr; + return false; + } + return true; +} + +void ProjectImportTask::import(OakNodeFolder *folder, + const std::vector &entries, + int &counter, OakUndoCommand *parent_command) +{ + std::vector mutable_entries = entries; + + for (size_t i = 0; i < mutable_entries.size(); i++) { + if (is_cancelled()) { + break; + } + + const std::string &file_path = mutable_entries[i]; + + std::error_code ec; + if (std::filesystem::is_directory(file_path, ec)) { + // Create a folder corresponding to the directory and recurse + std::vector entry_list; + for (const auto &entry : + std::filesystem::directory_iterator(file_path, ec)) { + entry_list.push_back(entry.path().string()); + } + + if (!entry_list.empty()) { + OakNodeFolder *f = oaknode_folder_create(project_); + if (!f) { + continue; + } + + oaknode_node_set_label(oaknode_folder_as_node(f), + basename_of(file_path).c_str()); + + add_item_to_folder(folder, oaknode_folder_as_node(f), + parent_command); + + // Recursively follow this path + import(f, entry_list, counter, parent_command); + } + + } else { + OakNodeFootage *footage = + oaknode_footage_create(project_, nullptr); + if (!footage) { + continue; + } + + oaknode_footage_set_cancel_atom(footage, get_cancel_atom()); + + bool ok = oaknode_footage_set_filename(footage, + file_path.c_str()) == + OAKNODE_OK; + + OakCancelAtom empty_atom = {}; + oaknode_footage_set_cancel_atom(footage, empty_atom); + + if (ok && oaknode_footage_is_valid(footage)) { + oaknode_node_set_label(oaknode_footage_as_node(footage), + basename_of(file_path).c_str()); + + // See if this footage is an image sequence + validate_image_sequence(footage, mutable_entries, i); + + // Create undoable command that adds the items to the model + add_item_to_folder(folder, oaknode_footage_as_node(footage), + parent_command); + + // Add to vector + imported_footage_.push_back(footage); + } else { + // Add to list so we can tell the user about it later + invalid_files_.push_back(file_path); + + oaknode_project_remove_node(project_, + oaknode_footage_as_node(footage)); + oaknode_node_free(oaknode_footage_as_node(footage)); + } + + counter++; + + emit_progress(static_cast(counter) / + static_cast(file_count_)); + } + } +} + +void ProjectImportTask::validate_image_sequence( + OakNodeFootage *footage, std::vector &info_list, + size_t index) +{ + char filename[1024]; + if (oaknode_footage_filename(footage, filename, sizeof(filename)) <= + 0) { + return; + } + + // Heuristically determine whether this file is part of an image sequence or not. + // By this point we've established that video contains a single still image stream. + int digit_count = oakcodec_decoder_get_image_sequence_digit_count(filename); + if (digit_count <= 0) { + return; + } + + bool ignored = false; + for (const std::string &ignore : image_sequence_ignore_files_) { + if (ignore == filename) { + ignored = true; + break; + } + } + if (ignored) { + return; + } + + if (!item_is_still_image_footage_only(footage)) { + return; + } + + OakVideoParams video_stream = {}; + if (oaknode_footage_get_video_params(footage, 0, &video_stream) != + OAKNODE_OK) { + return; + } + + int width = 0, height = 0; + oakcommon_videoparams_get_width(video_stream, &width); + oakcommon_videoparams_get_height(video_stream, &height); + + int64_t ind = oakcodec_decoder_get_image_sequence_index(filename); + + // Check if files around exist that follow a sequence + char prev_fn[1024], next_fn[1024]; + oakcodec_decoder_transform_image_sequence_file_name(filename, ind - 1, + prev_fn, sizeof(prev_fn)); + oakcodec_decoder_transform_image_sequence_file_name(filename, ind + 1, + next_fn, sizeof(next_fn)); + + OakNodeFootage *previous_file = + oaknode_footage_create(project_, prev_fn); + OakNodeFootage *next_file = oaknode_footage_create(project_, next_fn); + + bool prev_matches = + previous_file && oaknode_footage_is_valid(previous_file) && + compare_still_image_size(previous_file, width, height); + bool next_matches = + next_file && oaknode_footage_is_valid(next_file) && + compare_still_image_size(next_file, width, height); + + if (prev_matches || next_matches) { + // By this point, we've established this file is a still image with a number at the end of + // the filename surrounded by adjacent numbers. It could be a still image! But let's ask the + // user just in case... + bool is_sequence = + confirm_callback_ ? confirm_callback_(filename) : false; + + int64_t seq_index = oakcodec_decoder_get_image_sequence_index(filename); + + // Heuristic to find the first and last images (users can always override this later in + // FootagePropertiesDialog) + int64_t start_index = get_image_sequence_limit(filename, seq_index, + false); + int64_t end_index = get_image_sequence_limit(filename, seq_index, + true); + + // Depending on the user's choice, either remove them from the list or don't ask for + // the remainders + for (int64_t j = start_index; j <= end_index; j++) { + char entry_fn[1024]; + oakcodec_decoder_transform_image_sequence_file_name( + filename, j, entry_fn, sizeof(entry_fn)); + + if (is_sequence) { + // If this is part of the sequence we're importing here, remove it + for (size_t k = index + 1; k < info_list.size(); k++) { + if (info_list[k] == entry_fn) { + info_list.erase(info_list.begin() + k); + break; + } + } + } else { + image_sequence_ignore_files_.push_back(entry_fn); + } + } + + if (is_sequence) { + // User has confirmed it is an image sequence, let's set it accordingly. + oakcommon_videoparams_set_video_type( + video_stream, OAKCOMMON_VIDEO_TYPE_IMAGE_SEQUENCE); + + char rate_str[64]; + int needed = oakcommon_config_get( + nullptr, "DefaultSequenceFrameRate", rate_str, + sizeof(rate_str)); + if (needed > 0) { + int num = 0, den = 1; + if (sscanf(rate_str, "%d/%d", &num, &den) == 2 && den != 0) { + oakcommon_videoparams_set_time_base(video_stream, num, + den); + oakcommon_videoparams_set_frame_rate(video_stream, den, + num); + } + } + + oakcommon_videoparams_set_start_time(video_stream, + start_index); + oakcommon_videoparams_set_duration(video_stream, + end_index - start_index + 1); + + oaknode_footage_set_video_params(footage, 0, &video_stream); + } + } + + oakcommon_videoparams_free(&video_stream); + + if (previous_file) { + oaknode_project_remove_node(project_, + oaknode_footage_as_node(previous_file)); + oaknode_node_free(oaknode_footage_as_node(previous_file)); + } + if (next_file) { + oaknode_project_remove_node(project_, + oaknode_footage_as_node(next_file)); + oaknode_node_free(oaknode_footage_as_node(next_file)); + } +} + +void ProjectImportTask::add_item_to_folder(OakNodeFolder *folder, + OakNodeNode *item, + OakUndoCommand *command) +{ + OakUndoCommand *child = + oaknode_command_create_folder_add_child(folder, item); + if (child) { + oakundo_command_multi_add_child(command, child); + } +} + +bool ProjectImportTask::item_is_still_image_footage_only( + OakNodeFootage *footage) +{ + if (oaknode_footage_total_stream_count(footage) != 1) { + // Footage with more than one stream (usually video+audio) most likely isn't an image sequence + return false; + } + + OakVideoParams vp = {}; + if (oaknode_footage_get_video_params(footage, 0, &vp) != OAKNODE_OK) { + return false; + } + + int video_type = 0; + oakcommon_videoparams_get_video_type(vp, &video_type); + int valid = 0; + oakcommon_videoparams_get_is_valid(vp, &valid); + oakcommon_videoparams_free(&vp); + + // Footage must be valid and video stream must be a still image to be an image sequence + return valid && video_type == OAKCOMMON_VIDEO_TYPE_STILL; +} + +bool ProjectImportTask::compare_still_image_size(OakNodeFootage *footage, + int width, int height) +{ + if (!item_is_still_image_footage_only(footage)) { + return false; + } + + OakVideoParams stream = {}; + if (oaknode_footage_get_video_params(footage, 0, &stream) != + OAKNODE_OK) { + return false; + } + + int w = 0, h = 0; + oakcommon_videoparams_get_width(stream, &w); + oakcommon_videoparams_get_height(stream, &h); + oakcommon_videoparams_free(&stream); + + return w == width && h == height; +} + +int64_t ProjectImportTask::get_image_sequence_limit( + const std::string &start_fn, int64_t start, bool up) +{ + std::error_code ec; + + while (true) { + int64_t test_index = up ? start + 1 : start - 1; + + char test_filename[1024]; + oakcodec_decoder_transform_image_sequence_file_name( + start_fn.c_str(), test_index, test_filename, + sizeof(test_filename)); + + if (!std::filesystem::exists(test_filename, ec)) { + // Reached end of index + break; + } + + start = test_index; + } + + return start; +} + +} diff --git a/src/task/src/project/import/import.h b/src/task/src/project/import/import.h new file mode 100644 index 000000000..e13b1424a --- /dev/null +++ b/src/task/src/project/import/import.h @@ -0,0 +1,128 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_PROJECTIMPORTMANAGER_H +#define OAK_PROJECTIMPORTMANAGER_H + +#include +#include +#include + +#include "node/folder.h" +#include "node/footage.h" +#include "node/node.h" +#include "task.h" +#include "undo/undocommand.h" + +namespace olive +{ + +class ProjectImportTask : public Task { +public: + ProjectImportTask(OakNodeFolder *folder, OakNodeProject *project, + const std::vector &filenames); + ~ProjectImportTask() override; + + const int &get_file_count() const; + + /** Take ownership of the import command. After this call the task no + * longer owns (and will not free) the returned command. */ + OakUndoCommand *take_command() + { + OakUndoCommand *c = command_; + command_ = nullptr; + return c; + } + + const std::vector &get_invalid_files() const + { + return invalid_files_; + } + + bool has_invalid_files() const + { + return !invalid_files_.empty(); + } + + const std::vector &get_imported_footage() const + { + return imported_footage_; + } + + /** + * @brief Callback asking the user whether numbered stills form an + * image sequence (facade/UI concern). When no callback is + * installed, files are NOT treated as sequences (safest + * default - imports every file individually). + */ + using ImageSequenceConfirmFn = std::function; + static void set_image_sequence_confirm_callback( + ImageSequenceConfirmFn callback) + { + confirm_callback_ = std::move(callback); + } + +protected: + virtual bool run() override; + +private: + void import(OakNodeFolder *folder, + const std::vector &entries, int &counter, + OakUndoCommand *parent_command); + + void validate_image_sequence(OakNodeFootage *footage, + std::vector &info_list, + size_t index); + + void add_item_to_folder(OakNodeFolder *folder, OakNodeNode *item, + OakUndoCommand *command); + + static bool item_is_still_image_footage_only(OakNodeFootage *footage); + + static bool compare_still_image_size(OakNodeFootage *footage, int width, + int height); + + static int64_t get_image_sequence_limit(const std::string &start_fn, + int64_t start, bool up); + + OakUndoCommand *command_; + + OakNodeFolder *folder_; + + OakNodeProject *project_; + + std::vector filenames_; + + int file_count_; + + std::vector invalid_files_; + + std::vector image_sequence_ignore_files_; + + std::vector imported_footage_; + + static ImageSequenceConfirmFn confirm_callback_; +}; + +} + +#endif // OAK_PROJECTIMPORTMANAGER_H diff --git a/src/task/src/project/load/load.cpp b/src/task/src/project/load/load.cpp new file mode 100644 index 000000000..420c91639 --- /dev/null +++ b/src/task/src/project/load/load.cpp @@ -0,0 +1,100 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "load.h" + +#include "node/serializer.h" + +namespace olive +{ + +ProjectLoadBaseTask::ProjectLoadBaseTask(const std::string &filename) + : project_(nullptr) + , filename_(filename) +{ + set_title("Loading '" + filename + "'"); +} + +ProjectLoadTask::ProjectLoadTask(const std::string &filename) + : ProjectLoadBaseTask(filename) +{ +} + +bool ProjectLoadTask::run() +{ + project_ = oaknode_project_init(); + if (!project_) { + set_error("Failed to create project"); + return false; + } + + oaknode_project_set_filename(project_, get_filename().c_str()); + + int code = OAKNODE_SERIALIZER_RESULT_FILE_ERROR; + char details[512]; + details[0] = 0; + int result = oaknode_serializer_load_from_file( + project_, get_filename().c_str(), &code, details, sizeof(details)); + + bool success = false; + switch (code) { + case OAKNODE_SERIALIZER_RESULT_SUCCESS: + success = true; + break; + case OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_OLD: + set_error("This project is from a version of Oak Video Editor that " + "is no longer supported in this version."); + break; + case OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_NEW: + set_error("This project is from a newer version of Oak Video Editor " + "and cannot be opened in this version."); + break; + case OAKNODE_SERIALIZER_RESULT_UNKNOWN_VERSION: + set_error("Failed to determine project version."); + break; + case OAKNODE_SERIALIZER_RESULT_FILE_ERROR: + set_error("Failed to read file \"" + get_filename() + + "\" for reading."); + break; + case OAKNODE_SERIALIZER_RESULT_XML_ERROR: + set_error(std::string( + "Failed to read XML document. File may be corrupt. " + "Error was: ") + + details); + break; + case OAKNODE_SERIALIZER_RESULT_NO_DATA: + set_error("Failed to find any data to parse."); + break; + default: + set_error("Unknown error."); + break; + } + + if (result == OAKNODE_OK && success) { + return true; + } + + oaknode_project_free(project_); + project_ = nullptr; + return false; +} + +} diff --git a/src/task/src/project/load/load.h b/src/task/src/project/load/load.h new file mode 100644 index 000000000..a37075e5c --- /dev/null +++ b/src/task/src/project/load/load.h @@ -0,0 +1,73 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_PROJECTLOADMANAGER_H +#define OAK_PROJECTLOADMANAGER_H + +#include + +#include "node/project.h" +#include "task.h" + +namespace olive +{ + +/** + * @brief Base task for loading a project from a file + */ +class ProjectLoadBaseTask : public Task { +public: + ProjectLoadBaseTask(const std::string &filename); + + /** + * @brief Take the loaded project (ownership transfer). NULL if the + * task has not succeeded. + */ + OakNodeProject *take_project() + { + OakNodeProject *p = project_; + project_ = nullptr; + return p; + } + + const std::string &get_filename() const + { + return filename_; + } + +protected: + OakNodeProject *project_; + +private: + std::string filename_; +}; + +class ProjectLoadTask : public ProjectLoadBaseTask { +public: + ProjectLoadTask(const std::string &filename); + +protected: + virtual bool run() override; +}; + +} + +#endif // OAK_PROJECTLOADMANAGER_H diff --git a/src/task/src/project/load/loadbasetask.cpp.pending b/src/task/src/project/load/loadbasetask.cpp.pending new file mode 100644 index 000000000..c8a54b3b5 --- /dev/null +++ b/src/task/src/project/load/loadbasetask.cpp.pending @@ -0,0 +1,34 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "loadbasetask.h" + +namespace olive +{ + +ProjectLoadBaseTask::ProjectLoadBaseTask(const QString &filename) + : project_(nullptr) + , filename_(filename) +{ + set_title(tr("Loading '%1'").arg(filename)); +} + +} diff --git a/src/task/src/project/load/loadbasetask.h.pending b/src/task/src/project/load/loadbasetask.h.pending new file mode 100644 index 000000000..411093546 --- /dev/null +++ b/src/task/src/project/load/loadbasetask.h.pending @@ -0,0 +1,63 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_PROJECTLOADBASETASK_H +#define OAK_PROJECTLOADBASETASK_H + +#include "node/project.h" +#include "node/project/serializer/serializedlayoutinfo.h" +#include "task/task.h" + +namespace olive +{ + +class ProjectLoadBaseTask : public Task { + Q_OBJECT +public: + ProjectLoadBaseTask(const QString &filename); + + Project *get_loaded_project() const + { + return project_; + } + + const QString &get_filename() const + { + return filename_; + } + + const SerializedLayoutInfo &get_loaded_layout() const + { + return layout_; + } + +protected: + Project *project_; + + SerializedLayoutInfo layout_; + +private: + QString filename_; +}; + +} + +#endif // LOADBASETASK_H diff --git a/src/task/src/project/loadotio/loadotio.cpp.pending b/src/task/src/project/loadotio/loadotio.cpp.pending new file mode 100644 index 000000000..4ea804d8d --- /dev/null +++ b/src/task/src/project/loadotio/loadotio.cpp.pending @@ -0,0 +1,377 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "loadotio.h" + +#ifdef USE_OTIO + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "coreengine.h" +#include "node/audio/volume/volume.h" +#include "node/block/clip/clip.h" +#include "node/block/gap/gap.h" +#include "node/block/transition/crossdissolve/crossdissolvetransition.h" +#include "node/distort/transform/transformdistortnode.h" +#include "node/generator/matrix/matrix.h" +#include "node/math/math/math.h" +#include "node/nodeundo.h" +#include "node/project/folder/folder.h" +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "timeline/timelineundogeneral.h" + +namespace olive +{ + +LoadOTIOTask::LoadOTIOTask(const QString &s) + : ProjectLoadBaseTask(s) +{ +} + +bool LoadOTIOTask::run() +{ + OTIO::ErrorStatus es; + + auto root = OTIO::SerializableObjectWithMetadata::from_json_file( + get_filename().toStdString(), &es); + + if (es.outcome != OTIO::ErrorStatus::Outcome::OK) { + set_error( + tr("Failed to load OpenTimelineIO from file \"%1\" \n\nOpenTimelineIO Error:\n\n%2") + .arg(get_filename(), + QString::fromStdString(es.full_description))); + return false; + } + + project_ = new Project(); + project_->initialize(); + project_->set_modified(true); + + std::vector timelines; + + if (root->schema_name() == "SerializableCollection") { + // This is a number of timelines + std::vector> + &root_children = + static_cast(root)->children(); + + timelines.resize(root_children.size()); + for (size_t j = 0; j < root_children.size(); j++) { + timelines[j] = + static_cast(root_children[j].value); + } + } else if (root->schema_name() == "Timeline") { + // This is a single timeline + timelines.push_back(static_cast(root)); + } else { + // Unknown root, we don't know what to do with this + set_error(tr("Unknown OpenTimelineIO root element")); + delete project_; + project_ = nullptr; + return false; + } + + // Keep track of imported footage + QMap imported_footage; + QMap timeline_sequnce_map; + + // Variables used for loading bar + float number_of_clips = 0; + float clips_done = 0; + + // Generate a list of sequences with the same names as the timelines. + // Assumes each timeline has a unique name. + int unnamed_sequence_count = 0; + for (auto timeline : timelines) { + Sequence *sequence = new Sequence(); + if (!timeline->name().empty()) { + sequence->set_label(QString::fromStdString(timeline->name())); + } else { + // If the otio timeline does not provide a name, create a default one here + unnamed_sequence_count++; + QString label = tr("Sequence %1").arg(unnamed_sequence_count); + sequence->set_label(QString::fromStdString(label.toStdString())); + } + // Set default params incase they aren't edited. + sequence->set_default_parameters(); + timeline_sequnce_map.insert(timeline, sequence); + + // Get number of clips for loading bar + for (auto track : timeline->tracks()->children()) { + auto otio_track = static_cast(track.value); + number_of_clips += otio_track->children().size(); + } + } + + // Dialog has to be called from the main thread so we pass the list of sequences here. + bool accepted = false; + QMetaObject::invokeMethod( + EngineCore::instance(), "show_otio_import_dialog", Qt::BlockingQueuedConnection, + Q_RETURN_ARG(bool, accepted), + Q_ARG(QList, timeline_sequnce_map.values())); + + if (!accepted) { + // Cancel to indicate to caller that this task did not complete and to simply dispose of it + Cancel(); + qDeleteAll(timeline_sequnce_map); // Clear sequences + return true; + } + + foreach (auto timeline, timeline_sequnce_map.keys()) { + Sequence *sequence = timeline_sequnce_map.value(timeline); + sequence->setParent(project_); + FolderAddChild(project_->root(), sequence).redo_now(); + + // Create a folder for this sequence's footage + Folder *sequence_footage = new Folder(); + sequence_footage->set_label(QString::fromStdString(timeline->name())); + sequence_footage->setParent(project_); + FolderAddChild(project_->root(), sequence_footage).redo_now(); + + // Iterate through tracks + for (auto c : timeline->tracks()->children()) { + auto otio_track = static_cast(c.value); + + // Create a new track + Track *track = nullptr; + + // Determine what kind of track it is + if (otio_track->kind() == "Video" || + otio_track->kind() == "Audio") { + Track::Type type; + + if (otio_track->kind() == "Video") { + type = Track::k_video; + } else { + type = Track::k_audio; + } + + // Create track + TimelineAddTrackCommand t(sequence->track_list(type)); + t.redo_now(); + track = t.track(); + } else { + qWarning() << "Found unknown track type:" + << otio_track->kind().c_str(); + continue; + } + + // Get clips from track + auto clip_map = otio_track->children(); + if (es.outcome != OTIO::ErrorStatus::Outcome::OK) { + set_error(tr("Failed to load clip")); + return false; + } + + Block *previous_block = nullptr; + bool prev_block_transition = false; + + for (auto otio_block_retainer : clip_map) { + auto otio_block = otio_block_retainer.value; + + Block *block = nullptr; + + if (otio_block->schema_name() == "Clip") { + block = new ClipBlock(); + + } else if (otio_block->schema_name() == "Gap") { + block = new GapBlock(); + + } else if (otio_block->schema_name() == "Transition") { + // Todo: Look into OTIO supported transitions and add them to Olive + block = new CrossDissolveTransition(); + + } else { + // We don't know what this is yet, just create a gap for now so that *something* is there + qWarning() << "Found unknown block type:" + << otio_block->schema_name().c_str(); + block = new GapBlock(); + } + + block->setParent(project_); + block->set_label(QString::fromStdString(otio_block->name())); + + track->append_block(block); + + Rational start_time; + Rational duration; + + if (otio_block->schema_name() == "Clip" || + otio_block->schema_name() == "Gap") { + start_time = Rational::from_double( + static_cast(otio_block) + ->source_range() + ->start_time() + .to_seconds()); + duration = Rational::from_double( + static_cast(otio_block) + ->source_range() + ->duration() + .to_seconds()); + + if (otio_block->schema_name() == "Clip") { + static_cast(block)->set_media_in( + start_time); + } + block->set_length_and_media_out(duration); + } + + // If the previous block was a transition, connect the current block to it + if (prev_block_transition) { + TransitionBlock *previous_transition_block = + static_cast(previous_block); + Node::connect_edge( + block, NodeInput(previous_transition_block, + TransitionBlock::k_in_block_input)); + prev_block_transition = false; + } + + if (otio_block->schema_name() == "Transition") { + TransitionBlock *transition_block = + static_cast(block); + OTIO::Transition *otio_block_transition = + static_cast(otio_block); + + // Set how far the transition eats into the previous clip + transition_block->set_offsets_and_length( + Rational::fromRationalTime( + otio_block_transition->in_offset()), + Rational::fromRationalTime( + otio_block_transition->out_offset())); + + if (previous_block) { + Node::connect_edge( + previous_block, + NodeInput(transition_block, + TransitionBlock::k_out_block_input)); + } + prev_block_transition = true; + + // Add nodes to the graph and set up contexts + block->setParent(sequence->parent()); + + // Position transition in its own context + block->set_node_position_in_context(block, QPointF(0, 0)); + } + + if (otio_block->schema_name() == "Gap") { + // Add nodes to the graph and set up contexts + block->setParent(sequence->parent()); + + // Position transition in its own context + block->set_node_position_in_context(block, QPointF(0, 0)); + } + + // Update this after it's used but before any continue statements + previous_block = block; + + if (otio_block->schema_name() == "Clip") { + auto otio_clip = static_cast(otio_block); + if (!otio_clip->media_reference()) { + continue; + } + if (otio_clip->media_reference()->schema_name() == + "ExternalReference") { + // Link footage + QString footage_url = QString::fromStdString( + static_cast( + otio_clip->media_reference()) + ->target_url()); + + Footage *probed_item; + + if (imported_footage.contains(footage_url)) { + probed_item = imported_footage.value(footage_url); + } else { + probed_item = new Footage(footage_url); + imported_footage.insert(footage_url, probed_item); + probed_item->setParent(project_); + + QFileInfo info(probed_item->filename()); + probed_item->set_label(info.fileName()); + + FolderAddChild add(sequence_footage, probed_item); + add.redo_now(); + } + + // Add nodes to the graph and set up contexts + block->setParent(sequence->parent()); + + // Position clip in its own context + block->set_node_position_in_context(block, QPointF(0, 0)); + + // Position footage in its context + block->set_node_position_in_context(probed_item, + QPointF(-2, 0)); + + if (track->type() == Track::k_video) { + TransformDistortNode *transform = + new TransformDistortNode(); + transform->setParent(sequence->parent()); + + Node::connect_edge( + probed_item, + NodeInput(transform, + TransformDistortNode::k_texture_input)); + Node::connect_edge(transform, + NodeInput(block, + ClipBlock::k_buffer_in)); + block->set_node_position_in_context(transform, + QPointF(-1, 0)); + } else { + VolumeNode *volume_node = new VolumeNode(); + volume_node->setParent(sequence->parent()); + + Node::connect_edge( + probed_item, + NodeInput(volume_node, + VolumeNode::k_samples_input)); + Node::connect_edge(volume_node, + NodeInput(block, + ClipBlock::k_buffer_in)); + block->set_node_position_in_context(volume_node, + QPointF(-1, 0)); + } + } + } + clips_done++; + emit progress_changed(clips_done / number_of_clips); + } + } + } + + project_->moveToThread(qApp->thread()); + + return true; +} + +} + +#endif // USE_OTIO diff --git a/src/task/src/project/loadotio/loadotio.h.pending b/src/task/src/project/loadotio/loadotio.h.pending new file mode 100644 index 000000000..59f94e655 --- /dev/null +++ b/src/task/src/project/loadotio/loadotio.h.pending @@ -0,0 +1,47 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_OTIODECODER_H +#define OAK_OTIODECODER_H + +#ifdef USE_OTIO + +#include "common/otioutils.h" +#include "node/project.h" +#include "task/project/load/loadbasetask.h" + +namespace olive +{ + +class LoadOTIOTask : public ProjectLoadBaseTask { + Q_OBJECT +public: + LoadOTIOTask(const QString &filename); + +protected: + virtual bool run() override; +}; + +} + +#endif + +#endif // OAK_OTIODECODER_H diff --git a/src/task/src/project/save/save.cpp b/src/task/src/project/save/save.cpp new file mode 100644 index 000000000..9724ce737 --- /dev/null +++ b/src/task/src/project/save/save.cpp @@ -0,0 +1,98 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "save.h" + +#include "node/serializer.h" + +namespace olive +{ + +namespace +{ + +std::string project_filename(OakNodeProject *project) +{ + int needed = oaknode_project_filename(project, nullptr, 0); + if (needed <= 0) { + return std::string(); + } + std::string filename(size_t(needed), 0); + oaknode_project_filename(project, filename.data(), needed); + filename.resize(size_t(needed) - 1); + return filename; +} + +} // namespace + +ProjectSaveTask::ProjectSaveTask(OakNodeProject *project, + bool use_compression) + : project_(project) + , use_compression_(use_compression) +{ + set_title("Saving '" + project_filename(project_) + "'"); +} + +bool ProjectSaveTask::run() +{ + std::string using_filename = override_filename_.empty() + ? project_filename(project_) + : override_filename_; + + if (using_filename.empty()) { + set_error("Project has no filename to save to."); + return false; + } + + int code = OAKNODE_SERIALIZER_RESULT_FILE_ERROR; + char details[512]; + details[0] = 0; + int result = oaknode_serializer_save_to_file( + project_, using_filename.c_str(), use_compression_ ? 1 : 0, &code, + details, sizeof(details)); + + bool success = false; + switch (code) { + case OAKNODE_SERIALIZER_RESULT_SUCCESS: + success = true; + break; + case OAKNODE_SERIALIZER_RESULT_XML_ERROR: + set_error("Failed to write XML data."); + break; + case OAKNODE_SERIALIZER_RESULT_FILE_ERROR: + set_error(std::string("Failed to open file for writing: ") + details); + break; + case OAKNODE_SERIALIZER_RESULT_OVERWRITE_ERROR: + set_error("Failed to overwrite \"" + using_filename + + "\". Project has been saved as \"" + details + + "\" instead."); + success = true; + break; + default: + set_error("Unknown error."); + break; + } + + (void)result; + return success; +} + +} diff --git a/src/task/src/project/save/save.h b/src/task/src/project/save/save.h new file mode 100644 index 000000000..1cc8c5422 --- /dev/null +++ b/src/task/src/project/save/save.h @@ -0,0 +1,60 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_PROJECTSAVEMANAGER_H +#define OAK_PROJECTSAVEMANAGER_H + +#include + +#include "node/project.h" +#include "task.h" + +namespace olive +{ + +class ProjectSaveTask : public Task { +public: + ProjectSaveTask(OakNodeProject *project, bool use_compression); + + OakNodeProject *get_project() const + { + return project_; + } + + void set_override_filename(const std::string &filename) + { + override_filename_ = filename; + } + +protected: + virtual bool run() override; + +private: + OakNodeProject *project_; + + std::string override_filename_; + + bool use_compression_; +}; + +} + +#endif // OAK_PROJECTSAVEMANAGER_H diff --git a/src/task/src/project/saveotio/saveotio.cpp.pending b/src/task/src/project/saveotio/saveotio.cpp.pending new file mode 100644 index 000000000..0eac31175 --- /dev/null +++ b/src/task/src/project/saveotio/saveotio.cpp.pending @@ -0,0 +1,278 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "saveotio.h" + +#ifdef USE_OTIO + +#include +#include +#include +#include +#include +#include + +#include "node/block/clip/clip.h" +#include "node/block/gap/gap.h" +#include "node/block/transition/transition.h" +#include "node/project/footage/footage.h" + +namespace olive +{ + +SaveOTIOTask::SaveOTIOTask(Project *project) + : project_(project) +{ + set_title(tr("Exporting project to OpenTimelineIO")); +} + +bool SaveOTIOTask::run() +{ + QVector sequences = + project_->root()->list_children_of_type(); + + if (sequences.isEmpty()) { + set_error(tr("Project contains no sequences to export.")); + return false; + } + + std::vector serialized; + + foreach (Sequence *seq, sequences) { + auto otio_timeline = SerializeTimeline(seq); + + if (otio_timeline) { + // Append to list + serialized.push_back(otio_timeline); + } else { + // Delete all existing timelines + for (auto s : serialized) { + s->possibly_delete(); + } + + // Error out of function + set_error( + tr("Failed to serialize sequence \"%1\"").arg(seq->get_label())); + + return false; + } + } + + OTIO::ErrorStatus es; + + if (serialized.size() == 1) { + // Serialize timeline on its own + auto t = serialized.front(); + t->to_json_file(project_->filename().toStdString(), &es); + t->possibly_delete(); + } else { + // Serialize all into a SerializableCollection + auto collection = + new OTIO::SerializableCollection("Sequences", serialized); + collection->to_json_file(project_->filename().toStdString(), &es); + collection->possibly_delete(); + + // Delete all existing timelines + for (auto s : serialized) { + s->possibly_delete(); + } + } + + return (es.outcome == OTIO::ErrorStatus::Outcome::OK); +} + +OTIO::Timeline *SaveOTIOTask::SerializeTimeline(Sequence *sequence) +{ + auto otio_timeline = new OTIO::Timeline(sequence->get_label().toStdString()); + // Retainers clean themselves up when the final user is removed + OTIO::Timeline::Retainer *timeline_retainer = + new OTIO::Timeline::Retainer(otio_timeline); + // Suppress unused variable warning + Q_UNUSED(timeline_retainer); + + double rate = sequence->get_video_params().frame_rate().to_double(); + if (qIsNaN(rate)) { + return nullptr; + } + + if (!SerializeTrackList(sequence->track_list(Track::k_video), otio_timeline, + rate) || + !SerializeTrackList(sequence->track_list(Track::k_audio), otio_timeline, + rate)) { + otio_timeline->possibly_delete(); + return nullptr; + } + + return otio_timeline; +} + +OTIO::Track *SaveOTIOTask::SerializeTrack(Track *track, double sequence_rate, + Rational max_track_length) +{ + auto otio_track = new OTIO::Track(); + + OTIO::ErrorStatus es; + + switch (track->type()) { + case Track::k_video: + otio_track->set_kind("Video"); + break; + case Track::k_audio: + otio_track->set_kind("Audio"); + break; + default: + qWarning() + << "Don't know OTIO track kind for native type" << track->type(); + goto fail; + } + + foreach (Block *block, track->blocks()) { + OTIO::Composable *otio_block = nullptr; + + if (dynamic_cast(block)) { + auto otio_clip = new OTIO::Clip(block->get_label().toStdString()); + + otio_clip->set_source_range( + OTIO::TimeRange(block->in().toRationalTime(sequence_rate), + block->length().toRationalTime(sequence_rate))); + + QVector media_nodes = block->find_input_nodes(); + if (!media_nodes.isEmpty()) { + OTIO::TimeRange available_range; + if (otio_track->kind().compare("Video") == 0) { + // OTIO ExternalReference uses the source clips frame rate (or sample rate) as opposed to + // the sequences rate + double source_frame_rate = static_cast(block) + ->connected_viewer() + ->get_video_params() + .frame_rate() + .to_double(); + available_range = OTIO::TimeRange( + OTIO::RationalTime(0, source_frame_rate), + OTIO::RationalTime( + media_nodes.first()->get_video_params().duration(), + source_frame_rate)); + } else if (otio_track->kind().compare("Audio") == 0) { + available_range = OTIO::TimeRange( + OTIO::RationalTime( + 0, + media_nodes.first()->get_audio_params().sample_rate()), + OTIO::RationalTime( + media_nodes.first()->get_audio_params().duration(), + media_nodes.first()->get_audio_params().sample_rate())); + } + auto media_ref = new OTIO::ExternalReference( + media_nodes.first()->filename().toStdString(), + available_range); + otio_clip->set_media_reference(media_ref); + } + + otio_block = otio_clip; + } else if (dynamic_cast(block)) { + otio_block = + new OTIO::Gap(OTIO::TimeRange(block->in().toRationalTime(), + block->length().toRationalTime()), + block->get_label().toStdString()); + } else if (dynamic_cast(block)) { + auto otio_transition = + new OTIO::Transition(block->get_label().toStdString()); + + TransitionBlock *our_transition = + static_cast(block); + + otio_transition->set_in_offset( + our_transition->in_offset().toRationalTime()); + otio_transition->set_out_offset( + our_transition->out_offset().toRationalTime()); + + otio_block = new OTIO::Transition(); + } + + if (!otio_block) { + // We shouldn't ever get here, but catch without crashing if we ever do + goto fail; + } + + otio_track->append_child(otio_block, &es); + + if (es.outcome != OTIO::ErrorStatus::Outcome::OK) { + goto fail; + } + } + + // All OTIO tracks must have the same duration so we add a Gap to fill the remaining time + if (otio_track->duration(&es).to_seconds() < max_track_length.to_double()) { + double time_left = max_track_length.to_double() - + otio_track->duration(&es).to_seconds(); + + OTIO::Gap *gap = new OTIO::Gap(OTIO::TimeRange( + otio_track->duration(&es), OTIO::RationalTime(time_left, 1.0))); + otio_track->append_child(gap, &es); + + if (es.outcome != OTIO::ErrorStatus::Outcome::OK) { + goto fail; + } + } + + return otio_track; + +fail: + otio_track->possibly_delete(); + + return nullptr; +} + +bool SaveOTIOTask::SerializeTrackList(TrackList *list, + OTIO::Timeline *otio_timeline, + double sequence_rate) +{ + OTIO::ErrorStatus es; + + Rational max_track_length = RATIONAL_MIN; + + foreach (Track *track, list->get_tracks()) { + if (track->track_length() > max_track_length) { + max_track_length = track->track_length(); + } + } + + foreach (Track *track, list->get_tracks()) { + auto otio_track = + SerializeTrack(track, sequence_rate, max_track_length); + + if (!otio_track) { + return false; + } + + otio_timeline->tracks()->append_child(otio_track, &es); + + if (es.outcome != OTIO::ErrorStatus::Outcome::OK) { + otio_track->possibly_delete(); + return false; + } + } + + return true; +} + +} + +#endif // USE_OTIO diff --git a/src/task/src/project/saveotio/saveotio.h.pending b/src/task/src/project/saveotio/saveotio.h.pending new file mode 100644 index 000000000..c51db6263 --- /dev/null +++ b/src/task/src/project/saveotio/saveotio.h.pending @@ -0,0 +1,61 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_PROJECTSAVEASOTIOTASK_H +#define OAK_PROJECTSAVEASOTIOTASK_H + +#ifdef USE_OTIO + +#include +#include + +#include "common/otioutils.h" +#include "node/project.h" +#include "task/task.h" + +namespace olive +{ + +class SaveOTIOTask : public Task { + Q_OBJECT +public: + SaveOTIOTask(Project *project); + +protected: + virtual bool run() override; + +private: + OTIO::Timeline *SerializeTimeline(Sequence *sequence); + + OTIO::Track *SerializeTrack(Track *track, double sequence_rate, + Rational max_track_length); + + bool SerializeTrackList(TrackList *list, OTIO::Timeline *otio_timeline, + double sequence_rate); + + Project *project_; +}; + +} + +#endif + +#endif // OAK_PROJECTSAVEASOTIOTASK_H diff --git a/src/task/src/proxy/proxy.cpp b/src/task/src/proxy/proxy.cpp new file mode 100644 index 000000000..24abed15c --- /dev/null +++ b/src/task/src/proxy/proxy.cpp @@ -0,0 +1,310 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "proxy.h" + +#include +#include +#include +#include + +namespace olive +{ + +namespace +{ + +std::string shell_quote(const std::string &s) +{ + std::string out = "'"; + for (char c : s) { + if (c == '\'') { + out += "'\\''"; + } else { + out += c; + } + } + out += "'"; + return out; +} + +/** + * @brief Probes the source duration with the ffprobe next to ffmpeg + * + * Returns 0 when ffprobe is unavailable or the duration cannot be + * determined, in which case the task simply reports no intermediate + * progress. + */ +double probe_source_duration_seconds(const std::string &ffmpeg_path, + const std::string &source_filename) +{ + std::filesystem::path ffprobe = + std::filesystem::path(ffmpeg_path).parent_path() / "ffprobe"; +#if defined(_WIN32) + ffprobe += ".exe"; +#endif + std::error_code ec; + if (!std::filesystem::exists(ffprobe, ec)) { + return 0.0; + } + + std::string cmd = shell_quote(ffprobe.string()) + + " -v error -show_entries format=duration" + " -of default=noprint_wrappers=1:nokey=1 " + + shell_quote(source_filename); + FILE *pipe = popen(cmd.c_str(), "r"); + if (!pipe) { + return 0.0; + } + char buf[128] = { 0 }; + std::string output; + while (fgets(buf, sizeof(buf), pipe)) { + output += buf; + } + if (pclose(pipe) != 0) { + return 0.0; + } + return strtod(output.c_str(), nullptr); +} + +} // namespace + +ProxyTask::ProxyTask(const OakCodecTaskRequest &request, + const oakcodec_proxy_params ¶ms) + : source_filename_(request.input_filename ? request.input_filename : "") + , stream_index_(request.stream_index) + , params_(params) + , output_filename_(request.output_filename ? request.output_filename + : "") +{ + // Divider-based requests (width/height == 0) take the source fraction + if (request.proxy_width > 0 && request.proxy_height > 0) { + params_.width = request.proxy_width; + params_.height = request.proxy_height; + params_.divider = 1; + } + + set_title("Generating Proxy " + source_filename_ + ":" + + std::to_string(stream_index_)); +} + +std::vector +ProxyTask::build_arguments(const std::string &source_filename, + int stream_index, + const oakcodec_proxy_params ¶ms, + const std::string &output_filename) +{ + std::string scale_filter; + if (params.divider > 1) { + // Fraction of the source resolution, rounded down to even dimensions + // as required by yuv420p + scale_filter = "scale=w=trunc(iw/" + std::to_string(params.divider) + + "/2)*2:h=trunc(ih/" + std::to_string(params.divider) + + "/2)*2"; + } else { + scale_filter = "scale=w=" + std::to_string(params.width) + + ":h=" + std::to_string(params.height) + + ":force_original_aspect_ratio=decrease"; + } + + const std::string container_format = + params.extension[0] ? params.extension : "mp4"; + + std::vector args; + args.emplace_back("-y"); + // Report machine-readable progress on stdout for the task dialog + args.emplace_back("-nostats"); + args.emplace_back("-progress"); + args.emplace_back("pipe:1"); + args.emplace_back("-i"); + args.emplace_back(source_filename); + // Map the requested video stream first so it is stream 0 in the proxy + args.emplace_back("-map"); + args.emplace_back("0:" + std::to_string(stream_index)); + + if (params.include_audio) { + // Keep the source audio (if any) so the proxy can also be used for + // audio preview. Audio streams follow the video stream in source order. + args.emplace_back("-map"); + args.emplace_back("0:a?"); + args.emplace_back("-c:a"); + args.emplace_back("aac"); + args.emplace_back("-b:a"); + args.emplace_back("128k"); + } else { + args.emplace_back("-an"); + } + + args.emplace_back("-vf"); + args.emplace_back(scale_filter); + args.emplace_back("-c:v"); + args.emplace_back("libx264"); + args.emplace_back("-preset"); + args.emplace_back(params.preset); + args.emplace_back("-crf"); + args.emplace_back(std::to_string(params.crf)); + args.emplace_back("-pix_fmt"); + args.emplace_back("yuv420p"); + args.emplace_back("-movflags"); + args.emplace_back("+faststart"); + args.emplace_back("-f"); + args.emplace_back(container_format); + args.emplace_back(output_filename); + + return args; +} + +double ProxyTask::parse_progress(const std::string &line, + double duration_seconds) +{ + if (duration_seconds <= 0.0) { + return -1.0; + } + + int64_t out_time_us = -1; + if (line.rfind("out_time_us=", 0) == 0) { + out_time_us = strtoll(line.c_str() + 12, nullptr, 10); + } else if (line.rfind("out_time_ms=", 0) == 0) { + // Despite the name, ffmpeg reports this value in microseconds + out_time_us = strtoll(line.c_str() + 12, nullptr, 10); + } + + if (out_time_us < 0) { + return -1.0; + } + + double progress = out_time_us / 1000000.0 / duration_seconds; + return progress < 0.0 ? 0.0 : (progress > 1.0 ? 1.0 : progress); +} + +bool ProxyTask::run() +{ + char ffmpeg_buf[1024]; + if (oakcodec_proxy_find_ffmpeg(nullptr, ffmpeg_buf, + sizeof(ffmpeg_buf)) <= 0) { + set_error("Failed to generate proxy: ffmpeg executable was not " + "found. Set the ffmpeg path in Preferences > Disk > Proxy " + "Settings."); + fprintf(stderr, "ProxyTask: ffmpeg executable not found\n"); + return false; + } + + std::filesystem::path output_dir = + std::filesystem::path(output_filename_).parent_path(); + std::error_code ec; + if (!output_dir.empty() && !std::filesystem::exists(output_dir, ec) && + !std::filesystem::create_directories(output_dir, ec)) { + set_error("Failed to create proxy output directory"); + return false; + } + + fprintf(stderr, "ProxyTask: starting ffmpeg proxy generation: %s -> %s\n", + source_filename_.c_str(), output_filename_.c_str()); + + std::filesystem::remove(output_filename_, ec); + + const std::string working_filename = output_filename_ + ".working.mp4"; + std::filesystem::remove(working_filename, ec); + + const std::vector args = build_arguments( + source_filename_, stream_index_, params_, working_filename); + + std::string cmd = shell_quote(ffmpeg_buf); + for (const std::string &arg : args) { + cmd += " "; + cmd += shell_quote(arg); + } + cmd += " 2>&1"; + + const double duration_seconds = + probe_source_duration_seconds(ffmpeg_buf, source_filename_); + + FILE *process = popen(cmd.c_str(), "r"); + if (!process) { + set_error("Failed to start ffmpeg for proxy generation"); + return false; + } + + std::string progress_buffer; + double last_progress = 0.0; + char buf[256]; + + while (true) { + size_t n = fread(buf, 1, sizeof(buf), process); + if (n > 0) { + progress_buffer.append(buf, n); + size_t newline; + while ((newline = progress_buffer.find('\n')) != + std::string::npos) { + std::string line = progress_buffer.substr(0, newline); + progress_buffer.erase(0, newline + 1); + double progress = parse_progress(line, duration_seconds); + if (progress >= 0.0 && progress - last_progress > 0.001) { + last_progress = progress; + emit_progress(progress); + } + } + } + + if (n < sizeof(buf)) { + if (feof(process)) { + break; + } + if (ferror(process)) { + break; + } + } + + if (is_cancelled()) { + pclose(process); + std::filesystem::remove(working_filename, ec); + set_error("Proxy generation was cancelled"); + return false; + } + } + + int exit_status = pclose(process); + if (exit_status != 0) { + std::filesystem::remove(working_filename, ec); + set_error("ffmpeg failed to generate proxy"); + fprintf(stderr, "ProxyTask: ffmpeg failed with exit status %d\n", + exit_status); + return false; + } + + if (!std::filesystem::exists(working_filename, ec)) { + set_error("ffmpeg finished but proxy file was not created"); + return false; + } + + std::filesystem::rename(working_filename, output_filename_, ec); + if (ec) { + set_error("Failed to move proxy into place"); + return false; + } + + fprintf(stderr, "ProxyTask: proxy generation succeeded: %s\n", + output_filename_.c_str()); + emit_progress(1.0); + return true; +} + +} diff --git a/src/task/src/proxy/proxy.h b/src/task/src/proxy/proxy.h new file mode 100644 index 000000000..c3510e4eb --- /dev/null +++ b/src/task/src/proxy/proxy.h @@ -0,0 +1,75 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_PROXYTASK_H +#define OAK_PROXYTASK_H + +#include +#include + +#include "codec/proxy.h" +#include "codec/task.h" +#include "task.h" + +namespace olive +{ + +class ProxyTask : public Task { +public: + ProxyTask(const OakCodecTaskRequest &request, + const oakcodec_proxy_params ¶ms); + + /** + * @brief Builds the ffmpeg command line for a proxy generation run + * + * Extracted for testability. The video stream is always mapped first so + * that it is stream 0 in the proxy file; audio streams (when enabled) + * follow in source order. + */ + static std::vector build_arguments( + const std::string &source_filename, int stream_index, + const oakcodec_proxy_params ¶ms, + const std::string &output_filename); + + /** + * @brief Parses one line of ffmpeg "-progress" output + * + * Extracted for testability. If the line carries an output timestamp + * ("out_time_us=" or "out_time_ms="), returns the progress fraction in + * the range [0, 1] against duration_seconds. Returns a negative value + * when the line carries no timestamp or duration_seconds is unknown. + */ + static double parse_progress(const std::string &line, + double duration_seconds); + +protected: + virtual bool run() override; + +private: + std::string source_filename_; + int stream_index_; + oakcodec_proxy_params params_; + std::string output_filename_; +}; + +} + +#endif // OAK_PROXYTASK_H diff --git a/src/task/src/render/render.cpp.pending b/src/task/src/render/render.cpp.pending new file mode 100644 index 000000000..4028f5fe4 --- /dev/null +++ b/src/task/src/render/render.cpp.pending @@ -0,0 +1,352 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "render.h" + +#include "node/project/sequence/sequence.h" +#include "render/rendermanager.h" + +namespace olive +{ + +RenderTask::RenderTask() + : running_tickets_(0) + , native_progress_signalling_(true) +{ +} + +RenderTask::~RenderTask() +{ +} + +bool RenderTask::render(ColorManager *manager, const TimeRangeList &video_range, + const TimeRangeList &audio_range, + const TimeRange &subtitle_range, RenderMode::Mode mode, + FrameHashCache *cache, const QSize &force_size, + const QMatrix4x4 &force_matrix, + PixelFormat force_format, int force_channel_count, + ColorProcessorPtr force_color_output, + const ColorTransform &force_color_transform) +{ + QMetaObject::invokeMethod(RenderManager::instance(), + "SetAggressiveGarbageCollection", + Q_ARG(bool, true)); + + // Run watchers in another thread so they can accept signals even while this thread is blocked + QThread watcher_thread; + watcher_thread.start(); + + double progress_counter = 0; + double total_length = 0; + + // Store real time before any rendering takes place + // Queue audio jobs + for (const TimeRange &range : audio_range) { + // Don't count audio progress, since it's generally a lot faster than video and is weighted at + // 50%, which makes the progress bar look weird to the uninitiated + //total_length += r.length().toDouble(); + + RenderManager::RenderAudioParams rap( + viewer_->get_connected_sample_output(), range, audio_params_, + RenderMode::k_online); + + RenderTicketWatcher *watcher = new RenderTicketWatcher(); + watcher->setProperty("range", QVariant::fromValue(range)); + prepare_watcher(watcher, &watcher_thread); + increment_running_tickets(); + watcher->set_ticket(RenderManager::instance()->render_audio(rap)); + } + + // Look up hashes + TimeRangeListFrameIterator iterator( + video_range, video_params().frame_rate_as_time_base()); + total_number_of_frames_ = iterator.size(); + total_length += total_number_of_frames_; + + // Start a render of a limited amount, and then render one frame for each frame that gets + // finished. This prevents rendered frames from stacking up in memory indefinitely while the + // encoder is processing them. The amount is kind of arbitrary, but we use the thread count so + // each of the system's threads are utilized as memory allows. + const int maximum_rendered_frames = QThread::idealThreadCount(); + + Rational next_frame; + for (int i = 0; + i < maximum_rendered_frames && iterator.get_next(&next_frame); i++) { + start_ticket(&watcher_thread, manager, next_frame, mode, cache, + force_size, force_matrix, force_format, force_channel_count, + force_color_output, force_color_transform); + } + + bool result = true; + + // Subtitle loop, loops over all blocks in sequence on all tracks + if (!subtitle_range.length().isNull()) { + if (Sequence *sequence = dynamic_cast(viewer_)) { + TrackList *list = sequence->track_list(Track::k_subtitle); + QVector block_indexes(list->get_track_count(), 0); + + QVector tracks_to_push; + do { + tracks_to_push.clear(); + + for (int i = 0; i < block_indexes.size(); i++) { + Track *this_track = list->get_track_at(i); + if (this_track->is_muted()) { + continue; + } + + int &this_block_index = block_indexes[i]; + if (this_block_index >= this_track->blocks().size()) { + continue; + } + Block *this_block = + this_track->blocks().at(this_block_index); + + Track *compare_track = + tracks_to_push.isEmpty() ? + nullptr : + list->get_track_at(tracks_to_push.first()); + const int &compare_block_index = + tracks_to_push.isEmpty() ? + -1 : + block_indexes.at(tracks_to_push.first()); + Block *compare_block = + compare_track ? + compare_track->blocks().at(compare_block_index) : + nullptr; + if (!compare_track || + compare_block->in() >= this_block->in()) { + if (compare_track && + compare_block->in() != this_block->in()) { + tracks_to_push.clear(); + } + tracks_to_push.append(i); + } + } + + for (int i = 0; i < tracks_to_push.size(); i++) { + Track *this_track = list->get_track_at(tracks_to_push.at(i)); + Block *this_block = this_track->blocks().at( + block_indexes.at(tracks_to_push.at(i))); + + if (const SubtitleBlock *sub = + dynamic_cast(this_block)) { + if (sub->is_enabled()) { + if (!encode_subtitle(sub)) { + result = false; + break; + } + } + } + + block_indexes[tracks_to_push.at(i)]++; + } + } while (!tracks_to_push.isEmpty()); + } + } + + finished_watcher_mutex_.lock(); + + while (result && !is_cancelled()) { + while (!finished_watchers_.empty() && !is_cancelled() && result) { + RenderTicketWatcher *watcher = finished_watchers_.front(); + finished_watchers_.pop_front(); + + finished_watcher_mutex_.unlock(); + + // Analyze watcher here + RenderManager::TicketType ticket_type = + watcher->get_ticket() + ->property("type") + .value(); + + if (ticket_type == RenderManager::k_type_audio) { + TimeRange range = watcher->property("range").value(); + + if (!audio_downloaded(range, + watcher->get().value())) { + result = false; + } + + // Don't count audio progress, since it's generally a lot faster than video and is weighted at + // 50%, which makes the progress bar look weird to the uninitiated + //progress_counter += range.length().toDouble(); + //emit ProgressChanged(progress_counter / total_length); + + } else if (ticket_type == RenderManager::k_type_video && + two_step_frame_rendering()) { + if (!download_frame( + &watcher_thread, watcher->get().value(), + watcher->property("time").value())) { + result = false; + } + + if (native_progress_signalling_) { + progress_counter += 0.5; + emit progress_changed(progress_counter / total_length); + } + + } else { + // Assume single-step video or video download ticket + if (!frame_downloaded( + watcher->get().value(), + watcher->property("time").value())) { + result = false; + } + + if (native_progress_signalling_) { + double progress_to_add = 1.0; + if (two_step_frame_rendering()) { + progress_to_add *= 0.5; + } + progress_counter += progress_to_add; + + emit progress_changed(progress_counter / total_length); + } + + if (iterator.get_next(&next_frame)) { + start_ticket(&watcher_thread, manager, next_frame, mode, + cache, force_size, force_matrix, force_format, + force_channel_count, force_color_output, + force_color_transform); + } + } + + delete watcher; + running_watchers_.removeOne(watcher); + + finished_watcher_mutex_.lock(); + } + + if (is_cancelled() || !result) { + break; + } + + // Run out of finished watchers. If we still have running tickets, wait for the next one to finish. + if (running_tickets_ > 0) { + finished_watcher_wait_cond_.wait(&finished_watcher_mutex_); + } else { + // No more running tickets or finished tickets, wem ust be + break; + } + } + + finished_watcher_mutex_.unlock(); + + if (is_cancelled() || !result) { + // Cancel every watcher we created + foreach (RenderTicketWatcher *watcher, running_watchers_) { + watcher->cancel(); + disconnect(watcher, &RenderTicketWatcher::finished, this, + &RenderTask::ticket_done); + RenderManager::instance()->remove_ticket(watcher->get_ticket()); + } + + foreach (RenderTicketWatcher *watcher, running_watchers_) { + watcher->wait_for_finished(); + } + } + + watcher_thread.quit(); + watcher_thread.wait(); + + QMetaObject::invokeMethod(RenderManager::instance(), + "SetAggressiveGarbageCollection", + Q_ARG(bool, false)); + + return result; +} + +bool RenderTask::download_frame(QThread *thread, FramePtr frame, + const Rational &time) +{ + //RenderTicketWatcher* watcher = new RenderTicketWatcher(); + //PrepareWatcher(watcher, thread); + + //IncrementRunningTickets(); + + //watcher->SetTicket(RenderManager::instance()->SaveFrameToCache(viewer_->video_frame_cache(), frame, time)); + + // NOTE: Doesn't reflect the actual return result of SaveFrameToCache + return true; +} + +bool RenderTask::encode_subtitle(const SubtitleBlock *subtitle) +{ + Q_UNUSED(subtitle) + return true; +} + +void RenderTask::prepare_watcher(RenderTicketWatcher *watcher, QThread *thread) +{ + watcher->moveToThread(thread); + connect(watcher, &RenderTicketWatcher::finished, this, + &RenderTask::ticket_done, Qt::DirectConnection); + running_watchers_.append(watcher); +} + +void RenderTask::increment_running_tickets() +{ + finished_watcher_mutex_.lock(); + running_tickets_++; + finished_watcher_mutex_.unlock(); +} + +void RenderTask::start_ticket(QThread *watcher_thread, ColorManager *manager, + const Rational &time, RenderMode::Mode mode, + FrameHashCache *cache, const QSize &force_size, + const QMatrix4x4 &force_matrix, + PixelFormat force_format, int force_channel_count, + ColorProcessorPtr force_color_output, + const ColorTransform &force_color_transform) +{ + RenderManager::RenderVideoParams rvp(viewer_->get_connected_texture_output(), + video_params_, audio_params_, time, + manager, mode); + + rvp.force_size = force_size; + rvp.force_matrix = force_matrix; + rvp.force_format = force_format; + rvp.force_color_output = force_color_output; + rvp.force_color_transform = force_color_transform; + rvp.force_channel_count = force_channel_count; + + if (cache) { + rvp.add_cache(cache); + } + + RenderTicketWatcher *watcher = new RenderTicketWatcher(); + watcher->setProperty("time", QVariant::fromValue(time)); + prepare_watcher(watcher, watcher_thread); + increment_running_tickets(); + watcher->set_ticket(RenderManager::instance()->render_frame(rvp)); +} + +void RenderTask::ticket_done(RenderTicketWatcher *watcher) +{ + finished_watcher_mutex_.lock(); + finished_watchers_.push_back(watcher); + finished_watcher_wait_cond_.wakeAll(); + running_tickets_--; + finished_watcher_mutex_.unlock(); +} + +} diff --git a/src/task/src/render/render.h.pending b/src/task/src/render/render.h.pending new file mode 100644 index 000000000..f16204eec --- /dev/null +++ b/src/task/src/render/render.h.pending @@ -0,0 +1,154 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_RENDERTASK_H +#define OAK_RENDERTASK_H + +#include + +#include "node/block/subtitle/subtitle.h" +#include "node/color/colormanager/colormanager.h" +#include "node/output/viewer/viewer.h" +#include "task/task.h" +#include "render/renderticket.h" + +namespace olive +{ + +class RenderTask : public Task { + Q_OBJECT +public: + RenderTask(); + + virtual ~RenderTask() override; + +protected: + bool render(ColorManager *manager, const TimeRangeList &video_range, + const TimeRangeList &audio_range, + const TimeRange &subtitle_range, RenderMode::Mode mode, + FrameHashCache *cache, const QSize &force_size = QSize(0, 0), + const QMatrix4x4 &force_matrix = QMatrix4x4(), + PixelFormat force_format = PixelFormat::invalid, + int force_channel_count = 0, + ColorProcessorPtr force_color_output = nullptr, + const ColorTransform &force_color_transform = ColorTransform()); + + virtual bool download_frame(QThread *thread, FramePtr frame, + const Rational &time); + + virtual bool frame_downloaded(FramePtr frame, const Rational &time) = 0; + + virtual bool audio_downloaded(const TimeRange &range, + const SampleBuffer &samples) = 0; + + virtual bool encode_subtitle(const SubtitleBlock *subtitle); + + ViewerOutput *viewer() const + { + return viewer_; + } + + void set_viewer(ViewerOutput *v) + { + viewer_ = v; + } + + const VideoParams &video_params() const + { + return video_params_; + } + + void set_video_params(const VideoParams &video_params) + { + video_params_ = video_params; + } + + const AudioParams &audio_params() const + { + return audio_params_; + } + + void set_audio_params(const AudioParams &audio_params) + { + audio_params_ = audio_params; + } + + virtual void CancelEvent() override + { + finished_watcher_mutex_.lock(); + finished_watcher_wait_cond_.wakeAll(); + finished_watcher_mutex_.unlock(); + } + + virtual bool two_step_frame_rendering() const + { + return true; + } + + void set_native_progress_signalling_enabled(bool e) + { + native_progress_signalling_ = e; + } + + /** + * @brief Only valid after Render() is called + */ + int64_t get_total_number_of_frames() const + { + return total_number_of_frames_; + } + +private: + void prepare_watcher(RenderTicketWatcher *watcher, QThread *thread); + + void increment_running_tickets(); + + void start_ticket(QThread *watcher_thread, ColorManager *manager, + const Rational &time, RenderMode::Mode mode, + FrameHashCache *cache, const QSize &force_size, + const QMatrix4x4 &force_matrix, PixelFormat force_format, + int force_channel_count, + ColorProcessorPtr force_color_output, + const ColorTransform &force_color_transform); + + ViewerOutput *viewer_; + + VideoParams video_params_; + + AudioParams audio_params_; + + QVector running_watchers_; + std::list finished_watchers_; + int running_tickets_; + QMutex finished_watcher_mutex_; + QWaitCondition finished_watcher_wait_cond_; + + bool native_progress_signalling_; + + int64_t total_number_of_frames_; + +private slots: + void ticket_done(RenderTicketWatcher *watcher); +}; + +} + +#endif // OAK_RENDERTASK_H diff --git a/src/task/src/task.h b/src/task/src/task.h new file mode 100644 index 000000000..0fb7a575d --- /dev/null +++ b/src/task/src/task.h @@ -0,0 +1,233 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_TASK_H +#define OAK_TASK_H + +#include +#include +#include +#include +#include + +#include "render/cancelatom.h" + +#include + +namespace olive +{ + +/** + * @brief A base class for background tasks running in Olive. + * + * Tasks are multithreaded by design (i.e. they will always spawn + * a new thread and run in it). + * + * To subclass your own Task, override run() and return TRUE on success or FALSE on failure. Note that a Task can + * provide a "negative" output and still have succeeded. For example, the ProbeTask's role is to determine whether a + * certain media file can be used in Olive. Even if the probe *fails* to find a Decoder for this file, the Task itself + * has *succeeded* at discovering this. A failure of ProbeTask would indicate a catastrophic failure meaning it was + * unable to determine anything about the file. + * + * Tasks should be used with the TaskManager which will manage starting and deleting them. + * + * De-Qt version: Qt signals are replaced by listener callbacks + * (01 §4: async tasks are the one place callbacks are allowed - they are + * the command's return channel). + */ +class Task { +public: + /** + * @brief Task lifecycle events delivered to listeners + */ + enum EventType { + k_event_started, + k_event_progress, + k_event_finished + }; + + /** + * @brief Listener callback. For k_event_finished, `value` is 1.0 on + * success and 0.0 on failure; for k_event_progress it is 0..1; for + * k_event_started it is the start time in milliseconds. + */ + using EventListener = std::function; + + Task() + : title_("Task") + , error_("Unknown error") + , start_time_(0) + , cancel_atom_(oakrender_cancelatom_init()) + { + } + + virtual ~Task() + { + oakrender_cancelatom_free(&cancel_atom_); + } + + /** + * @brief Retrieve the current title of this Task + */ + const std::string &get_title() const + { + return title_; + } + + /** + * @brief Returns the error that occurred if run() returns false + */ + const std::string &get_error() const + { + return error_; + } + + const int64_t &get_start_time() const + { + return start_time_; + } + + /** + * @brief Register a lifecycle listener (async command return channel) + */ + void add_event_listener(EventListener listener) + { + std::lock_guard lock(listeners_mutex_); + listeners_.push_back(std::move(listener)); + } + + /** + * @brief Run this task + * + * @return True if the task completed successfully, false if not. + * + * \see get_error() if this returns false. + */ + bool start() + { + start_time_ = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + emit_event(k_event_started, double(start_time_)); + + bool ret = run(); + + emit_event(k_event_finished, ret ? 1.0 : 0.0); + + return ret; + } + + /** + * @brief Reset state so that run() can be called again. + */ + virtual void reset() + { + } + + /** + * @brief Cancel the Task + * + * Sends a signal to the Task to stop as soon as possible. Always call this directly. + */ + void cancel() + { + oakrender_cancelatom_cancel(cancel_atom_); + cancel_event(); + } + + bool is_cancelled() const + { + int cancelled = 0; + oakrender_cancelatom_is_cancelled(cancel_atom_, &cancelled); + return cancelled != 0; + } + + /** + * @brief The task's cancellation atom (borrowed handle; addref if + * retained beyond the task's lifetime) + */ + OakCancelAtom get_cancel_atom() const + { + return cancel_atom_; + } + + /** + * @brief Emit a progress value between 0.0 and 1.0 + */ + void emit_progress(double p) + { + emit_event(k_event_progress, p); + } + +protected: + virtual bool run() = 0; + + /** + * @brief Called when the task is cancelled (subclass hook) + */ + virtual void cancel_event() + { + } + + /** + * @brief Set the error message + */ + void set_error(const std::string &s) + { + error_ = s; + } + + /** + * @brief Set the Task title + */ + void set_title(const std::string &s) + { + title_ = s; + } + +private: + void emit_event(EventType type, double value) + { + std::vector listeners; + { + std::lock_guard lock(listeners_mutex_); + listeners = listeners_; + } + for (const EventListener &listener : listeners) { + listener(type, value); + } + } + + std::string title_; + + std::string error_; + + int64_t start_time_; + + OakCancelAtom cancel_atom_; + + std::mutex listeners_mutex_; + std::vector listeners_; +}; + +} + +#endif // OAK_TASK_H diff --git a/src/task/src/taskmanager.cpp b/src/task/src/taskmanager.cpp new file mode 100644 index 000000000..18d15a135 --- /dev/null +++ b/src/task/src/taskmanager.cpp @@ -0,0 +1,146 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#include "taskmanager.h" + +namespace olive +{ + +TaskManager *TaskManager::instance_ = nullptr; + +TaskManager::~TaskManager() +{ + std::vector> tasks; + { + std::lock_guard lock(mutex_); + tasks = std::move(tasks_); + } + + for (auto &entry : tasks) { + entry->task->cancel(); + } + for (auto &entry : tasks) { + if (entry->thread.joinable()) { + entry->thread.join(); + } + } +} + +void TaskManager::create_instance() +{ + instance_ = new TaskManager(); +} + +void TaskManager::destroy_instance() +{ + delete instance_; + instance_ = nullptr; +} + +TaskManager *TaskManager::instance() +{ + return instance_; +} + +int TaskManager::get_task_count() const +{ + std::lock_guard lock(const_cast(mutex_)); + return int(tasks_.size()); +} + +Task *TaskManager::get_task_at(int index) const +{ + std::lock_guard lock(const_cast(mutex_)); + if (index < 0 || index >= int(tasks_.size())) { + return nullptr; + } + return tasks_[index]->task; +} + +void TaskManager::add_task(Task *t) +{ + auto entry = std::make_unique(); + entry->task = t; + entry->finished = std::make_shared>(false); + entry->succeeded = std::make_shared>(false); + + std::shared_ptr> finished = entry->finished; + std::shared_ptr> succeeded = entry->succeeded; + + entry->thread = std::thread([t, finished, succeeded]() { + bool ret = t->start(); + succeeded->store(ret); + finished->store(true); + }); + + std::lock_guard lock(mutex_); + tasks_.push_back(std::move(entry)); +} + +void TaskManager::cancel_task(Task *t) +{ + t->cancel(); +} + +void TaskManager::cancel_task_and_wait(Task *t) +{ + t->cancel(); + + std::thread tmp; + { + std::lock_guard lock(mutex_); + for (auto &entry : tasks_) { + if (entry->task == t && entry->thread.joinable()) { + tmp = std::move(entry->thread); + break; + } + } + } + if (tmp.joinable()) { + tmp.join(); + } +} + +void TaskManager::delete_finished() +{ + std::vector> finished_entries; + + { + std::lock_guard lock(mutex_); + auto it = tasks_.begin(); + while (it != tasks_.end()) { + if ((*it)->finished->load()) { + finished_entries.push_back(std::move(*it)); + it = tasks_.erase(it); + } else { + it++; + } + } + } + + for (auto &entry : finished_entries) { + if (entry->thread.joinable()) { + entry->thread.join(); + } + } +} + +} diff --git a/src/task/src/taskmanager.h b/src/task/src/taskmanager.h new file mode 100644 index 000000000..82c6d36d9 --- /dev/null +++ b/src/task/src/taskmanager.h @@ -0,0 +1,97 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2025 mikesolar + + 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 . + +***/ + +#ifndef OAK_TASKMANAGER_H +#define OAK_TASKMANAGER_H + +#include +#include +#include +#include +#include + +#include "task.h" + +namespace olive +{ + +/** + * @brief An object that manages background Task objects, handling their start and end + * + * TaskManager handles the life of a Task object. After a new Task is created, it should be sent to TaskManager through + * add_task(). TaskManager will take ownership of the task and run it on a worker thread. + * + * De-Qt version: no signals. The caller that creates/deletes tasks knows + * what changed (01 §4), so no list-changed notification exists here. + */ +class TaskManager { +public: + TaskManager() = default; + + /** + * @brief Ensures all Tasks are cancelled, joined and deleted + */ + virtual ~TaskManager(); + + static void create_instance(); + + static void destroy_instance(); + + static TaskManager *instance(); + + int get_task_count() const; + + Task *get_task_at(int index) const; + + /** + * @brief Add a new Task + * + * TaskManager takes ownership of this Task and will be responsible for freeing it. + * A Task object should only be added once. + */ + void add_task(Task *t); + + void cancel_task(Task *t); + + void cancel_task_and_wait(Task *t); + + /** + * @brief Delete all finished tasks (successful or failed) + */ + void delete_finished(); + +private: + struct Entry { + Task *task; + std::thread thread; + std::shared_ptr> finished; + std::shared_ptr> succeeded; + }; + + std::mutex mutex_; + std::vector> tasks_; + + static TaskManager *instance_; +}; + +} + +#endif // OAK_TASKMANAGER_H diff --git a/src/task/standalone/CMakeLists.txt b/src/task/standalone/CMakeLists.txt new file mode 100644 index 000000000..cbcf76a8a --- /dev/null +++ b/src/task/standalone/CMakeLists.txt @@ -0,0 +1,177 @@ +# Olive - Non-Linear Video Editor +# Copyright (C) 2022 Olive Team +# Modifications Copyright (C) 2025 mikesolar +# +# 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 . + +cmake_minimum_required(VERSION 3.16) +project(oaktask-standalone CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# oaktask manipulates node graphs through the oaknode C ABI; the +# not-yet-split engine modules' symbols are allowed to dangle, resolved +# by transitional stubs in src/{node,render}/transition. +set(OAK_REPO_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../..) +list(APPEND CMAKE_MODULE_PATH "${OAK_REPO_ROOT}/cmake") + +set(BUILD_TESTS ON CACHE BOOL "" FORCE) +set(OLIVECORE_BUILD_TESTS OFF CACHE BOOL "" FORCE) +# The /opt/otio OpenTimelineIO dylibs use @loader_path install names that +# break when linked from a build tree; oaktask does not need OTIO. +set(CMAKE_DISABLE_FIND_PACKAGE_OpenTimelineIO ON) + +find_package(EXPAT REQUIRED) +find_package(OpenColorIO CONFIG REQUIRED) +find_package(OpenImageIO CONFIG REQUIRED) +set(OCIO_LIBRARIES OpenColorIO::OpenColorIO) +set(OCIO_INCLUDE_DIRS ${OpenColorIO_INCLUDE_DIRS}) +set(OIIO_LIBRARIES OpenImageIO::OpenImageIO) +set(OIIO_INCLUDE_DIRS ${OpenImageIO_INCLUDE_DIRS}) + +add_subdirectory(${OAK_REPO_ROOT}/core ${CMAKE_BINARY_DIR}/core) +target_include_directories(olivecore PUBLIC + ${OAK_REPO_ROOT}/third_party/openfx/include +) +add_subdirectory(${OAK_REPO_ROOT}/ffmpeg_bridge ${CMAKE_BINARY_DIR}/ffmpeg_bridge) + +set(BUILD_TESTS OFF) +add_subdirectory(${OAK_REPO_ROOT}/src/undo ${CMAKE_BINARY_DIR}/undo) +add_subdirectory(${OAK_REPO_ROOT}/src/common ${CMAKE_BINARY_DIR}/common) +add_subdirectory(${OAK_REPO_ROOT}/src/render/src ${CMAKE_BINARY_DIR}/render) +add_subdirectory(${OAK_REPO_ROOT}/src/render/c_api ${CMAKE_BINARY_DIR}/render_c_api) +set(BUILD_TESTS ON) + +enable_testing() +set(BUILD_TESTS OFF) +add_subdirectory(${OAK_REPO_ROOT}/src/timeline ${CMAKE_BINARY_DIR}/timeline) +add_subdirectory(${OAK_REPO_ROOT}/src/codec ${CMAKE_BINARY_DIR}/codec) +set(BUILD_TESTS ON) +add_subdirectory(${OAK_REPO_ROOT}/src/node ${CMAKE_BINARY_DIR}/node) +add_subdirectory(${OAK_REPO_ROOT}/src/task ${CMAKE_BINARY_DIR}/task) + +target_include_directories(oakcodec PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition + ${OAK_REPO_ROOT}/src/render/src + ${OAK_REPO_ROOT}/src/node/src +) +foreach(t oakcodec oaktimeline) + if(TARGET ${t}) + target_link_options(${t} PRIVATE + "-undefined" "dynamic_lookup" + ) + endif() +endforeach() + +# oaknode needs its transition stubs and the oakrender real headers +# (src/node/transition/render/* bridge there); src/render/transition +# must precede src/node/transition. +target_include_directories(oaknode BEFORE PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition + ${OAK_REPO_ROOT}/src/render/src +) +target_include_directories(oaknode PUBLIC + ${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include + /opt/homebrew/include + /opt/homebrew/include/Imath +) +target_link_options(oaknode PRIVATE + "-undefined" "dynamic_lookup" +) + +target_include_directories(oakrender BEFORE PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition +) +target_include_directories(oakrender PUBLIC + ${OAK_REPO_ROOT}/engine/include + ${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include + /opt/homebrew/include + /opt/homebrew/include/Imath + /opt/homebrew/include/OpenEXR +) +foreach(t oakrender oakgl oakgl2 oakvulkan) + if(TARGET ${t}) + target_link_options(${t} PRIVATE + "-undefined" "dynamic_lookup" + ) + endif() +endforeach() + +target_include_directories(oaktask PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition + ${OAK_REPO_ROOT}/src/render/src + ${OAK_REPO_ROOT}/src/node/src +) +target_link_options(oaktask PRIVATE + "-undefined" "dynamic_lookup" +) + +target_link_libraries(oakrender PRIVATE + oaknode + oakcommon + oakundo + olivecore + ffmpeg_bridge + ${OCIO_LIBRARIES} + ${OIIO_LIBRARIES} + "-framework OpenGL" + "-framework CoreVideo" + "-framework Metal" + "-framework QuartzCore" +) + +target_link_libraries(oaknode-gtest PRIVATE oakrender oaktimeline oakcodec) + +# liboakrender references the oakengine_ipc_* C ABI (worker IPC) via +# dynamic_lookup; the real implementation (engine/src/capi/ipc.cpp) is still +# Qt-based, so test binaries link an inert shim instead. +target_sources(oaknode-gtest PRIVATE + ${OAK_REPO_ROOT}/src/node/standalone/oakengine_ipc_shim.cpp +) +target_include_directories(oaknode-gtest PRIVATE + ${OAK_REPO_ROOT}/engine/include +) + +target_link_libraries(oaktask-gtest PRIVATE oakrender) +target_sources(oaktask-gtest PRIVATE + ${OAK_REPO_ROOT}/src/node/standalone/oakengine_ipc_shim.cpp +) +target_include_directories(oaktask-gtest PRIVATE + ${OAK_REPO_ROOT}/engine/include +) + +# OFX typeinfo is referenced via dynamic_lookup; force-load the host +# support archive so RTTI resolves (same as oaknode's test wiring). +find_library(OAKNODE_OFX_HOST_ARCHIVE NAMES OfxHost + PATHS ${OAK_REPO_ROOT}/build/third_party/openfx/HostSupport + NO_DEFAULT_PATH) +if(NOT OAKNODE_OFX_HOST_ARCHIVE) + message(FATAL_ERROR + "libOfxHost.a not found; run the full-tree build once or set " + "OAKNODE_OFX_HOST_ARCHIVE") +endif() +target_link_options(oaktask-gtest PRIVATE + "-Wl,-force_load,${OAKNODE_OFX_HOST_ARCHIVE}") +target_link_options(oaknode-gtest PRIVATE + "-Wl,-force_load,${OAKNODE_OFX_HOST_ARCHIVE}") + +# oaknode's Sequence::add_default_nodes() constructs +# TimelineAddTrackCommand, which lives in liboaktask; the two +# libraries resolve each other at runtime (dynamic_lookup). +target_link_libraries(oaknode-gtest PRIVATE oaktask) diff --git a/src/task/tests/CMakeLists.txt b/src/task/tests/CMakeLists.txt new file mode 100644 index 000000000..5257b3dee --- /dev/null +++ b/src/task/tests/CMakeLists.txt @@ -0,0 +1,22 @@ +find_package(GTest REQUIRED) +include(GoogleTest) + +add_executable(oaktask-gtest + task_test.cpp +) + +target_compile_definitions(oaktask-gtest PRIVATE + OAK_REPO_ROOT="${OAK_REPO_ROOT}") + +target_link_libraries(oaktask-gtest PRIVATE + oaktask + oaktimeline + GTest::gtest + GTest::gtest_main +) + +gtest_discover_tests(oaktask-gtest + DISCOVERY_MODE PRE_TEST + PROPERTIES ENVIRONMENT + "OCIO=${OAK_REPO_ROOT}/engine/render/ocioconf/config.ocio" +) diff --git a/src/task/tests/task_test.cpp b/src/task/tests/task_test.cpp new file mode 100644 index 000000000..43ec8a493 --- /dev/null +++ b/src/task/tests/task_test.cpp @@ -0,0 +1,316 @@ +/*** + + 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 . + +***/ + +#include +#include + +#include + +#include "codec/conform.h" +#include "codec/decoder.h" +#include "codec/task.h" +#include "node/folder.h" +#include "node/project.h" +#include "task/manager.h" +#include "task/project.h" +#include "task/task.h" +#include "undo/undostack.h" + +namespace +{ + +class OakTaskFixture : public ::testing::Test { +protected: + void SetUp() override + { + project_ = oaknode_project_init(); + ASSERT_NE(project_, nullptr); + ASSERT_EQ(oaknode_project_initialize(project_), OAKNODE_OK); + } + + void TearDown() override + { + oaknode_project_free(project_); + } + + OakNodeProject *project_ = nullptr; +}; + +// ---- task family ---------------------------------------------------------- + +TEST_F(OakTaskFixture, TaskLifecycleSync) +{ + oaktask_task_free(nullptr); // no-op on NULL + + OakTaskTask *t = oaktask_create_project_save(project_, nullptr, 0); + ASSERT_NE(t, nullptr); + + char title[128]; + EXPECT_GT(oaktask_task_title(t, title, sizeof(title)), 0); + EXPECT_EQ(oaktask_task_title(nullptr, title, sizeof(title)), + OAKTASK_E_INVALID); + + EXPECT_EQ(oaktask_task_is_finished(t), 0); + EXPECT_EQ(oaktask_task_is_finished(nullptr), OAKTASK_E_INVALID); + + // Save to an override path in the temp dir + std::string path = + (std::filesystem::temp_directory_path() / "oaktask_save_test.ove") + .string(); + + oaktask_task_free(t); + + t = oaktask_create_project_save(project_, path.c_str(), 0); + ASSERT_NE(t, nullptr); + + EXPECT_EQ(oaktask_task_start_sync(t), 1); + EXPECT_EQ(oaktask_task_is_finished(t), 1); + EXPECT_TRUE(std::filesystem::exists(path)); + + char err[256]; + EXPECT_GE(oaktask_task_error(t, err, sizeof(err)), 0); + + oaktask_task_free(t); + EXPECT_EQ(oaktask_debug_alive_count(), 0); + + std::filesystem::remove(path); +} + +TEST_F(OakTaskFixture, LoadMissingFileFails) +{ + OakTaskTask *t = + oaktask_create_project_load("/nonexistent/definitely-missing.ove"); + ASSERT_NE(t, nullptr); + + EXPECT_EQ(oaktask_task_start_sync(t), 0); + + char err[256]; + EXPECT_GT(oaktask_task_error(t, err, sizeof(err)), 0); + + EXPECT_EQ(oaktask_load_take_project(t), nullptr); + oaktask_task_free(t); + + EXPECT_EQ(oaktask_create_project_load(nullptr), nullptr); +} + +TEST_F(OakTaskFixture, SaveLoadRoundTrip) +{ + std::string path = + (std::filesystem::temp_directory_path() / "oaktask_roundtrip.ove") + .string(); + + OakTaskTask *save = + oaktask_create_project_save(project_, path.c_str(), 0); + ASSERT_NE(save, nullptr); + ASSERT_EQ(oaktask_task_start_sync(save), 1); + oaktask_task_free(save); + + OakTaskTask *load = oaktask_create_project_load(path.c_str()); + ASSERT_NE(load, nullptr); + ASSERT_EQ(oaktask_task_start_sync(load), 1); + + OakNodeProject *loaded = oaktask_load_take_project(load); + ASSERT_NE(loaded, nullptr); + EXPECT_EQ(oaktask_load_take_project(load), nullptr); + + oaknode_project_free(loaded); + oaktask_task_free(load); + + std::filesystem::remove(path); +} + +TEST_F(OakTaskFixture, ImportDemoFootage) +{ + OakNodeFolder *folder = oaknode_folder_create(project_); + ASSERT_NE(folder, nullptr); + + const char *urls[] = { OAK_REPO_ROOT "/tests/demo.mp4" }; + OakTaskTask *t = + oaktask_create_project_import(folder, project_, urls, 1); + ASSERT_NE(t, nullptr); + + ASSERT_EQ(oaktask_task_start_sync(t), 1); + + if (oaktask_import_footage_count(t) == 0) { + // Footage probing still resolves through oaknode's codec + // transition stubs (module wiring milestone) - demo.mp4 reports + // invalid until then. + GTEST_SKIP() + << "footage probe not wired to oakcodec yet (step 4)"; + } + + EXPECT_EQ(oaktask_import_footage_count(t), 1); + EXPECT_NE(oaktask_import_footage_at(t, 0), nullptr); + EXPECT_EQ(oaktask_import_footage_at(t, 5), nullptr); + EXPECT_EQ(oaktask_import_invalid_count(t), 0); + EXPECT_EQ(oaktask_import_invalid_at(t, 0, nullptr, 0), + OAKTASK_E_NOT_FOUND); + + // The import command adds the footage to the folder; undo removes it + OakUndoCommand *cmd = oaktask_import_take_command(t); + ASSERT_NE(cmd, nullptr); + EXPECT_EQ(oaktask_import_take_command(t), nullptr); + + EXPECT_EQ(oaknode_folder_child_count(folder), 0); // not yet redone + + oakundo_command_redo_now(cmd); + EXPECT_EQ(oaknode_folder_child_count(folder), 1); + + oakundo_command_undo_now(cmd); + EXPECT_EQ(oaknode_folder_child_count(folder), 0); + + oakundo_command_free(cmd); + oaktask_task_free(t); + + EXPECT_EQ(oaktask_create_project_import(nullptr, project_, urls, 1), + nullptr); + EXPECT_EQ(oaktask_import_footage_count(nullptr), OAKTASK_E_INVALID); + + EXPECT_EQ(oaktask_debug_alive_count(), 0); +} + +// ---- manager family ------------------------------------------------------- + +TEST(OakTaskManager, InitShutdownAndCodecSubmitter) +{ + EXPECT_EQ(oaktask_manager_init(), OAKTASK_OK); + EXPECT_EQ(oaktask_manager_init(), OAKTASK_E_STATE); + + EXPECT_EQ(oakcodec_task_submit_is_registered(), 1); + + EXPECT_GE(oaktask_manager_count(), 0); + oaktask_manager_delete_finished(); + + EXPECT_EQ(oaktask_manager_at(0), nullptr); + + oaktask_manager_shutdown(); + EXPECT_EQ(oakcodec_task_submit_is_registered(), 0); + + EXPECT_EQ(oaktask_manager_count(), OAKTASK_E_STATE); +} + +TEST(OakTaskManager, AsyncStartAndSubscribe) +{ + ASSERT_EQ(oaktask_manager_init(), OAKTASK_OK); + + OakNodeProject *project = oaknode_project_init(); + ASSERT_NE(project, nullptr); + ASSERT_EQ(oaknode_project_initialize(project), OAKNODE_OK); + + std::string path = + (std::filesystem::temp_directory_path() / "oaktask_async.ove") + .string(); + + OakTaskTask *t = oaktask_create_project_save(project, path.c_str(), 0); + ASSERT_NE(t, nullptr); + + struct Seen { + int started; + int finished; + double succeeded; + } seen = { 0, 0, 0.0 }; + + EXPECT_EQ(oaktask_task_subscribe( + t, + [](int event_id, double value, void *userdata) { + Seen *s = static_cast(userdata); + if (event_id == OAKTASK_EVENT_STARTED) { + s->started++; + } else if (event_id == OAKTASK_EVENT_FINISHED) { + s->finished++; + s->succeeded = value; + } + }, + &seen), + 0); + EXPECT_EQ(oaktask_task_subscribe(t, nullptr, nullptr), + OAKTASK_E_INVALID); + + ASSERT_EQ(oaktask_task_start(t), OAKTASK_OK); + EXPECT_EQ(oaktask_task_start(t), OAKTASK_E_STATE); + + EXPECT_EQ(oaktask_task_wait(t), OAKTASK_OK); + EXPECT_EQ(oaktask_task_is_finished(t), 1); + EXPECT_EQ(oaktask_task_succeeded(t), 1); + + EXPECT_EQ(seen.started, 1); + EXPECT_EQ(seen.finished, 1); + EXPECT_EQ(seen.succeeded, 1.0); + + EXPECT_TRUE(std::filesystem::exists(path)); + std::filesystem::remove(path); + + oaktask_task_free(t); + oaknode_project_free(project); + oaktask_manager_shutdown(); + + EXPECT_EQ(oaktask_debug_alive_count(), 0); +} + +// ---- conform submitter end-to-end ------------------------------------------ + +TEST(OakTaskConform, SubmittedConformProducesPcm) +{ + const std::string demo = OAK_REPO_ROOT "/tests/demo.mp4"; + if (!std::filesystem::exists(demo)) { + GTEST_SKIP() << "demo.mp4 not available"; + } + + ASSERT_EQ(oaktask_manager_init(), OAKTASK_OK); + ASSERT_EQ(oakcodec_conform_create_instance(), OAKCODEC_OK); + + // Probe demo.mp4 for an audio stream + OakDecoder probe = oakcodec_decoder_probe(demo.c_str()); + ASSERT_NE(probe.ctx, nullptr); + if (oakcodec_decoder_probe_audio_stream_count(probe) <= 0) { + oakcodec_decoder_free(&probe); + oaktask_manager_shutdown(); + GTEST_SKIP() << "demo.mp4 has no audio stream"; + } + + oakcodec_audio_stream_info info = {}; + ASSERT_EQ(oakcodec_decoder_probe_get_audio_stream(probe, 0, &info), + OAKCODEC_OK); + oakcodec_decoder_free(&probe); + + std::string cache_path = + (std::filesystem::temp_directory_path() / "oaktask_conform_cache") + .string(); + std::error_code ec; + std::filesystem::remove_all(cache_path, ec); + std::filesystem::create_directories(cache_path, ec); + + const int sample_format = 4; /* olive::core::SampleFormat::f32_p */ + int state = oakcodec_conform_get_state( + cache_path.c_str(), demo.c_str(), info.stream_index, + info.sample_rate, info.channel_layout, sample_format, 1); + EXPECT_EQ(state, 0 /* OAKCODEC_CONFORM_EXISTS */); + + int count = oakcodec_conform_filename_count( + cache_path.c_str(), demo.c_str(), info.stream_index, + info.sample_rate, info.channel_layout, sample_format); + EXPECT_GT(count, 0); + + std::filesystem::remove_all(cache_path, ec); + oakcodec_conform_destroy_instance(); + oaktask_manager_shutdown(); +} + +} // namespace