diff --git a/.gitmodules b/.gitmodules index 5467b79e5c..64e0013615 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "src/deps/Imath"] path = src/deps/Imath url = https://github.com/AcademySoftwareFoundation/Imath +[submodule "src/deps/minizip-ng"] + path = src/deps/minizip-ng + url = https://github.com/zlib-ng/minizip-ng.git diff --git a/CMakeLists.txt b/CMakeLists.txt index e53f335c7b..65d9c518b4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,12 +28,12 @@ project(OpenTimelineIO VERSION ${OTIO_VERSION} LANGUAGES C CXX) # Installation options option(OTIO_CXX_INSTALL "Install the C++ bindings" ON) option(OTIO_PYTHON_INSTALL "Install the Python bindings" OFF) -option(OTIO_DEPENDENCIES_INSTALL "Install OTIO's C++ header dependencies (Imath)" ON) option(OTIO_INSTALL_PYTHON_MODULES "Install OTIO pure Python modules/files" ON) option(OTIO_INSTALL_COMMANDLINE_TOOLS "Install the OTIO command line tools" ON) option(OTIO_INSTALL_CONTRIB "Install the opentimelineio_contrib Python package" ON) option(OTIO_FIND_IMATH "Find Imath using find_package" OFF) option(OTIO_FIND_RAPIDJSON "Find RapidJSON using find_package" OFF) +option(OTIO_FIND_MINIZIP_NG "Find minizip-ng using find_package" OFF) set(OTIO_PYTHON_INSTALL_DIR "" CACHE STRING "Python installation dir (such as the site-packages dir)") # Build options @@ -148,12 +148,6 @@ set(OTIO_RESOLVED_CXX_INSTALL_DIR "${CMAKE_INSTALL_PREFIX}") if(OTIO_CXX_INSTALL) message(STATUS "Installing C++ bindings to: ${OTIO_RESOLVED_CXX_INSTALL_DIR}") message(STATUS "Installing C++ dynamic libraries to: ${OTIO_RESOLVED_CXX_DYLIB_INSTALL_DIR}") - - if(OTIO_DEPENDENCIES_INSTALL) - message(STATUS " Installing header dependencies for C++ (OTIO_DEPENDENCIES_INSTALL=ON)") - else() - message(STATUS " Not installing header dependencies for C++ (OTIO_DEPENDENCIES_INSTALL=OFF)") - endif() else() message(STATUS "Install C++ bindings: OFF") endif() @@ -263,6 +257,16 @@ else() message(STATUS "Using src/deps/rapidjson by default") endif() +#----- minizip-ng +if(OTIO_FIND_MINIZIP_NG) + find_package(minizip-ng CONFIG REQUIRED) + if (minizip-ng_FOUND) + message(STATUS "Found minizip-ng at ${minizip-ng_CONFIG}") + endif() +else() + message(STATUS "Using src/deps/minizip-ng by default") +endif() + # set up the internally hosted dependencies add_subdirectory(src/deps) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 960a996257..09d951ffe5 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -5,6 +5,7 @@ include_directories(${PROJECT_SOURCE_DIR}/src ${PROJECT_SOURCE_DIR}/src/deps/optional-lite/include ${PYTHON_INCLUDE_DIRS}) +list(APPEND examples bundle) list(APPEND examples conform) list(APPEND examples flatten_video_tracks) list(APPEND examples summarize_timing) diff --git a/examples/bundle.cpp b/examples/bundle.cpp new file mode 100644 index 0000000000..3b427fce11 --- /dev/null +++ b/examples/bundle.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +// Example OTIO script that can create and extract bundles. + +#include "util.h" + +#include "opentimelineio/bundle.h" +#include "opentimelineio/fileUtils.h" +#include "opentimelineio/timeline.h" + +#include + +namespace otio = opentimelineio::OPENTIMELINEIO_VERSION; +namespace bundle = opentimelineio::OPENTIMELINEIO_VERSION::bundle; + +bool +ends_with(std::string const& s, std::string const& find) +{ + size_t const s_size = s.size(); + size_t const find_size = find.size(); + return find_size < s_size ? + s.substr(s_size - find_size, find_size) == find : + false; +} + +int +main(int argc, char** argv) +{ + if (argc != 3) + { + std::cout << "Usage:\n"; + std::cout << " bundle (input.otio) (output.otioz) - " + << "Create an .otioz bundle from an .otio file.\n"; + std::cout << " bundle (input.otio) (output.otiod) - " + << "Create an .otiod bundle from an .otio file.\n"; + std::cout << " bundle (input.otioz) (output) - " + << "Extract an .otioz bundle.\n"; + return 1; + } + const std::string input = otio::to_unix_separators(argv[1]); + const std::string output = otio::to_unix_separators(argv[2]); + + if (ends_with(input, ".otio") && ends_with(output, ".otioz")) + { + // Open timeline. + otio::ErrorStatus error_status; + otio::SerializableObject::Retainer timeline( + dynamic_cast( + otio::Timeline::from_json_file(input, &error_status))); + if (!timeline || otio::is_error(error_status)) + { + examples::print_error(error_status); + return 1; + } + + // Create .otioz bundle. + bundle::WriteOptions options; + options.parent_path = + std::filesystem::u8path(input).parent_path().u8string(); + if (!bundle::to_otioz( + timeline.value, + output, + options, + &error_status)) + { + examples::print_error(error_status); + return 1; + } + } + else if (ends_with(input, ".otioz")) + { + // Extract .otioz bundle. + bundle::OtiozReadOptions options; + options.extract_path = output; + otio::ErrorStatus error_status; + auto result = bundle::from_otioz(input, options, &error_status); + if (otio::is_error(error_status)) + { + examples::print_error(error_status); + return 1; + } + } + else if (ends_with(input, ".otio") && ends_with(output, ".otiod")) + { + // Open timeline. + otio::ErrorStatus error_status; + otio::SerializableObject::Retainer timeline( + dynamic_cast( + otio::Timeline::from_json_file(input, &error_status))); + if (!timeline || otio::is_error(error_status)) + { + examples::print_error(error_status); + return 1; + } + + // Create .otiod bundle. + bundle::WriteOptions options; + options.parent_path = + std::filesystem::u8path(input).parent_path().u8string(); + if (!bundle::to_otiod( + timeline.value, + output, + options, + &error_status)) + { + examples::print_error(error_status); + return 1; + } + } + + return 0; +} diff --git a/examples/conform.cpp b/examples/conform.cpp index b0b934797a..5ac318b531 100644 --- a/examples/conform.cpp +++ b/examples/conform.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include @@ -111,9 +112,9 @@ int main(int argc, char** argv) std::cout << "Usage: conform (input) (folder) (output)" << std::endl; return 1; } - const std::string input = examples::normalize_path(argv[1]); - const std::string folder = examples::normalize_path(argv[2]); - const std::string output = examples::normalize_path(argv[3]); + const std::string input = otio::to_unix_separators(argv[1]); + const std::string folder = otio::to_unix_separators(argv[2]); + const std::string output = otio::to_unix_separators(argv[3]); otio::ErrorStatus error_status; otio::SerializableObject::Retainer timeline( diff --git a/examples/io_perf_test.cpp b/examples/io_perf_test.cpp index 067d063e3a..20c1dc403f 100644 --- a/examples/io_perf_test.cpp +++ b/examples/io_perf_test.cpp @@ -4,6 +4,7 @@ #include #include "opentimelineio/clip.h" +#include "opentimelineio/fileUtils.h" #include "opentimelineio/typeRegistry.h" #include "opentimelineio/serialization.h" #include "opentimelineio/deserialization.h" @@ -93,7 +94,7 @@ main( const std::string tmp_dir_path = ( RUN_STRUCT.FIXED_TMP ? "/var/tmp/ioperftest" - : examples::create_temp_dir() + : otio::create_temp_dir() ); otio::ErrorStatus err; @@ -123,7 +124,7 @@ main( cl->metadata()["example thing"] = "banana"; chrono_time_point begin = std::chrono::steady_clock::now(); cl->to_json_file( - examples::normalize_path(tmp_dir_path + "/clip.otio"), + otio::to_unix_separators(tmp_dir_path + "/clip.otio"), &err, &downgrade_manifest ); @@ -140,7 +141,7 @@ main( otio::SerializableObject::Retainer timeline( dynamic_cast( otio::Timeline::from_json_file( - examples::normalize_path(argv[1]), + otio::to_unix_separators(argv[1]), &err ) ) @@ -205,7 +206,7 @@ main( { begin = std::chrono::steady_clock::now(); timeline.value->to_json_file( - examples::normalize_path(tmp_dir_path + "/io_perf_test.otio"), + otio::to_unix_separators(tmp_dir_path + "/io_perf_test.otio"), &err, &downgrade_manifest ); @@ -218,7 +219,7 @@ main( { begin = std::chrono::steady_clock::now(); timeline.value->to_json_file( - examples::normalize_path( + otio::to_unix_separators( tmp_dir_path + "/io_perf_test.nodowngrade.otio" ), diff --git a/examples/util.cpp b/examples/util.cpp index df8a434509..2aeb2e2722 100644 --- a/examples/util.cpp +++ b/examples/util.cpp @@ -3,6 +3,7 @@ #include "util.h" +#include "opentimelineio/fileUtils.h" #include #include @@ -16,7 +17,6 @@ #define WIN32_LEAN_AND_MEAN #endif // WIN32_LEAN_AND_MEAN #include -#include #if defined(min) #undef min #endif // min @@ -35,16 +35,6 @@ namespace examples { #if defined(_WINDOWS) -std::string normalize_path(std::string const& in) -{ - std::string out; - for (auto i : in) - { - out.push_back('\\' == i ? '/' : i); - } - return out; -} - std::string absolute(std::string const& in) { wchar_t buf[MAX_PATH]; @@ -53,45 +43,7 @@ std::string absolute(std::string const& in) { buf[0] = 0; } - return normalize_path(utf16.to_bytes(buf)); -} - -std::string create_temp_dir() -{ - std::string out; - - // Get the temporary directory. - char path[MAX_PATH]; - DWORD r = GetTempPath(MAX_PATH, path); - if (r) - { - out = std::string(path); - - // Replace path separators. - for (size_t i = 0; i < out.size(); ++i) - { - if ('\\' == out[i]) - { - out[i] = '/'; - } - } - - // Create a unique name from a GUID. - GUID guid; - CoCreateGuid(&guid); - const uint8_t* guidP = reinterpret_cast(&guid); - for (int i = 0; i < 16; ++i) - { - char buf[3] = ""; - sprintf_s(buf, 3, "%02x", guidP[i]); - out += buf; - } - - // Create a unique directory. - CreateDirectory(out.c_str(), NULL); - } - - return out; + return otio::to_unix_separators(utf16.to_bytes(buf)); } std::vector glob(std::string const& path, std::string const& pattern) @@ -124,11 +76,6 @@ std::vector glob(std::string const& path, std::string const& patter #else // _WINDOWS -std::string normalize_path(std::string const& in) -{ - return in; -} - std::string absolute(std::string const& in) { char buf[PATH_MAX]; @@ -136,36 +83,6 @@ std::string absolute(std::string const& in) return buf; } -std::string create_temp_dir() -{ - // Find the temporary directory. - std::string path; - char* env = nullptr; - if ((env = getenv("TEMP"))) path = env; - else if ((env = getenv("TMP"))) path = env; - else if ((env = getenv("TMPDIR"))) path = env; - else - { - for (const auto& i : { "/tmp", "/var/tmp", "/usr/tmp" }) - { - struct stat buffer; - if (0 == stat(i, &buffer)) - { - path = i; - break; - } - } - } - - // Create a unique directory. - path = path + "/XXXXXX"; - const size_t size = path.size(); - std::vector buf(size + 1); - memcpy(buf.data(), path.c_str(), size); - buf[size] = 0; - return mkdtemp(buf.data()); -} - std::vector glob(std::string const& path, std::string const& pattern) { std::vector out; diff --git a/examples/util.h b/examples/util.h index 924fbb38e4..0b6b9d2b8b 100644 --- a/examples/util.h +++ b/examples/util.h @@ -9,15 +9,9 @@ namespace examples { -// Normalize path (change '\' path delimiters to '/'). -std::string normalize_path(std::string const&); - // Get the absolute path. std::string absolute(std::string const&); -// Create a temporary directory. -std::string create_temp_dir(); - // Get a list of files from a directory. std::vector glob(std::string const& path, std::string const& pattern); diff --git a/setup.py b/setup.py index eb73f5fde8..e1e2ac98a5 100644 --- a/setup.py +++ b/setup.py @@ -110,6 +110,7 @@ def generate_cmake_arguments(self): '-DOTIO_CXX_INSTALL:BOOL=OFF', '-DOTIO_SHARED_LIBS:BOOL=OFF', '-DCMAKE_BUILD_TYPE=' + self.build_config, + '-DCMAKE_INSTALL_PREFIX=' + install_dir, '-DOTIO_PYTHON_INSTALL_DIR=' + install_dir, # turn off the C++ tests during a Python build '-DBUILD_TESTING:BOOL=OFF', diff --git a/src/deps/CMakeLists.txt b/src/deps/CMakeLists.txt index efb5b5bd65..dd1ca5723d 100644 --- a/src/deps/CMakeLists.txt +++ b/src/deps/CMakeLists.txt @@ -9,6 +9,9 @@ set(DEPS_SUBMODULES pybind11) if(NOT OTIO_FIND_RAPIDJSON) set(DEPS_SUBMODULES ${DEPS_SUBMODULES} rapidjson) endif() +if(NOT OTIO_FIND_MINIZIP_NG) + set(DEPS_SUBMODULES ${DEPS_SUBMODULES} minizip-ng) +endif() foreach(submodule IN LISTS DEPS_SUBMODULES) file(GLOB SUBMOD_CONTENTS ${submodule}) @@ -26,15 +29,31 @@ if(OTIO_PYTHON_INSTALL) endif() if(NOT OTIO_FIND_IMATH) - # preserve BUILD_SHARED_LIBS options for this project, but set it off for Imath - option(BUILD_SHARED_LIBS "Build shared libraries" ON) set(BUILD_SHARED_LIBS OFF) - - # If we do not want Imath to install headers and CMake files use the EXCLUDE_FROM_ALL option - if(OTIO_CXX_INSTALL AND OTIO_DEPENDENCIES_INSTALL) - add_subdirectory(Imath) - else() - add_subdirectory(Imath EXCLUDE_FROM_ALL) - endif() + add_subdirectory(Imath) endif() +if(NOT OTIO_FIND_MINIZIP_NG) + set(BUILD_SHARED_LIBS OFF) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) + set(MZ_COMPAT ON) + set(MZ_ZLIB ON) + set(MZ_BZIP2 OFF) + set(MZ_LZMA OFF) + set(MZ_ZSTD OFF) + set(MZ_LIBCOMP OFF) + set(MZ_PKCRYPT OFF) + set(MZ_WZAES OFF) + set(MZ_OPENSSL OFF) + set(MZ_LIBBSD OFF) + set(MZ_ICONV OFF) + set(MZ_FETCH_LIBS ON) + set(MZ_FORCE_FETCH_LIBS ON) + add_subdirectory(minizip-ng) + + # TODO This is a temporary workaround to find zlib-ng. For some reason + # zlib-ng is not installing it's own CMake configuration files when built + # by minizip-ng. + install(FILES zlibng-config.cmake DESTINATION ${CMAKE_INSTALL_PREFIX}/lib/cmake/zlibng) + +endif() diff --git a/src/deps/minizip-ng b/src/deps/minizip-ng new file mode 160000 index 0000000000..f3ed731e27 --- /dev/null +++ b/src/deps/minizip-ng @@ -0,0 +1 @@ +Subproject commit f3ed731e27a97e30dffe076ed5e0537daae5c1bd diff --git a/src/deps/zlibng-config.cmake b/src/deps/zlibng-config.cmake new file mode 100644 index 0000000000..4b8ae01d8b --- /dev/null +++ b/src/deps/zlibng-config.cmake @@ -0,0 +1,4 @@ +get_filename_component(_IMPORT_PREFIX "${CMAKE_CURRENT_LIST_FILE}" PATH) +set(ZLIBNG_INCLUDE_DIRS "${_IMPORT_PREFIX}/include") +set(ZLIBNG_LIBRARIES "z-ng") +set(ZLIBNG_LIBRARY_DIRS "${_IMPORT_PREFIX}/lib") diff --git a/src/opentimelineio/CMakeLists.txt b/src/opentimelineio/CMakeLists.txt index f5258b0569..4dfd93d89a 100644 --- a/src/opentimelineio/CMakeLists.txt +++ b/src/opentimelineio/CMakeLists.txt @@ -4,6 +4,8 @@ set(OPENTIMELINEIO_HEADER_FILES anyDictionary.h anyVector.h + bundle.h + bundleUtils.h color.h clip.h composable.h @@ -13,6 +15,7 @@ set(OPENTIMELINEIO_HEADER_FILES effect.h errorStatus.h externalReference.h + fileUtils.h freezeFrame.h gap.h generatorReference.h @@ -36,10 +39,13 @@ set(OPENTIMELINEIO_HEADER_FILES transition.h typeRegistry.h unknownSchema.h + urlUtils.h vectorIndexing.h version.h) add_library(opentimelineio ${OTIO_SHARED_OR_STATIC_LIB} + bundle.cpp + bundleUtils.cpp color.cpp clip.cpp composable.cpp @@ -49,6 +55,7 @@ add_library(opentimelineio ${OTIO_SHARED_OR_STATIC_LIB} effect.cpp errorStatus.cpp externalReference.cpp + fileUtils.cpp freezeFrame.cpp gap.cpp generatorReference.cpp @@ -58,6 +65,8 @@ add_library(opentimelineio ${OTIO_SHARED_OR_STATIC_LIB} marker.cpp mediaReference.cpp missingReference.cpp + otiod.cpp + otioz.cpp safely_typed_any.cpp serializableCollection.cpp serializableObject.cpp @@ -74,6 +83,7 @@ add_library(opentimelineio ${OTIO_SHARED_OR_STATIC_LIB} transition.cpp typeRegistry.cpp unknownSchema.cpp + urlUtils.cpp CORE_VERSION_MAP.cpp ${OPENTIMELINEIO_HEADER_FILES}) @@ -92,7 +102,8 @@ endif() target_link_libraries(opentimelineio - PUBLIC opentime Imath::Imath) + PUBLIC opentime Imath::Imath + PRIVATE MINIZIP::minizip) set_target_properties(opentimelineio PROPERTIES DEBUG_POSTFIX "${OTIO_DEBUG_POSTFIX}" diff --git a/src/opentimelineio/OpenTimelineIOConfig.cmake.in b/src/opentimelineio/OpenTimelineIOConfig.cmake.in index 355f8ea952..d0a6d23924 100644 --- a/src/opentimelineio/OpenTimelineIOConfig.cmake.in +++ b/src/opentimelineio/OpenTimelineIOConfig.cmake.in @@ -3,5 +3,6 @@ include(CMakeFindDependencyMacro) find_dependency(OpenTime) find_dependency(Imath) +find_dependency(minizip) include("${CMAKE_CURRENT_LIST_DIR}/OpenTimelineIOTargets.cmake") diff --git a/src/opentimelineio/bundle.cpp b/src/opentimelineio/bundle.cpp new file mode 100644 index 0000000000..d1f3529c61 --- /dev/null +++ b/src/opentimelineio/bundle.cpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "opentimelineio/bundle.h" + +#include "opentimelineio/bundleUtils.h" + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { namespace bundle { + +size_t +get_media_size( + Timeline const* timeline, + WriteOptions const& options, + ErrorStatus* error_status) +{ + size_t byte_count = 0; + try + { + // Get the file manifest. + std::map manifest; + timeline_for_bundle_and_manifest( + timeline, + std::filesystem::u8path(options.parent_path), + options.media_policy, + manifest); + + // Count the bytes in each file. + for (auto const& file: manifest) + { + byte_count += std::filesystem::file_size(file.first); + } + } + catch (std::exception const& e) + { + if (error_status) + { + *error_status = + ErrorStatus(ErrorStatus::BUNDLE_SIZE_ERROR, e.what()); + } + } + return byte_count; +} + +} // namespace bundle +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/bundle.h b/src/opentimelineio/bundle.h new file mode 100644 index 0000000000..0288582d45 --- /dev/null +++ b/src/opentimelineio/bundle.h @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#pragma once + +#include "opentimelineio/timeline.h" + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { + +namespace bundle { + +/// @brief The current otioz version. +static std::string const otioz_version = "1.0.0"; + +/// @brief The current otiod version. +static std::string const otiod_version = "1.0.0"; + +/// @brief The version file name in the bundle. +static std::string const version_file = "version.txt"; + +/// @brief The OTIO file name in the bundle. +static std::string const otio_file = "content.otio"; + +/// @brief The media directory name in the bundle. +static std::string const media_dir = "media"; + +/// @brief This enumeration provides the bundle media reference policy. +enum class MediaReferencePolicy +{ + ErrorIfNotFile, ///< Return an error if there are any non-file media references. + MissingIfNotFile, ///< Replace non-file media references with missing references. + AllMissing ///< Replace all media references with missing references. +}; + +/// @brief Options for writing bundles. +struct WriteOptions +{ + /// @brief The parent path is used to locate media with relative paths. If + /// parent path is empty, paths are relative to the current working directory. + std::string parent_path; + + /// @brief The bundle media reference policy. + MediaReferencePolicy media_policy = MediaReferencePolicy::ErrorIfNotFile; + + /// @todo Add comment. + schema_version_map const* target_family_label_spec = nullptr; + + /// @brief The number of spaces to use for JSON indentation. + int indent = 4; +}; + +/// @brief Options for reading .otioz bundles. +struct OtiozReadOptions +{ + /// @brief Extract the contents of the bundle to the given path. If the + /// path is empty, the contents are not extracted, and only the timeline + /// is read from the bundle. + std::string extract_path; +}; + +/// @brief Options for reading .otiod bundles. +struct OtiodReadOptions +{ + /// @brief Use absolute paths for media references. + bool absolute_media_reference_paths = false; +}; + +/// @brief Get the total size (in bytes) of the media files that will be +/// put into the bundle. +size_t get_media_size( + Timeline const* timeline, + WriteOptions const& options = WriteOptions(), + ErrorStatus* error_status = nullptr); + +/// @brief Write a timeline and it's referenced media to an .otioz bundle. +/// +/// Takes as input a timeline that has media references which are all +/// ExternalReferences, with target_urls to files with unique basenames that are +/// accessible through the file system. The timeline .otio file, a version file, +/// and media references are bundled into a single zip file with the suffix +/// .otioz. +/// +/// The timline .otio file and version file are compressed using the ZIP +/// "deflate" mode. All media files are store uncompressed. +/// +/// Can error out if files are not locally referenced. or provide missing +/// references. +/// +/// Note that .otioz files _always_ use the unix style path separator ('/'). +/// This ensures that regardless of which platform a bundle was created on, it +/// can be read on UNIX and Windows platforms. +/// +/// @param timeline The timeline to write. +/// @param file_name The bundle file name. +/// @param options The bundle options. +/// @param error_status The error status. +bool to_otioz( + Timeline const* timeline, + std::string const& file_name, + WriteOptions const& options = WriteOptions(), + ErrorStatus* error_status = nullptr); + +/// @brief Read a timeline from an .otioz bundle. +/// +/// @param file_name The bundle file name. +/// @param output_dir The directory where the bundle will be extracted. +/// @param error_status The error status. +Timeline* from_otioz( + std::string const& file_name, + OtiozReadOptions const& options = OtiozReadOptions(), + ErrorStatus* error_status = nullptr); + +/// @brief Write a timeline and it's referenced media to an .otiod bundle. +/// +/// Takes as input a timeline that has media references which are all +/// ExternalReferences, with target_urls to files with unique basenames that are +/// accessible through the file system. The timeline .otio file, a version file, +/// and media references are bundled into a single directory named with a +/// suffix of .otiod. +/// +/// @param timeline The timeline to write. +/// @param file_name The bundle file name. +/// @param options The bundle options. +/// @param error_status The error status. +bool to_otiod( + Timeline const* timeline, + std::string const& file_name, + WriteOptions const& options = WriteOptions(), + ErrorStatus* error_status = nullptr); + +/// @brief Read a timeline from an .otiod bundle. +/// +/// @param file_name The bundle file name. +/// @param timeline_file_name Returns the timeline file name. +/// @param error_status The error status. +Timeline* from_otiod( + std::string const& file_name, + OtiodReadOptions const& options = OtiodReadOptions(), + ErrorStatus* error_status = nullptr); + +} // namespace bundle +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/bundleUtils.cpp b/src/opentimelineio/bundleUtils.cpp new file mode 100644 index 0000000000..61a271eb3c --- /dev/null +++ b/src/opentimelineio/bundleUtils.cpp @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "opentimelineio/bundleUtils.h" + +#include "opentimelineio/clip.h" +#include "opentimelineio/externalReference.h" +#include "opentimelineio/fileUtils.h" +#include "opentimelineio/missingReference.h" +#include "opentimelineio/imageSequenceReference.h" +#include "opentimelineio/urlUtils.h" + +#include + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { namespace bundle { + +std::string +to_string(MediaReferencePolicy media_referenece_policy) +{ + switch (media_referenece_policy) + { + case MediaReferencePolicy::ErrorIfNotFile: + return "ErrorIfNotFile"; + case MediaReferencePolicy::MissingIfNotFile: + return "MissingIfNotFile"; + case MediaReferencePolicy::AllMissing: + return "AllMissing"; + default: + break; + } + return ""; +} + +namespace { + +// Replace the original media reference with a missing reference with the +// same metadata. +// +// Aditional metadata: +// * missing_reference_because +// +// For external references: +// * original_target_url +// +// For image sequence references: +// * original_target_url_base +// * original_name_prefix +// * original_name_suffix +// * original_start_frame +// * original_frame_step +// * original_rate +// * original_frame_zero_padding +SerializableObject::Retainer +reference_cloned_and_missing( + SerializableObject::Retainer const& orig_mr, + std::string const& reason_missing) +{ + SerializableObject::Retainer result(new MissingReference); + auto metadata = orig_mr->metadata(); + metadata["missing_reference_because"] = reason_missing; + if (auto orig_er = dynamic_retainer_cast(orig_mr)) + { + metadata["original_target_url"] = orig_er->target_url(); + } + else if (auto orig_isr = dynamic_retainer_cast(orig_mr)) + { + metadata["original_target_url_base"] = orig_isr->target_url_base(); + metadata["original_name_prefix"] = orig_isr->name_prefix(); + metadata["original_name_suffix"] = orig_isr->name_suffix(); + metadata["original_start_frame"] = orig_isr->start_frame(); + metadata["original_frame_step"] = orig_isr->frame_step(); + metadata["original_rate"] = orig_isr->rate(); + metadata["original_frame_zero_padding"] = + orig_isr->frame_zero_padding(); + } + result->metadata() = metadata; + return result; +} + +} // namspace + +SerializableObject::Retainer timeline_for_bundle_and_manifest( + SerializableObject::Retainer const& timeline, + std::filesystem::path const& parent_path, + MediaReferencePolicy media_policy, + Manifest& output_manifest) +{ + output_manifest.clear(); + std::map + bundle_paths_to_abs_paths; + + // Make an editable copy of the timeline. + SerializableObject::Retainer result_timeline( + dynamic_cast(timeline->clone())); + + // The result timeline is manipulated in place. + for (auto& cl : result_timeline->find_clips()) + { + auto mr = cl->media_reference(); + auto er = dynamic_cast(cl->media_reference()); + auto isr = dynamic_cast(cl->media_reference()); + if (er || isr) + { + if (MediaReferencePolicy::AllMissing == media_policy) + { + std::stringstream ss; + ss << to_string(media_policy) + << " specified as the MediaReferencePolicy"; + cl->set_media_reference( + reference_cloned_and_missing(mr, ss.str())); + continue; + } + + // Ensure that the URL scheme is either "file://" or "". + // File means "absolute path", "" is interpreted as a relative path, + // relative to the source .otio file. + std::string const url = er ? er->target_url() + : isr->target_url_base(); + std::string const scheme = scheme_from_url(url); + if (!(scheme == "file://" || scheme.empty())) + { + if (MediaReferencePolicy::ErrorIfNotFile == media_policy) + { + std::stringstream ss; + ss << "Bundles only work with media reference target URLs " + << "that begin with 'file://' or ''. Got a target URL of: " + << "'" << url << "'."; + throw std::runtime_error(ss.str()); + } + if (MediaReferencePolicy::MissingIfNotFile == media_policy) + { + cl->set_media_reference(reference_cloned_and_missing( + mr, + "target_url is not a file scheme url")); + continue; + } + } + + // Get the list of target files. + std::vector target_files; + if (er) + { + target_files.push_back(filepath_from_url(er->target_url())); + } + else if (isr) + { + TimeRange const range = cl->available_range(); + for (int frame = range.start_time().to_frames(); + frame < range.duration().to_frames(); + ++frame) + { + std::stringstream ss; + ss << isr->name_prefix(); + ss << std::setfill('0') + << std::setw(isr->frame_zero_padding()) << frame; + ss << isr->name_suffix(); + target_files.push_back(filepath_from_url( + isr->target_url_base() + ss.str())); + } + } + + // Get absolute paths to the target files. + std::vector target_paths; + std::filesystem::path target_path; + bool target_error = false; + for (const auto& target_file: target_files) + { + target_path = std::filesystem::u8path(target_file); + if (scheme.empty()) + { + target_path = parent_path / target_path; + } + target_path = std::filesystem::absolute(target_path); + if (!std::filesystem::exists(target_path) + || !std::filesystem::is_regular_file(target_path)) + { + target_error = true; + break; + } + target_paths.push_back(target_path); + } + if (target_error) + { + if (MediaReferencePolicy::ErrorIfNotFile == media_policy) + { + std::stringstream ss; + ss << "'" << target_path.u8string() + << "' is not a file or does not exist."; + throw std::runtime_error(ss.str()); + } + if (MediaReferencePolicy::MissingIfNotFile == media_policy) + { + cl->set_media_reference(reference_cloned_and_missing( + mr, + "target_url target is not a file or does not exist")); + continue; + } + } + + // Add files to the manifest. + std::filesystem::path bundle_path; + for (auto const& path: target_paths) + { + bundle_path = media_dir / path.filename(); + const auto i = output_manifest.find(path); + if (i == output_manifest.end()) + { + const auto j = bundle_paths_to_abs_paths.find(bundle_path); + if (j != bundle_paths_to_abs_paths.end()) + { + std::stringstream ss; + ss << "Bundles require that the media files have unique " + << "basenames. File '" << path.u8string() + << "' and '" << j->second + << "' have matching basenames of: '" + << path.filename().u8string() << "'."; + throw std::runtime_error(ss.str()); + } + bundle_paths_to_abs_paths[bundle_path] = path; + output_manifest[path] = bundle_path; + } + } + + // Relink the media reference. + if (er) + { + std::string const new_url = + url_from_filepath(bundle_path.u8string()); + er->set_target_url(new_url); + } + else if (isr) + { + std::string const new_url = + url_from_filepath(bundle_path.parent_path().u8string()) + + "/"; + isr->set_target_url_base(new_url); + } + } + } + + return result_timeline; +} + +} // namespace bundle +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/bundleUtils.h b/src/opentimelineio/bundleUtils.h new file mode 100644 index 0000000000..fc6d9a8b02 --- /dev/null +++ b/src/opentimelineio/bundleUtils.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#pragma once + +#include "opentimelineio/bundle.h" + +#include + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { +namespace bundle { + +/// @brief Convert a media reference policy to a string. +std::string to_string(MediaReferencePolicy); + +/// @brief This maps absolute paths of media references to their relative +/// paths in the bundle media directory. +typedef std::map Manifest; + +/// @brief Create a new timeline based on the input timeline that has media +/// references replaced according to the media reference policy. +/// +/// The media references are relinked to relative file paths in the media +/// directory. +/// +/// This is considered an internal API. +/// +/// Throws std::exception on errors. +SerializableObject::Retainer timeline_for_bundle_and_manifest( + SerializableObject::Retainer const&, + std::filesystem::path const& timeline_dir, + MediaReferencePolicy media_reference_policy, + Manifest& output_manifest); + +} // namespace bundle +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/errorStatus.cpp b/src/opentimelineio/errorStatus.cpp index e19f18e775..b0d9da059a 100644 --- a/src/opentimelineio/errorStatus.cpp +++ b/src/opentimelineio/errorStatus.cpp @@ -66,6 +66,12 @@ ErrorStatus::outcome_to_string(Outcome o) return "the media references cannot contain an empty key"; case NOT_A_GAP: return "object is not descendent of Gap type"; + case BUNDLE_SIZE_ERROR: + return "error compiting the size of the bundle"; + case BUNDLE_WRITE_ERROR: + return "error writing bundle"; + case BUNDLE_READ_ERROR: + return "error reading bundle"; default: return "unknown/illegal ErrorStatus::Outcome code"; }; diff --git a/src/opentimelineio/errorStatus.h b/src/opentimelineio/errorStatus.h index eafff9e230..c1ece8736a 100644 --- a/src/opentimelineio/errorStatus.h +++ b/src/opentimelineio/errorStatus.h @@ -44,7 +44,10 @@ struct ErrorStatus CANNOT_COMPUTE_BOUNDS, MEDIA_REFERENCES_DO_NOT_CONTAIN_ACTIVE_KEY, MEDIA_REFERENCES_CONTAIN_EMPTY_KEY, - NOT_A_GAP + NOT_A_GAP, + BUNDLE_SIZE_ERROR, + BUNDLE_WRITE_ERROR, + BUNDLE_READ_ERROR }; /// @brief Construct a new status with no error. diff --git a/src/opentimelineio/fileUtils.cpp b/src/opentimelineio/fileUtils.cpp new file mode 100644 index 0000000000..749f93706f --- /dev/null +++ b/src/opentimelineio/fileUtils.cpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "opentimelineio/fileUtils.h" + +#include +#include + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { + +std::string +to_unix_separators(std::string const& path) +{ + std::string result = path; + std::replace(result.begin(), result.end(), '\\', '/'); + return result; +} + +std::string create_temp_dir() +{ + // \todo Replace std::tmpnam(), since it is potentially unsafe. A possible + // replacement might be mkdtemp(), but that does not seem to be available + // on Cygwin. + std::string const out(std::tmpnam(nullptr)); + std::filesystem::create_directory(out); + return out; +} + +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/fileUtils.h b/src/opentimelineio/fileUtils.h new file mode 100644 index 0000000000..ad71760441 --- /dev/null +++ b/src/opentimelineio/fileUtils.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#pragma once + +#include "opentimelineio/version.h" + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { + +/// @name File Utilities +///@{ + +/// @brief Convert Windows path separators to UNIX path separators. +std::string to_unix_separators(std::string const&); + +// Create a temporary directory. +std::string create_temp_dir(); + +///@} + +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/otiod.cpp b/src/opentimelineio/otiod.cpp new file mode 100644 index 0000000000..079795e623 --- /dev/null +++ b/src/opentimelineio/otiod.cpp @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "opentimelineio/bundle.h" + +#include "opentimelineio/bundleUtils.h" +#include "opentimelineio/clip.h" +#include "opentimelineio/errorStatus.h" +#include "opentimelineio/externalReference.h" +#include "opentimelineio/timeline.h" +#include "opentimelineio/urlUtils.h" + +#include +#include + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { namespace bundle { + +bool +to_otiod( + Timeline const* timeline, + std::string const& file_name, + WriteOptions const& options, + ErrorStatus* error_status) +{ + try + { + // Check the path does not already exist. + std::filesystem::path const path = std::filesystem::u8path(file_name); + if (std::filesystem::exists(path)) + { + std::stringstream ss; + ss << "'" << path.u8string() << "' exists, will not overwrite."; + throw std::runtime_error(ss.str()); + } + + // Check the parent path exists. + std::filesystem::path const parent_path = path.parent_path(); + if (!std::filesystem::exists(parent_path)) + { + std::stringstream ss; + ss << "Directory '" << parent_path.u8string() + << "' does not exist, cannot create '" << path.u8string() + << "'."; + throw std::runtime_error(ss.str()); + } + + // Check the parent path is a directory. + if (!std::filesystem::is_directory(parent_path)) + { + std::stringstream ss; + ss << "'" << parent_path.u8string() + << "' is not a directory, cannot create '" << path.u8string() + << "'."; + throw std::runtime_error(ss.str()); + } + + // Create the new timeline and file manifest. + Manifest manifest; + auto result_timeline = timeline_for_bundle_and_manifest( + timeline, + std::filesystem::u8path(options.parent_path), + options.media_policy, + manifest); + + // Create the output directory. + std::filesystem::create_directory(path); + + // Write the version file. + { + std::ofstream of; + of.open(path / version_file); + of << otiod_version << '\n'; + } + + // Write the .otio file. + std::string const result_otio_file = (path / otio_file).u8string(); + if (!result_timeline->to_json_file( + result_otio_file, + error_status, + options.target_family_label_spec, + options.indent)) + { + std::stringstream ss; + if (error_status) + { + ss << error_status->details; + } + else + { + ss << "Cannot write timeline: '" << result_otio_file << "'."; + } + throw std::runtime_error(ss.str()); + } + + // Create the media directory and copy the files from the manifest. + // + // @todo Can we use std::async to speed up file copies? + std::filesystem::create_directory(path / media_dir); + for (auto const& i: manifest) + { + std::filesystem::copy_file(i.first, path / i.second); + } + } + catch (const std::exception& e) + { + if (error_status) + { + *error_status = + ErrorStatus(ErrorStatus::BUNDLE_WRITE_ERROR, e.what()); + } + return false; + } + return true; +} + +Timeline* +from_otiod( + std::string const& file_name, + OtiodReadOptions const& options, + ErrorStatus* error_status) +{ + Timeline* timeline = nullptr; + try + { + // Read the timeline. + std::filesystem::path const timeline_path = + std::filesystem::u8path(file_name) / otio_file; + timeline = dynamic_cast( + Timeline::from_json_file(timeline_path.u8string(), error_status)); + + if (options.absolute_media_reference_paths) + { + for (auto cl : timeline->find_clips()) + { + if (auto er = dynamic_cast(cl->media_reference())) + { + std::filesystem::path const path = + timeline_path.parent_path() + / std::filesystem::u8path( + filepath_from_url(er->target_url())); + er->set_target_url(url_from_filepath(path.u8string())); + } + } + } + } + catch (const std::exception& e) + { + if (error_status) + { + *error_status = + ErrorStatus(ErrorStatus::BUNDLE_READ_ERROR, e.what()); + } + } + return timeline; +} + +} // namespace bundle +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/otioz.cpp b/src/opentimelineio/otioz.cpp new file mode 100644 index 0000000000..e96c4264d5 --- /dev/null +++ b/src/opentimelineio/otioz.cpp @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "opentimelineio/bundle.h" + +#include "opentimelineio/bundleUtils.h" +#include "opentimelineio/errorStatus.h" +#include "opentimelineio/timeline.h" +#include "opentimelineio/urlUtils.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { namespace bundle { + +namespace { + +class ZipWriter +{ +public: + ZipWriter(std::string const& zip_file_name); + ~ZipWriter(); + + void add_compressed( + std::string const& string, + std::string const& file_name_in_zip); + + void add_uncompressed( + std::filesystem::path const& path, + std::string const& file_name_in_zip); + + private: + void* _zip = nullptr; + uint32_t _attributes = 0; +}; + +ZipWriter::ZipWriter(std::string const& zip_file_name) +{ + _zip = mz_zip_writer_create(); + if (!_zip) + { + std::stringstream ss; + ss << "Cannot create ZIP writer: '" << zip_file_name << "'."; + throw std::runtime_error(ss.str()); + } + + int32_t err = mz_zip_writer_open_file(_zip, zip_file_name.c_str(), 0, 0); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot open ZIP file: '" << zip_file_name << "'."; + throw std::runtime_error(ss.str()); + } + + err = mz_os_get_file_attribs(zip_file_name.c_str(), &_attributes); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot get file attributes: '" << zip_file_name << "'."; + throw std::runtime_error(ss.str()); + } +} + +ZipWriter::~ZipWriter() +{ + if (_zip) + { + mz_zip_writer_close(_zip); + mz_zip_writer_delete(&_zip); + } +} + +void +ZipWriter::add_compressed( + std::string const& content, + std::string const& file_name_in_zip) +{ + mz_zip_file file_info; + memset(&file_info, 0, sizeof(mz_zip_file)); + mz_zip_writer_set_compress_level(_zip, MZ_COMPRESS_LEVEL_NORMAL); + file_info.version_madeby = MZ_VERSION_MADEBY; + file_info.flag = MZ_ZIP_FLAG_UTF8; + file_info.modified_date = std::time(nullptr); + file_info.compression_method = MZ_COMPRESS_METHOD_DEFLATE; + file_info.uncompressed_size = content.size(); + file_info.filename = file_name_in_zip.c_str(); + file_info.external_fa = _attributes; + int32_t err = mz_zip_writer_add_buffer( + _zip, + (void*)(content.c_str()), + (int32_t)(content.size()), + &file_info); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot add file '" << file_name_in_zip << "' to ZIP."; + throw std::runtime_error(ss.str()); + } +} + +void +ZipWriter::add_uncompressed( + std::filesystem::path const& path, + std::string const& file_name_in_zip) +{ + mz_zip_writer_set_compress_method(_zip, MZ_COMPRESS_METHOD_STORE); + int32_t err = mz_zip_writer_add_file( + _zip, + path.u8string().c_str(), + file_name_in_zip.c_str()); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot add file '" << path.u8string() << "' to ZIP."; + throw std::runtime_error(ss.str()); + } +} + +} // namespace + +bool +to_otioz( + Timeline const* timeline, + std::string const& file_name, + WriteOptions const& options, + ErrorStatus* error_status) +{ + try + { + // Check the path does not already exist. + std::filesystem::path const path = std::filesystem::u8path(file_name); + if (std::filesystem::exists(path)) + { + std::stringstream ss; + ss << "'" << path.u8string() << "' exists, will not overwrite."; + throw std::runtime_error(ss.str()); + } + + // Create the new timeline and file manifest. + Manifest manifest; + auto result_timeline = timeline_for_bundle_and_manifest( + timeline, + std::filesystem::u8path(options.parent_path), + options.media_policy, + manifest); + + // Write the archive. + ZipWriter zip(file_name); + + // Write the version file. + zip.add_compressed(otioz_version, version_file); + + // Write the .otio file. + std::string const result_otio = result_timeline->to_json_string( + error_status, + options.target_family_label_spec, + options.indent); + if (error_status && is_error(error_status)) + { + throw std::runtime_error(error_status->details); + } + zip.add_compressed(result_otio, otio_file); + + // Write the files from the manifest. + for (auto const& i: manifest) + { + zip.add_uncompressed(i.first, i.second.u8string()); + } + } + catch (std::exception const& e) + { + if (error_status) + { + *error_status = + ErrorStatus(ErrorStatus::BUNDLE_WRITE_ERROR, e.what()); + } + return false; + } + return true; +} + +namespace { + +class ZipReader +{ +public: + ZipReader(std::string const& zip_file_name); + ~ZipReader(); + + void extract(std::string const& file_name, std::string&); + + void extract_all(std::string const& output_dir); + +private: + std::string _zip_file_name; + void* _zip = nullptr; +}; + +ZipReader::ZipReader(std::string const& zip_file_name) + : _zip_file_name(zip_file_name) +{ + _zip = mz_zip_reader_create(); + if (!_zip) + { + std::stringstream ss; + ss << "Cannot create ZIP reader: '" << zip_file_name << "'."; + throw std::runtime_error(ss.str()); + } + + int32_t err = mz_zip_reader_open_file(_zip, zip_file_name.c_str()); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot open ZIP file: '" << zip_file_name << "'."; + throw std::runtime_error(ss.str()); + } +} + +ZipReader::~ZipReader() +{ + if (_zip) + { + mz_zip_reader_close(_zip); + mz_zip_reader_delete(&_zip); + } +} + +void ZipReader::extract(std::string const& file_name, std::string& text) +{ + int32_t err = mz_zip_reader_locate_entry(_zip, file_name.c_str(), 0); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot locate file in ZIP: '" << file_name << "'."; + throw std::runtime_error(ss.str()); + } + + int32_t const size = mz_zip_reader_entry_save_buffer_length(_zip); + text.resize(size); + err = mz_zip_reader_entry_save_buffer(_zip, text.data(), size); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot read file in ZIP: '" << file_name << "'."; + throw std::runtime_error(ss.str()); + } +} + +void +ZipReader::extract_all(std::string const& output_dir) +{ + int32_t err = mz_zip_reader_save_all(_zip, output_dir.c_str()); + if (err != MZ_OK) + { + std::stringstream ss; + ss << "Cannot extract ZIP file: '" << _zip_file_name << "'."; + throw std::runtime_error(ss.str()); + } +} + +} // namespace + +Timeline* +from_otioz( + std::string const& file_name, + OtiozReadOptions const& options, + ErrorStatus* error_status) +{ + Timeline* timeline = nullptr; + try + { + // Open the archive. + ZipReader zip(file_name); + + if (!options.extract_path.empty()) + { + // Check the path does not already exist. + std::filesystem::path const extract_path = + std::filesystem::u8path(options.extract_path); + if (std::filesystem::exists(extract_path)) + { + std::stringstream ss; + ss << "'" << extract_path.u8string() + << "' exists, will not overwrite."; + throw std::runtime_error(ss.str()); + } + + // Extract the archive. + zip.extract_all(extract_path.u8string()); + + // Read the timeline. + std::string const timeline_file = + (extract_path / otio_file).u8string(); + timeline = dynamic_cast( + Timeline::from_json_file(timeline_file, error_status)); + } + else + { + // Extract and read the timeline. + std::string json; + zip.extract(otio_file, json); + timeline = dynamic_cast( + Timeline::from_json_string(json, error_status)); + } + } + catch (std::exception const& e) + { + if (error_status) + { + *error_status = + ErrorStatus(ErrorStatus::BUNDLE_READ_ERROR, e.what()); + } + } + return timeline; +} + +} // namespace bundle +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/urlUtils.cpp b/src/opentimelineio/urlUtils.cpp new file mode 100644 index 0000000000..690a135922 --- /dev/null +++ b/src/opentimelineio/urlUtils.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "opentimelineio/urlUtils.h" + +#include "opentimelineio/fileUtils.h" + +#include +#include +#include +#include +#include + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { + +std::string +scheme_from_url(std::string const& url) +{ + std::regex const rx("^([A-Za-z0-9+-\\.]+://)"); + auto const rxi = std::sregex_iterator(url.begin(), url.end(), rx); + return rxi != std::sregex_iterator() ? rxi->str() : std::string(); +} + +std::string +url_encode(std::string const& url) +{ + // Don't encode these characters. + std::vector const chars = { '-', '.', '_', '~', ':', '/', '?', '#', + '[', ']', '@', '!', '$', '&', '\'', '(', + ')', '*', '+', ',', ';', '=', '\\' }; + + // Copy characters to the result, encoding if necessary. + std::stringstream ss; + ss.fill('0'); + ss << std::hex; + for (auto i = url.begin(), end = url.end(); i != end; ++i) + { + auto const j = std::find(chars.begin(), chars.end(), *i); + if (std::isalnum(*i) || j != chars.end()) + { + ss << *i; + } + else + { + ss << '%' << std::setw(2) << int(*i); + } + } + return ss.str(); +} + +std::string +url_decode(std::string const& url) +{ + std::string result; + + // Find all percent encodings. + size_t url_pos = 0; + std::regex const rx("(%[0-9A-Fa-f][0-9A-Fa-f])"); + for (auto i = std::sregex_iterator(url.begin(), url.end(), rx); + i != std::sregex_iterator(); + ++i) + { + // Copy parts without any encodings. + if (url_pos != static_cast(i->position())) + { + result.append(url.substr(url_pos, i->position() - url_pos)); + url_pos = i->position(); + } + + // Convert the encoding and append it. + std::stringstream ss; + ss << std::hex << i->str().substr(1); + unsigned int j = 0; + ss >> j; + result.push_back(char(j)); + url_pos += i->str().size(); + } + + // Copy the remainder without any encodings. + if (!url.empty() && url_pos != url.size() - 1) + { + result.append(url.substr(url_pos, url.size() - url_pos)); + } + + return result; +} + +std::string +url_from_filepath(std::string const& filepath) +{ + std::string const encoded = url_encode(to_unix_separators(filepath)); + std::string const url = std::filesystem::u8path(filepath).is_relative() + ? encoded + : ("file://" + encoded); + return url; +} + +std::string +filepath_from_url(std::string const& url) +{ + // Skip over the URL scheme. + bool has_scheme = false; + size_t pos = 0; + std::string const scheme = scheme_from_url(url); + if (!scheme.empty()) + { + has_scheme = true; + pos += scheme.size(); + } + + // Remove the URL query and fragment. + size_t size = std::string::npos; + size_t i = url.find('?', pos); + size_t j = url.find('#', pos); + if (i != std::string::npos || j != std::string::npos) + { + size = std::min(i, j) + 1; + } + std::string const path = url.substr(pos, size); + + // Decode the path. + std::string decoded = url_decode(path); + + // Use UNIX separators. + decoded = to_unix_separators(decoded); + + // Check for Windows drive letters. + bool has_windows_drive = false; + std::regex rx = std::regex("^([A-Za-z]:)"); + std::smatch matches; + if (std::regex_search(decoded, matches, rx)) + { + has_windows_drive = true; + } + else + { + rx = std::regex("^(.*/)([A-Za-z]:)"); + if (std::regex_search(decoded, matches, rx)) + { + has_windows_drive = true; + decoded = decoded.substr(matches.position(1) + matches.length(1)); + } + } + + // Add the "//" for UNC paths. + bool has_unc = false; + size = decoded.size(); + if (has_scheme && !has_windows_drive && pos < size - 1 && decoded[0] != '/') + { + has_unc = true; + decoded.insert(0, "//"); + } + + // Remove the current directory. + rx = std::regex("^(./)"); + if (!has_windows_drive && !has_unc + && std::regex_search(decoded, matches, rx)) + { + decoded = decoded.substr(matches.position() + matches.length()); + } + + return decoded; +} + +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/opentimelineio/urlUtils.h b/src/opentimelineio/urlUtils.h new file mode 100644 index 0000000000..61c7a6b523 --- /dev/null +++ b/src/opentimelineio/urlUtils.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#pragma once + +#include "opentimelineio/version.h" + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { + +/// @name URL Utilities +/// @todo Should we use a thrid party library for handling URLs? +///@{ + +/// @brief Get the scheme from a URL. +std::string scheme_from_url(std::string const&); + +/// @brief Encode a URL (i.e., replace " " characters with "%20"). +std::string url_encode(std::string const& url); + +/// @brief Decode a URL (i.e., replace "%20" strings with " "). +std::string url_decode(std::string const& url); + +/// @brief Convert a filesystem path to a file URL. +/// +/// For example: +/// * "/var/tmp/thing.otio" -> "file:///var/tmp/thing.otio" +/// * "subdir/thing.otio" -> "tmp/thing.otio" +/// +/// @todo Hopefully this can be replaced by functionality from the C++ +/// standard library at some point. +std::string url_from_filepath(std::string const&); + +/// @brief Convert a file URL to a filesystem path. +/// +/// URLs can either be encoded according to the `RFC 3986` standard or not. +/// Additionally, Windows mapped drive letter and UNC paths need to be +/// accounted for when processing URLs. +/// +/// RFC 3986: https://tools.ietf.org/html/rfc3986 +/// +/// @todo Hopefully this can be replaced by functionality from the C++ +/// standard library at some point. +std::string filepath_from_url(std::string const&); + +///@} + +}} // namespace opentimelineio::OPENTIMELINEIO_VERSION diff --git a/src/py-opentimelineio/opentimelineio-bindings/CMakeLists.txt b/src/py-opentimelineio/opentimelineio-bindings/CMakeLists.txt index d37da3629f..6271f2ba90 100644 --- a/src/py-opentimelineio/opentimelineio-bindings/CMakeLists.txt +++ b/src/py-opentimelineio/opentimelineio-bindings/CMakeLists.txt @@ -16,6 +16,7 @@ pybind11_add_module(_otio otio_tests.cpp otio_serializableObjects.cpp otio_utils.cpp + otio_bundle.cpp ${_OTIO_HEADER_FILES}) target_include_directories(_otio diff --git a/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.cpp b/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.cpp index c714888b4e..1de7c96b5f 100644 --- a/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.cpp +++ b/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.cpp @@ -180,6 +180,7 @@ PYBIND11_MODULE(_otio, m) { otio_imath_bindings(m); otio_serializable_object_bindings(m); otio_tests_bindings(m); + otio_bundle_bindings(m); m.def( "_serialize_json_to_string", diff --git a/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.h b/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.h index dc5287076b..cd03a9ae51 100644 --- a/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.h +++ b/src/py-opentimelineio/opentimelineio-bindings/otio_bindings.h @@ -11,3 +11,4 @@ void otio_any_vector_bindings(pybind11::module); void otio_imath_bindings(pybind11::module); void otio_serializable_object_bindings(pybind11::module); void otio_tests_bindings(pybind11::module); +void otio_bundle_bindings(pybind11::module); diff --git a/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp b/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp new file mode 100644 index 0000000000..e130ef67b4 --- /dev/null +++ b/src/py-opentimelineio/opentimelineio-bindings/otio_bundle.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include +#include +#include + +#include "otio_errorStatusHandler.h" + +#include +#include + +using namespace opentimelineio::OPENTIMELINEIO_VERSION; +using namespace opentimelineio::OPENTIMELINEIO_VERSION::bundle; + +namespace py = pybind11; + +void otio_bundle_bindings(pybind11::module m) +{ + auto mbundle = m.def_submodule("bundle"); + + mbundle.attr("otioz_version") = otioz_version; + mbundle.attr("otiod_version") = otiod_version; + mbundle.attr("version_file") = version_file; + mbundle.attr("otio_file") = otio_file; + mbundle.attr("media_dir") = media_dir; + + py::enum_(mbundle, "MediaReferencePolicy", +R"docstring( +This enumeration provides the bundle media reference policy. +)docstring") + .value( + "ErrorIfNotFile", + MediaReferencePolicy::ErrorIfNotFile, + "Return an error if there are any non-file media references.") + .value( + "MissingIfNotFile", + MediaReferencePolicy::MissingIfNotFile, + "Replace non-file media references with missing references.") + .value( + "AllMissing", + MediaReferencePolicy::AllMissing, + "Replace all media references with missing references."); + + py::class_(mbundle, "WriteOptions", +R"docstring( +Options for writing bundles. +)docstring") + .def(py::init<>()) + .def_readwrite( + "parent_path", + &WriteOptions::parent_path, + "The parent path is used to locate media with relative paths. If " + "parent path is empty, paths are relative to the current working " + "directory.") + .def_readwrite( + "media_policy", + &WriteOptions::media_policy, + "The bundle media reference policy.") + .def_readwrite( + "indent", + &WriteOptions::indent, + "The number of spaces to use for JSON indentation."); + + py::class_(mbundle, "OtiozReadOptions", +R"docstring( +Options for reading .otioz bundles. +)docstring") + .def(py::init<>()) + .def_readwrite( + "extract_path", + &OtiozReadOptions::extract_path, + "Extract the contents of the bundle to the given path. If the path " + "is empty, the contents are not extracted, and only the timeline " + "is read from the bundle."); + + py::class_(mbundle, "OtiodReadOptions", +R"docstring( +Options for reading .otiod bundles. +)docstring") + .def(py::init<>()) + .def_readwrite( + "absolute_media_reference_paths", + &OtiodReadOptions::absolute_media_reference_paths, + "Use absolute paths for media references."); + + mbundle.def( + "get_media_size", + []( + Timeline const* timeline, + WriteOptions const& options = WriteOptions()) + { + return get_media_size(timeline, options, ErrorStatusHandler()); + }, + "Get the total size (in bytes) of the media files that will be put " + "into the bundle.", + py::arg("timeline"), + py::arg("options") = WriteOptions()); + + mbundle.def( + "to_otioz", + []( + Timeline const* timeline, + std::string const& file_name, + WriteOptions const& options = WriteOptions()) + { + return to_otioz(timeline, file_name, options, ErrorStatusHandler()); + }, + "Write a timeline and it's referenced media to an .otioz bundle.", + py::arg("timeline"), + py::arg("file_name"), + py::arg("options") = WriteOptions()); + + mbundle.def( + "from_otioz", + []( + std::string const& file_name, + OtiozReadOptions const& options = OtiozReadOptions()) + { + return from_otioz(file_name, options, ErrorStatusHandler()); + }, + "Read a timeline from an .otioz bundle.", + py::arg("file_name"), + py::arg("options") = OtiozReadOptions()); + + mbundle.def( + "to_otiod", + []( + Timeline const* timeline, + std::string const& file_name, + WriteOptions const& options = WriteOptions()) + { + return to_otiod(timeline, file_name, options, ErrorStatusHandler()); + }, + "Write a timeline and it's referenced media to an .otiod bundle.", + py::arg("timeline"), + py::arg("file_name"), + py::arg("options") = WriteOptions()); + + mbundle.def( + "from_otiod", + []( + std::string const& file_name, + OtiodReadOptions const& options = OtiodReadOptions()) + { + return from_otiod(file_name, options, ErrorStatusHandler()); + }, + "Read a timeline from an .otiod bundle.", + py::arg("file_name"), + py::arg("options") = OtiodReadOptions()); +} diff --git a/src/py-opentimelineio/opentimelineio-bindings/otio_errorStatusHandler.cpp b/src/py-opentimelineio/opentimelineio-bindings/otio_errorStatusHandler.cpp index 506a5db686..be64cf20a5 100644 --- a/src/py-opentimelineio/opentimelineio-bindings/otio_errorStatusHandler.cpp +++ b/src/py-opentimelineio/opentimelineio-bindings/otio_errorStatusHandler.cpp @@ -27,6 +27,18 @@ struct _CannotComputeAvailableRangeException : public OTIOException { using OTIOException::OTIOException; }; +struct _BundleSizeException : public OTIOException { + using OTIOException::OTIOException; +}; + +struct _BundleWriteException : public OTIOException { + using OTIOException::OTIOException; +}; + +struct _BundleReadException : public OTIOException { + using OTIOException::OTIOException; +}; + ErrorStatusHandler::~ErrorStatusHandler() noexcept(false) { if (!is_error(error_status)) { return; @@ -69,6 +81,12 @@ ErrorStatusHandler::~ErrorStatusHandler() noexcept(false) { throw py::value_error("The media references do not contain the active key"); case ErrorStatus::MEDIA_REFERENCES_CONTAIN_EMPTY_KEY: throw py::value_error("The media references contain an empty key"); + case ErrorStatus::BUNDLE_SIZE_ERROR: + throw _BundleSizeException(full_details()); + case ErrorStatus::BUNDLE_WRITE_ERROR: + throw _BundleWriteException(full_details()); + case ErrorStatus::BUNDLE_READ_ERROR: + throw _BundleReadException(full_details()); default: throw py::value_error(full_details()); } diff --git a/src/py-opentimelineio/opentimelineio/__init__.py b/src/py-opentimelineio/opentimelineio/__init__.py index 036907f4f1..869ac1f9b9 100644 --- a/src/py-opentimelineio/opentimelineio/__init__.py +++ b/src/py-opentimelineio/opentimelineio/__init__.py @@ -22,6 +22,5 @@ adapters, hooks, algorithms, - url_utils, versioning, ) diff --git a/src/py-opentimelineio/opentimelineio/adapters/__init__.py b/src/py-opentimelineio/opentimelineio/adapters/__init__.py index 7ee6557742..642336cec2 100644 --- a/src/py-opentimelineio/opentimelineio/adapters/__init__.py +++ b/src/py-opentimelineio/opentimelineio/adapters/__init__.py @@ -28,13 +28,11 @@ # OTIO Json, OTIOZ and OTIOD adapters are always available from . import ( # noqa: F401 otio_json, # core JSON adapter - file_bundle_utils, # utilities for working with OTIO file bundles ) __all__ = [ 'Adapter', 'otio_json', - 'file_bundle_utils', 'suffixes_with_defined_adapters', 'available_adapter_names', 'from_filepath', diff --git a/src/py-opentimelineio/opentimelineio/adapters/file_bundle_utils.py b/src/py-opentimelineio/opentimelineio/adapters/file_bundle_utils.py deleted file mode 100644 index 818299cafa..0000000000 --- a/src/py-opentimelineio/opentimelineio/adapters/file_bundle_utils.py +++ /dev/null @@ -1,172 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -"""Common utilities used by the file bundle adapters (otiod and otioz).""" - -import os -import copy - -from .. import ( - exceptions, - schema, - url_utils, -) - -import urllib - - -# versioning -BUNDLE_VERSION = "1.0.0" -BUNDLE_VERSION_FILE = "version.txt" - -# other variables -BUNDLE_PLAYLIST_PATH = "content.otio" -BUNDLE_DIR_NAME = "media" - - -class NotAFileOnDisk(exceptions.OTIOError): - pass - - -class MediaReferencePolicy: - ErrorIfNotFile = "ErrorIfNotFile" - MissingIfNotFile = "MissingIfNotFile" - AllMissing = "AllMissing" - - -def reference_cloned_and_missing(orig_mr, reason_missing): - """Replace orig_mr with a missing reference with the same metadata. - - Also adds original_target_url and missing_reference_because fields. - """ - - orig_mr = copy.deepcopy(orig_mr) - media_reference = schema.MissingReference() - media_reference.__dict__ = orig_mr.__dict__ - media_reference.metadata['missing_reference_because'] = reason_missing - media_reference.metadata['original_target_url'] = orig_mr.target_url - - return media_reference - - -def _guarantee_unique_basenames(path_list, adapter_name): - # walking across all unique file references, guarantee that all the - # basenames are unique - basename_to_source_fn = {} - for fn in path_list: - new_basename = os.path.basename(fn) - if new_basename in basename_to_source_fn: - raise exceptions.OTIOError( - f"Error: the {adapter_name} adapter requires that the media" - f" files have unique basenames. File '{fn}' and" - f" '{basename_to_source_fn[new_basename]}' have matching" - f" basenames of: '{new_basename}'" - ) - basename_to_source_fn[new_basename] = fn - - -def _prepped_otio_for_bundle_and_manifest( - input_otio, # otio to process - media_policy, # how to handle media references (see: MediaReferencePolicy) - adapter_name, # just for error messages -): - """ Create a new OTIO based on input_otio that has had media references - replaced according to the media_policy. Return that new OTIO and a - mapping of all the absolute file paths (not URLs) to be used in the bundle, - mapped to MediaReferences associated with those files. Media references in - the OTIO will be relinked by the adapters to point to their output - locations. - - The otio[dz] adapters use this function to do further relinking and build - their bundles. - - This is considered an internal API. - - media_policy is expected to be of type MediaReferencePolicy. - """ - - # make sure the incoming OTIO isn't edited - result_otio = copy.deepcopy(input_otio) - - path_to_reference_map = {} - invalid_files = set() - - # result_otio is manipulated in place - for cl in result_otio.find_clips(): - if media_policy == MediaReferencePolicy.AllMissing: - cl.media_reference = reference_cloned_and_missing( - cl.media_reference, - f"{media_policy} specified as the MediaReferencePolicy" - ) - continue - - try: - target_url = cl.media_reference.target_url - except AttributeError: - # not an ExternalReference, ignoring it. - continue - - parsed_url = urllib.parse.urlparse(target_url) - - # ensure that the urlscheme is either "file" or "" - # file means "absolute path" - # "" is interpreted as a relative path, relative to cwd of the python - # process - if parsed_url.scheme not in ("file", ""): - if media_policy is MediaReferencePolicy.ErrorIfNotFile: - raise NotAFileOnDisk( - f"The {adapter_name} adapter only works with media" - " reference target_url attributes that begin with 'file:'." - f" Got a target_url of: '{target_url}'" - ) - if media_policy is MediaReferencePolicy.MissingIfNotFile: - cl.media_reference = reference_cloned_and_missing( - cl.media_reference, - "target_url is not a file scheme url (start with url:)" - ) - continue - - # get an absolute path to the target file - target_file = os.path.abspath(url_utils.filepath_from_url(target_url)) - - # if the file hasn't already been checked - if ( - target_file not in path_to_reference_map - and target_file not in invalid_files - and ( - not os.path.exists(target_file) - or not os.path.isfile(target_file) - ) - ): - invalid_files.add(target_file) - - if target_file in invalid_files: - if media_policy is MediaReferencePolicy.ErrorIfNotFile: - raise NotAFileOnDisk(target_file) - if media_policy is MediaReferencePolicy.MissingIfNotFile: - cl.media_reference = reference_cloned_and_missing( - cl.media_reference, - "target_url target is not a file or does not exist" - ) - - # do not need to relink it in the future or add this target to - # the manifest, because the path is either not a file or does - # not exist. - continue - - # add the media reference to the list of references that point at this - # file, they will need to be relinked - path_to_reference_map.setdefault(target_file, []).append( - cl.media_reference - ) - - _guarantee_unique_basenames(path_to_reference_map.keys(), adapter_name) - - return result_otio, path_to_reference_map - - -def _total_file_size_of(filepaths): - fsize = 0 - for fn in filepaths: - fsize += os.path.getsize(fn) - return fsize diff --git a/src/py-opentimelineio/opentimelineio/adapters/otiod.py b/src/py-opentimelineio/opentimelineio/adapters/otiod.py index f00f4a74a4..65bafcb5a3 100644 --- a/src/py-opentimelineio/opentimelineio/adapters/otiod.py +++ b/src/py-opentimelineio/opentimelineio/adapters/otiod.py @@ -9,132 +9,33 @@ into a single directory named with a suffix of .otiod. """ -import os -import shutil - -from . import ( - file_bundle_utils as utils, - otio_json, -) - from .. import ( - exceptions, - url_utils, + _otio ) -import pathlib -import urllib.parse as urlparse - def read_from_file( filepath, # convert the media_reference paths to absolute paths absolute_media_reference_paths=False, ): - result = otio_json.read_from_file( - os.path.join(filepath, utils.BUNDLE_PLAYLIST_PATH) - ) - - if not absolute_media_reference_paths: - return result - - for cl in result.find_clips(): - try: - source_fpath = cl.media_reference.target_url - except AttributeError: - continue - - rel_path = urlparse.urlparse(source_fpath).path - new_fpath = url_utils.url_from_filepath( - os.path.join(filepath, rel_path) - ) - - cl.media_reference.target_url = new_fpath - - return result + options = _otio.bundle.OtiodReadOptions() + options.absolute_media_reference_paths = absolute_media_reference_paths + return _otio.bundle.from_otiod(filepath, options) def write_to_file( input_otio, filepath, - # see documentation in file_bundle_utils for more information on the - # media_policy - media_policy=utils.MediaReferencePolicy.ErrorIfNotFile, + # see documentation in bundle.h for more information on the media_policy + media_policy=_otio.bundle.MediaReferencePolicy.ErrorIfNotFile, dryrun=False ): + options = _otio.bundle.WriteOptions() + options.media_policy = media_policy - if os.path.exists(filepath): - raise exceptions.OTIOError( - f"'{filepath}' exists, will not overwrite." - ) - - if not os.path.exists(os.path.dirname(filepath)): - raise exceptions.OTIOError( - f"Directory '{os.path.dirname(filepath)}' does not exist, cannot" - f" create '{filepath}'." - ) - - if not os.path.isdir(os.path.dirname(filepath)): - raise exceptions.OTIOError( - f"'{os.path.dirname(filepath)}' is not a directory, cannot create" - f" '{filepath}'." - ) - - # general algorithm for the file bundle adapters: - # ------------------------------------------------------------------------- - # - build file manifest (list of paths to files on disk that will be put - # into the archive) - # - build a mapping of path to file on disk to url to put into the media - # reference in the result - # - relink the media references to point at the final location inside the - # archive - # - build the resulting structure (zip file, directory) - # ------------------------------------------------------------------------- - - result_otio, path_to_mr_map = utils._prepped_otio_for_bundle_and_manifest( - input_otio, - media_policy, - "OTIOD" - ) - - # dryrun reports the total size of files if dryrun: - return utils._total_file_size_of(path_to_mr_map.keys()) - - abspath_to_output_path_map = {} - - # relink all the media references to their target paths - for abspath, references in path_to_mr_map.items(): - target = os.path.join( - filepath, - utils.BUNDLE_DIR_NAME, - os.path.basename(abspath) - ) - - # conform to posix style paths inside the bundle, so that they are - # portable between windows and *nix style environments - final_path = str(pathlib.Path(target).as_posix()) - - # cache the output path - abspath_to_output_path_map[abspath] = final_path - - for mr in references: - # author the relative path from the root of the bundle in url - # form into the target_url - mr.target_url = url_utils.url_from_filepath( - os.path.relpath(final_path, filepath) - ) - - os.mkdir(filepath) - - otio_json.write_to_file( - result_otio, - os.path.join(filepath, utils.BUNDLE_PLAYLIST_PATH) - ) - - # write the media files - os.mkdir(os.path.join(filepath, utils.BUNDLE_DIR_NAME)) - for src, dst in abspath_to_output_path_map.items(): - shutil.copyfile(src, dst) + return _otio.bundle.get_media_size(input_otio, options) + _otio.bundle.to_otiod(input_otio, filepath, options) return diff --git a/src/py-opentimelineio/opentimelineio/adapters/otioz.py b/src/py-opentimelineio/opentimelineio/adapters/otioz.py index 4ccba931c7..27e98dca84 100644 --- a/src/py-opentimelineio/opentimelineio/adapters/otioz.py +++ b/src/py-opentimelineio/opentimelineio/adapters/otioz.py @@ -16,142 +16,34 @@ read on unix and windows platforms. """ -import os -import zipfile - from .. import ( - exceptions, - url_utils, -) - -from . import ( - file_bundle_utils as utils, - otio_json, + _otio ) -import pathlib - def read_from_file( filepath, # if provided, will extract contents of zip to this directory extract_to_directory=None, ): - if not zipfile.is_zipfile(filepath): - raise exceptions.OTIOError(f"Not a zipfile: {filepath}") - - if extract_to_directory: - output_media_directory = os.path.join( - extract_to_directory, - utils.BUNDLE_DIR_NAME - ) - - if not os.path.exists(extract_to_directory): - raise exceptions.OTIOError( - f"Directory '{extract_to_directory()}' does not exist, cannot" - " unpack otioz there." - ) - - if os.path.exists(output_media_directory): - raise exceptions.OTIOError( - f"Error: '{output_media_directory}' already exists on disk, " - f"cannot overwrite while unpacking OTIOZ file '{filepath}'." - ) - - with zipfile.ZipFile(filepath, 'r') as zi: - result = otio_json.read_from_string( - zi.read(utils.BUNDLE_PLAYLIST_PATH) - ) - - if extract_to_directory: - zi.extractall(extract_to_directory) - - return result + options = _otio.bundle.OtiozReadOptions() + if extract_to_directory is not None: + options.extract_path = extract_to_directory + return _otio.bundle.from_otioz(filepath, options) def write_to_file( input_otio, filepath, - # see documentation in file_bundle_utils for more information on the - # media_policy - media_policy=utils.MediaReferencePolicy.ErrorIfNotFile, + # see documentation in bundle.h for more information on the media_policy + media_policy=_otio.bundle.MediaReferencePolicy.ErrorIfNotFile, dryrun=False ): - if os.path.exists(filepath): - raise exceptions.OTIOError( - f"'{filepath}' exists, will not overwrite." - ) - - # general algorithm for the file bundle adapters: - # ------------------------------------------------------------------------- - # - build file manifest (list of paths to files on disk that will be put - # into the archive) - # - build a mapping of path to file on disk to url to put into the media - # reference in the result - # - relink the media references to point at the final location inside the - # archive - # - build the resulting structure (zip file, directory) - # ------------------------------------------------------------------------- + options = _otio.bundle.WriteOptions() + options.media_policy = media_policy - result_otio, path_to_mr_map = utils._prepped_otio_for_bundle_and_manifest( - input_otio, - media_policy, - "OTIOZ" - ) - - # dryrun reports the total size of files if dryrun: - return utils._total_file_size_of(path_to_mr_map.keys()) - - abspath_to_output_path_map = {} - - # relink all the media references to their target paths - for abspath, references in path_to_mr_map.items(): - target = os.path.join(utils.BUNDLE_DIR_NAME, os.path.basename(abspath)) - - # conform to posix style paths inside the bundle, so that they are - # portable between windows and *nix style environments - final_path = str(pathlib.Path(target).as_posix()) - - # cache the output path - abspath_to_output_path_map[abspath] = final_path - - for mr in references: - # author the final_path in url form into the target_url - mr.target_url = url_utils.url_from_filepath(final_path) - - # write the otioz file to the temp directory - otio_str = otio_json.write_to_string(result_otio) - - with zipfile.ZipFile(filepath, mode='w') as target: - # write the version file (compressed) - target.writestr( - utils.BUNDLE_VERSION_FILE, - utils.BUNDLE_VERSION, - # XXX: OTIOZ was introduced when python 2.7 was still a supported - # platform. The newer algorithms, like BZIP2 and LZMA, are not - # available in python2, so it uses the zlib based - # ZIP_DEFLATED. Now that OTIO is Python3+, this could switch - # to using BZIP2 or LZMA instead... with the caveat that this - # would make OTIOZ files incompatible with python 2 based OTIO - # installs. - # - # For example, if we used ZIP_LZMA, then otio release v0.15 - # would still be able to open these files as long as the - # python interpreter was version 3+. - compress_type=zipfile.ZIP_DEFLATED - ) - - # write the OTIO (compressed) - target.writestr( - utils.BUNDLE_PLAYLIST_PATH, - otio_str, - # XXX: See comment above about ZIP_DEFLATED vs other algorithms - compress_type=zipfile.ZIP_DEFLATED - ) - - # write the media (uncompressed) - for src, dst in abspath_to_output_path_map.items(): - target.write(src, dst, compress_type=zipfile.ZIP_STORED) + return _otio.bundle.get_media_size(input_otio, options) + _otio.bundle.to_otioz(input_otio, filepath, options) return diff --git a/src/py-opentimelineio/opentimelineio/url_utils.py b/src/py-opentimelineio/opentimelineio/url_utils.py deleted file mode 100644 index d99cc9ba90..0000000000 --- a/src/py-opentimelineio/opentimelineio/url_utils.py +++ /dev/null @@ -1,108 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -"""Utilities for conversion between urls and file paths""" - -import os -import urllib -from urllib import request -import pathlib - - -def url_from_filepath(fpath): - """Convert a filesystem path to an url in a portable way. - - ensures that `fpath` conforms to the following pattern: - * if it is an absolute path, "file:///path/to/thing" - * if it is a relative path, "path/to/thing" - - In other words, if you pass in: - * "/var/tmp/thing.otio" -> "file:///var/tmp/thing.otio" - * "subdir/thing.otio" -> "tmp/thing.otio" - """ - - try: - # appears to handle absolute windows paths better, which are absolute - # and start with a drive letter. - return urllib.parse.unquote(pathlib.PurePath(fpath).as_uri()) - except ValueError: - # scheme is "file" for absolute paths, else "" - scheme = "file" if os.path.isabs(fpath) else "" - - # handles relative paths - return urllib.parse.urlunparse( - urllib.parse.ParseResult( - scheme=scheme, - path=fpath, - netloc="", - params="", - query="", - fragment="" - ) - ) - - -def filepath_from_url(urlstr): - """ - Take an url and return a filepath. - - URLs can either be encoded according to the `RFC 3986`_ standard or not. - Additionally, Windows mapped drive letter and UNC paths need to be - accounted for when processing URL(s); however, there are `ongoing - discussions`_ about how to best handle this within Python developer - community. This function is meant to cover these scenarios in the interim. - - .. _RFC 3986: https://tools.ietf.org/html/rfc3986#section-2.1 - .. _ongoing discussions: https://discuss.python.org/t/file-uris-in-python/15600 - """ - - # Parse provided URL - parsed_result = urllib.parse.urlparse(urlstr) - - # De-encode the parsed path - decoded_parsed_path = urllib.parse.unquote(parsed_result.path) - - # Convert the parsed URL to a path - filepath = pathlib.PurePath( - request.url2pathname(decoded_parsed_path) - ) - - # If the network location is a window drive, reassemble the path - if pathlib.PureWindowsPath(parsed_result.netloc).drive: - filepath = pathlib.PurePath(parsed_result.netloc + decoded_parsed_path) - - # If the specified index is a windows drive, then append it to the other - # parts - elif pathlib.PureWindowsPath(filepath.parts[0]).drive: - filepath = pathlib.PurePosixPath(filepath.drive, *filepath.parts[1:]) - - # If the specified index is a windows drive, then offset the path - elif ( - # relative paths may not have a parts[1] - len(filepath.parts) > 1 - and pathlib.PureWindowsPath(filepath.parts[1]).drive - ): - # Remove leading "/" if/when `request.url2pathname` yields - # "/S:/path/file.ext" - filepath = pathlib.PurePosixPath(*filepath.parts[1:]) - - # Should catch UNC paths, - # as parsing "file:///some/path/to/file.ext" doesn't provide a netloc - elif parsed_result.netloc and parsed_result.netloc != 'localhost': - # Paths of type: "file://host/share/path/to/file.ext" provide "host" as - # netloc - filepath = pathlib.PurePath( - '//', - parsed_result.netloc + decoded_parsed_path - ) - - # Executing `as_posix` on Windows seems to generate a path with only 1 - # leading `/`, so we insert another `/` at the front of the string path - # to match Linux and Windows UNC conventions and return it. - conformed_filepath = filepath.as_posix() - if not conformed_filepath.startswith('//'): - conformed_filepath = '/' + conformed_filepath - return conformed_filepath - - # Convert "\" to "/" if needed - return filepath.as_posix() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e5eb0b4925..e091901f50 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,7 +15,18 @@ foreach(test ${tests_opentime}) WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) endforeach() -list(APPEND tests_opentimelineio test_clip test_serialization test_serializableCollection test_stack_algo test_timeline test_track test_editAlgorithm test_composition) +list(APPEND tests_opentimelineio + test_clip + test_serialization + test_serializableCollection + test_stack_algo + test_timeline + test_track + test_editAlgorithm + test_composition + test_url_conversions + test_otiod + test_otioz) foreach(test ${tests_opentimelineio}) add_executable(${test} utils.h utils.cpp ${test}.cpp) diff --git a/tests/test_otiod.cpp b/tests/test_otiod.cpp new file mode 100644 index 0000000000..c020047c75 --- /dev/null +++ b/tests/test_otiod.cpp @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "utils.h" + +#include "opentimelineio/bundle.h" +#include "opentimelineio/bundleUtils.h" +#include "opentimelineio/clip.h" +#include "opentimelineio/externalReference.h" +#include "opentimelineio/fileUtils.h" +#include "opentimelineio/imageSequenceReference.h" +#include "opentimelineio/missingReference.h" +#include "opentimelineio/urlUtils.h" + +#include +#include +#include + +namespace otime = opentime::OPENTIME_VERSION; +namespace otio = opentimelineio::OPENTIMELINEIO_VERSION; +namespace bundle = opentimelineio::OPENTIMELINEIO_VERSION::bundle; + +int +main(int argc, char** argv) +{ + Tests tests; + + // Sample data paths. + std::filesystem::path const sample_data_dir = + std::filesystem::current_path() / "sample_data"; + std::string const screening_example_path = otio::to_unix_separators( + (sample_data_dir / "screening_example.otio").u8string()); + + // Sample media paths. + std::string const media_example_path_rel = "OpenTimelineIO@3xDark.png"; + std::string const media_example_path_url_rel = otio::to_unix_separators( + otio::url_from_filepath(media_example_path_rel)); + std::string const media_example_path_abs = otio::to_unix_separators( + (sample_data_dir / "OpenTimelineIO@3xLight.png").u8string()); + std::string const media_example_path_url_abs = otio::to_unix_separators( + otio::url_from_filepath(media_example_path_abs)); + + // Test timeline. + otio::SerializableObject::Retainer timeline( + dynamic_cast(otio::Timeline::from_json_file( + screening_example_path))); + + // Convert to contrived local references. + bool last_rel = false; + for (auto cl : timeline->find_clips()) + { + // Vary the relative and absolute paths, make sure that both work. + std::string const next_rel = last_rel ? media_example_path_url_rel + : media_example_path_url_abs; + last_rel = !last_rel; + cl->set_media_reference(new otio::ExternalReference(next_rel)); + } + + tests.add_test( + "test_file_bundle_manifest_missing_reference", + [sample_data_dir, timeline] + { + std::map manifest; + auto result_timeline = bundle::timeline_for_bundle_and_manifest( + timeline, + sample_data_dir, + bundle::MediaReferencePolicy::AllMissing, + manifest); + + // All missing should be empty. + assertTrue(manifest.empty()); + for (auto cl : result_timeline->find_clips()) + { + assertTrue(dynamic_cast(cl->media_reference())); + } + }); + + tests.add_test( + "test_file_bundle_manifest", + [sample_data_dir, + media_example_path_abs, + media_example_path_rel, + timeline] + { + std::map manifest; + auto result_timeline = bundle::timeline_for_bundle_and_manifest( + timeline, + sample_data_dir, + bundle::MediaReferencePolicy::ErrorIfNotFile, + manifest); + assertEqual(manifest.size(), 2); + + // Compare absolute paths. + std::set const known_files = { + std::filesystem::u8path(media_example_path_abs), + sample_data_dir / std::filesystem::u8path(media_example_path_rel) + }; + std::set manifest_abs; + for (const auto& i : manifest) + { + manifest_abs.insert(i.first); + } + assertEqual(manifest_abs, known_files); + }); + + tests.add_test( + "test_round_trip", + [sample_data_dir, media_example_path_url_rel, timeline] + { + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::filesystem::path const temp_file = temp_dir / "test.otiod"; + bundle::WriteOptions options; + options.parent_path = sample_data_dir.u8string(); + assertTrue(bundle::to_otiod(timeline, temp_file.u8string(), options)); + + // By default will provide relative paths. + auto result = bundle::from_otiod(temp_file.u8string()); + for (auto cl: result->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + assertTrue(std::filesystem::u8path( + otio::filepath_from_url(er->target_url())) + .is_relative()); + } + } + + // Clone the input and conform the media references to what they + // should be in the output. + otio::SerializableObject::Retainer clone( + dynamic_cast(timeline->clone())); + for (auto cl: clone->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + std::filesystem::path const path = + otio::filepath_from_url(er->target_url()); + er->set_target_url(otio::url_from_filepath( + (bundle::media_dir / path.filename()).u8string())); + } + } + + assertEqual( + result->to_json_string(), + clone->to_json_string()); + }); + + tests.add_test( + "test_round_trip_all_missing_references", + [sample_data_dir, timeline] + { + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::filesystem::path const temp_file = temp_dir / "test.otiod"; + bundle::WriteOptions options; + options.parent_path = sample_data_dir.u8string(); + options.media_policy = bundle::MediaReferencePolicy::AllMissing; + assertTrue(bundle::to_otiod( + timeline, + temp_file.u8string(), + options)); + + auto result = bundle::from_otiod(temp_file.u8string()); + + for (auto clip: result->find_clips()) + { + assertTrue(dynamic_cast( + clip->media_reference())); + } + }); + + tests.add_test( + "test_round_trip_absolute_paths", + [sample_data_dir, media_example_path_url_rel, timeline] + { + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::filesystem::path const temp_file = temp_dir / "test.otiod"; + bundle::WriteOptions write_options; + write_options.parent_path = sample_data_dir.u8string(); + assertTrue(bundle::to_otiod( + timeline, + temp_file.u8string(), + write_options)); + + // Can optionally generate absolute paths. + bundle::OtiodReadOptions read_options; + read_options.absolute_media_reference_paths = true; + auto result = bundle::from_otiod( + temp_file.u8string(), + read_options); + + for (auto clip: result->find_clips()) + { + if (auto er = dynamic_cast( + clip->media_reference())) + { + assertTrue(std::filesystem::u8path( + otio::filepath_from_url(er->target_url())) + .is_absolute()); + } + } + + // Clone the input and conform the media references to what they + // should be in the output. + otio::SerializableObject::Retainer clone( + dynamic_cast(timeline->clone())); + for (auto cl: clone->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + std::filesystem::path const path = + otio::filepath_from_url(er->target_url()); + er->set_target_url(otio::url_from_filepath( + (temp_file / bundle::media_dir / path.filename()).u8string())); + } + } + + assertEqual( + result->to_json_string(), + clone->to_json_string()); + }); + + tests.add_test( + "test_round_trip_with_sequence", + [sample_data_dir, media_example_path_rel] + { + // Create an image sequence. + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::string const name_prefix = "sequence."; + std::string const name_suffix = ".png"; + int const frame_zero_padding = 4; + int const sequence_frames = 10; + for (int frame = 0; frame < sequence_frames; ++frame) + { + std::stringstream ss; + ss << name_prefix << + std::setfill('0') << std::setw(frame_zero_padding) << + frame << + name_suffix; + std::filesystem::copy( + sample_data_dir / media_example_path_rel, + temp_dir / ss.str()); + } + + // Create a timeline with an image sequence reference. + otio::SerializableObject::Retainer timeline( + new otio::Timeline); + auto track = new otio::Track; + timeline->tracks()->append_child(track); + auto isr = new otio::ImageSequenceReference( + "", + name_prefix, + name_suffix, + 0, + 1, + 24.0, + frame_zero_padding, + otio::ImageSequenceReference::MissingFramePolicy::error, + otio::TimeRange(0.0, sequence_frames, 24.0)); + auto clip = new otio::Clip("Sequence", isr); + track->append_child(clip); + + // Write the bundle. + std::filesystem::path const temp_file = temp_dir / "test.otiod"; + bundle::WriteOptions write_options; + write_options.parent_path = temp_dir.u8string(); + assertTrue(bundle::to_otiod( + timeline, + temp_file.u8string(), + write_options)); + + // Check the media exists. + for (int frame = 0; frame < sequence_frames; ++frame) + { + std::stringstream ss; + ss << name_prefix << + std::setfill('0') << std::setw(frame_zero_padding) << + frame << + name_suffix; + assertTrue(std::filesystem::exists( + temp_file / bundle::media_dir / ss.str())); + } + }); + + tests.run(argc, argv); + return 0; +} diff --git a/tests/test_otiod.py b/tests/test_otiod.py index aef9eef8e0..85333f291d 100644 --- a/tests/test_otiod.py +++ b/tests/test_otiod.py @@ -11,139 +11,76 @@ import opentimelineio as otio from opentimelineio import test_utils as otio_test_utils -from opentimelineio.adapters import file_bundle_utils SAMPLE_DATA_DIR = os.path.join(os.path.dirname(__file__), "sample_data") -SCREENING_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "screening_example.otio") - -MEDIA_EXAMPLE_PATH_REL = os.path.relpath( - os.path.join( - SAMPLE_DATA_DIR, - "OpenTimelineIO@3xDark.png" - ) -) -MEDIA_EXAMPLE_PATH_URL_REL = otio.url_utils.url_from_filepath( - MEDIA_EXAMPLE_PATH_REL -) -MEDIA_EXAMPLE_PATH_ABS = os.path.abspath( - MEDIA_EXAMPLE_PATH_REL.replace( - "3xDark", - "3xLight" - ) -) -MEDIA_EXAMPLE_PATH_URL_ABS = otio.url_utils.url_from_filepath( - MEDIA_EXAMPLE_PATH_ABS -) +IMAGE0_EXAMPLE = "OpenTimelineIO@3xDark.png" +IMAGE0_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, IMAGE0_EXAMPLE) +IMAGE1_EXAMPLE = "OpenTimelineIO@3xLight.png" +IMAGE1_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, IMAGE1_EXAMPLE) class OTIODTester(unittest.TestCase, otio_test_utils.OTIOAssertions): def setUp(self): - tl = otio.adapters.read_from_file(SCREENING_EXAMPLE_PATH) - - # convert to contrived local reference - last_rel = False - for cl in tl.find_clips(): - # vary the relative and absolute paths, make sure that both work - next_rel = ( - MEDIA_EXAMPLE_PATH_URL_REL if last_rel else MEDIA_EXAMPLE_PATH_URL_ABS - ) - last_rel = not last_rel - cl.media_reference = otio.schema.ExternalReference( - target_url=next_rel - ) - - self.tl = tl - - def test_file_bundle_manifest_missing_reference(self): - # all missing should be empty - result_otio, manifest = ( - file_bundle_utils._prepped_otio_for_bundle_and_manifest( - input_otio=self.tl, - media_policy=file_bundle_utils.MediaReferencePolicy.AllMissing, - adapter_name="TEST_NAME", - ) + track = otio.schema.Track(name="track") + mr0 = otio.schema.ExternalReference( + available_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(1, 24) + ), + target_url=IMAGE0_EXAMPLE_PATH ) - - self.assertEqual(manifest, {}) - for cl in result_otio.find_clips(): - self.assertIsInstance( - cl.media_reference, - otio.schema.MissingReference, - "{} is of type {}, not an instance of {}.".format( - cl.media_reference, - type(cl.media_reference), - type(otio.schema.MissingReference) - ) - ) - - def test_file_bundle_manifest(self): - result_otio, manifest = ( - file_bundle_utils._prepped_otio_for_bundle_and_manifest( - input_otio=self.tl, - media_policy=( - file_bundle_utils.MediaReferencePolicy.ErrorIfNotFile - ), - adapter_name="TEST_NAME", - ) + cl0 = otio.schema.Clip( + name="clip 0", + media_reference=mr0, + source_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(24, 24) + ), ) - - self.assertEqual(len(manifest.keys()), 2) - - files_in_manifest = set(manifest.keys()) - known_files = { - MEDIA_EXAMPLE_PATH_ABS: 5, - os.path.abspath(MEDIA_EXAMPLE_PATH_REL): 4 - } - - # should only contain absolute paths - self.assertEqual(files_in_manifest, set(known_files.keys())) - - for fname, count in known_files.items(): - self.assertEqual(len(manifest[fname]), count) + track.append(cl0) + mr1 = otio.schema.ExternalReference( + available_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(1, 24) + ), + target_url=IMAGE1_EXAMPLE_PATH + ) + cl1 = otio.schema.Clip( + name="clip 1", + media_reference=mr1, + source_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(24, 24) + ), + ) + track.append(cl1) + self.tl = otio.schema.Timeline("test_round_trip", tracks=[track]) def test_round_trip(self): + with tempfile.NamedTemporaryFile(suffix=".otiod") as bogusfile: tmp_path = bogusfile.name otio.adapters.write_to_file(self.tl, tmp_path) self.assertTrue(os.path.exists(tmp_path)) - # by default will provide relative paths - result = otio.adapters.read_from_file( - tmp_path, - ) - - for cl in result.find_clips(): - self.assertNotEqual( - cl.media_reference.target_url, - MEDIA_EXAMPLE_PATH_URL_REL - ) + result = otio.adapters.read_from_file(tmp_path) - # conform media references in input to what they should be in the output - for cl in self.tl.find_clips(): - # construct an absolute file path to the result - cl.media_reference.target_url = ( - otio.url_utils.url_from_filepath( - os.path.join( - otio.adapters.file_bundle_utils.BUNDLE_DIR_NAME, - os.path.basename(cl.media_reference.target_url) - ) - ) - ) - - self.assertJsonEqual(result, self.tl) + clips = result.find_clips() + self.assertTrue( + clips[0].media_reference.target_url.endswith(IMAGE0_EXAMPLE) + ) + self.assertTrue( + clips[1].media_reference.target_url.endswith(IMAGE1_EXAMPLE) + ) def test_round_trip_all_missing_references(self): + with tempfile.NamedTemporaryFile(suffix=".otiod") as bogusfile: tmp_path = bogusfile.name otio.adapters.write_to_file( self.tl, tmp_path, media_policy=( - otio.adapters.file_bundle_utils.MediaReferencePolicy.AllMissing + otio._otio.bundle.MediaReferencePolicy.AllMissing ) ) - # ...but can be optionally told to generate absolute paths result = otio.adapters.read_from_file( tmp_path, absolute_media_reference_paths=True @@ -156,36 +93,23 @@ def test_round_trip_all_missing_references(self): ) def test_round_trip_absolute_paths(self): + with tempfile.NamedTemporaryFile(suffix=".otiod") as bogusfile: tmp_path = bogusfile.name otio.adapters.write_to_file(self.tl, tmp_path) - # ...but can be optionally told to generate absolute paths result = otio.adapters.read_from_file( tmp_path, absolute_media_reference_paths=True ) - for cl in result.find_clips(): - self.assertNotEqual( - cl.media_reference.target_url, - MEDIA_EXAMPLE_PATH_URL_REL - ) - - # conform media references in input to what they should be in the output - for cl in self.tl.find_clips(): - # should be only field that changed - cl.media_reference.target_url = ( - otio.url_utils.url_from_filepath( - os.path.join( - tmp_path, - otio.adapters.file_bundle_utils.BUNDLE_DIR_NAME, - os.path.basename(cl.media_reference.target_url) - ) - ) - ) - - self.assertJsonEqual(result, self.tl) + clips = result.find_clips() + self.assertTrue( + clips[0].media_reference.target_url.endswith(IMAGE0_EXAMPLE) + ) + self.assertTrue( + clips[1].media_reference.target_url.endswith(IMAGE1_EXAMPLE) + ) if __name__ == "__main__": diff --git a/tests/test_otioz.cpp b/tests/test_otioz.cpp new file mode 100644 index 0000000000..226acd8915 --- /dev/null +++ b/tests/test_otioz.cpp @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "utils.h" + +#include "opentimelineio/bundle.h" +#include "opentimelineio/bundleUtils.h" +#include "opentimelineio/clip.h" +#include "opentimelineio/externalReference.h" +#include "opentimelineio/fileUtils.h" +#include "opentimelineio/imageSequenceReference.h" +#include "opentimelineio/missingReference.h" +#include "opentimelineio/urlUtils.h" + +#include +#include +#include + +namespace otime = opentime::OPENTIME_VERSION; +namespace otio = opentimelineio::OPENTIMELINEIO_VERSION; +namespace bundle = opentimelineio::OPENTIMELINEIO_VERSION::bundle; + +int +main(int argc, char** argv) +{ + Tests tests; + + // Sample data paths. + std::filesystem::path const sample_data_dir = + std::filesystem::current_path() / "sample_data"; + std::string const screening_example_path = otio::to_unix_separators( + (sample_data_dir / "screening_example.otio").u8string()); + + // Sample media paths. + std::string const media_example_path_rel = "OpenTimelineIO@3xDark.png"; + std::string const media_example_path_url_rel = otio::to_unix_separators( + otio::url_from_filepath(media_example_path_rel)); + std::string const media_example_path_abs = otio::to_unix_separators( + (sample_data_dir / "OpenTimelineIO@3xLight.png").u8string()); + std::string const media_example_path_url_abs = otio::to_unix_separators( + otio::url_from_filepath(media_example_path_abs)); + + // Test timeline. + otio::SerializableObject::Retainer timeline( + dynamic_cast( + otio::Timeline::from_json_file(screening_example_path))); + + // Convert to contrived local references. + bool last_rel = false; + for (auto cl: timeline->find_clips()) + { + // Vary the relative and absolute paths, make sure that both work. + std::string const next_rel = last_rel ? media_example_path_url_rel + : media_example_path_url_abs; + last_rel = !last_rel; + cl->set_media_reference(new otio::ExternalReference(next_rel)); + } + + tests.add_test( + "test_media_size", + [sample_data_dir, + media_example_path_rel, + media_example_path_abs, + timeline] + { + bundle::WriteOptions options; + options.parent_path = sample_data_dir.u8string(); + size_t const size = bundle::get_media_size(timeline, options); + size_t const size_compare = + std::filesystem::file_size( + sample_data_dir / media_example_path_rel) + + std::filesystem::file_size(media_example_path_abs); + assertEqual(size, size_compare); + }); + + tests.add_test( + "test_not_a_file_error", + [sample_data_dir, + timeline] + { + otio::SerializableObject::Retainer clone( + dynamic_cast(timeline->clone())); + for (auto cl : clone->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + // Write with a non-file scheme. + er->set_target_url("http://not.a.file.com"); + } + } + + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::filesystem::path const temp_file = temp_dir / "test.otioz"; + otio::ErrorStatus error; + assertFalse(bundle::to_otioz( + clone, + temp_file.u8string(), + bundle::WriteOptions(), + &error)); + std::cout << "ERROR: " << error.details << std::endl; + assertTrue(otio::is_error(error)); + }); + + tests.add_test( + "test_colliding_basename", + [sample_data_dir, media_example_path_abs, timeline] + { + std::filesystem::path const temp_dir = otio::create_temp_dir(); + + std::filesystem::path const colliding_file = + temp_dir / std::filesystem::u8path(media_example_path_abs). + filename(); + std::filesystem::copy_file(media_example_path_abs, colliding_file); + + otio::SerializableObject::Retainer clone( + dynamic_cast(timeline->clone())); + if (auto er = dynamic_cast( + clone->find_clips()[0]->media_reference())) + { + er->set_target_url(otio::url_from_filepath(colliding_file.u8string())); + } + + std::filesystem::path const temp_file = temp_dir / "test.otioz"; + bundle::WriteOptions options; + options.parent_path = sample_data_dir.u8string(); + otio::ErrorStatus error; + assertFalse(bundle::to_otioz( + clone, + temp_file.u8string(), + options, + &error)); + std::cout << "ERROR: " << error.details << std::endl; + assertTrue(otio::is_error(error)); + }); + + tests.add_test( + "test_round_trip", + [sample_data_dir, timeline] + { + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::filesystem::path const temp_file = temp_dir / "test.otioz"; + bundle::WriteOptions options; + options.parent_path = sample_data_dir.u8string(); + assertTrue(bundle::to_otioz( + timeline, + temp_file.u8string(), + options)); + + auto result = bundle::from_otioz(temp_file.u8string()); + + for (auto cl : result->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + // Ensure that UNIX style paths are used, so that bundles + // created on Windows are compatible with ones created on UNIX. + std::string const windows("media\\"); + assertNotEqual( + otio::filepath_from_url(er->target_url()) + .substr(windows.size()), + windows); + } + } + + // Clone the input and conform the media references to what they + // should be in the output. + otio::SerializableObject::Retainer clone( + dynamic_cast(timeline->clone())); + for (auto cl : clone->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + std::string const file = + std::filesystem::path( + otio::filepath_from_url(er->target_url())) + .filename() + .u8string(); + std::string const url = otio::url_from_filepath( + (std::filesystem::u8path(bundle::media_dir) / file) + .u8string()); + er->set_target_url(url); + } + } + + assertEqual(result->to_json_string(), clone->to_json_string()); + }); + + tests.add_test( + "test_round_trip_with_extraction", + [sample_data_dir, timeline] + { + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::filesystem::path const temp_file = temp_dir / "test.otioz"; + bundle::WriteOptions write_options; + write_options.parent_path = sample_data_dir.u8string(); + assertTrue(bundle::to_otioz( + timeline, + temp_file.u8string(), + write_options)); + + bundle::OtiozReadOptions read_options; + std::filesystem::path const output_path = temp_dir / "extract"; + read_options.extract_path = output_path.u8string(); + auto result = bundle::from_otioz( + temp_file.u8string(), + read_options); + + // Make sure that all the references are ExternalReference. + for (auto cl : result->find_clips()) + { + assertTrue(dynamic_cast( + cl->media_reference())); + } + + // Clone the input and conform the media references to what they + // should be in the output. + otio::SerializableObject::Retainer clone( + dynamic_cast(timeline->clone())); + for (auto cl: clone->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + std::string const file = + std::filesystem::path( + otio::filepath_from_url(er->target_url())) + .filename() + .u8string(); + std::string const url = otio::url_from_filepath( + (std::filesystem::u8path(bundle::media_dir) / file) + .u8string()); + er->set_target_url(url); + } + } + assertEqual(result->to_json_string(), clone->to_json_string()); + + // Check the version file exists. + assertTrue( + std::filesystem::exists(output_path / bundle::version_file)); + + // Check the content file exists. + assertTrue( + std::filesystem::exists(output_path / bundle::otio_file)); + + // Check the media directory exists. + assertTrue( + std::filesystem::exists(output_path / bundle::media_dir)); + + // Check the media files exist. + for (auto cl: clone->find_clips()) + { + if (auto er = dynamic_cast( + cl->media_reference())) + { + std::string const file = + otio::filepath_from_url(er->target_url()); + assertTrue(std::filesystem::exists(output_path / file)); + } + } + }); + + tests.add_test( + "test_round_trip_with_extraction_no_media", + [sample_data_dir, timeline] + { + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::filesystem::path const temp_file = temp_dir / "test.otioz"; + bundle::WriteOptions write_options; + write_options.parent_path = sample_data_dir.u8string(); + write_options.media_policy = bundle::MediaReferencePolicy::AllMissing; + assertTrue(bundle::to_otioz( + timeline, + temp_file.u8string(), + write_options)); + + bundle::OtiozReadOptions read_options; + std::filesystem::path const output_path = temp_dir / "extract"; + read_options.extract_path = output_path.u8string(); + auto result = bundle::from_otioz( + temp_file.u8string(), + read_options); + + // Check the version file exists. + assertTrue( + std::filesystem::exists(output_path / bundle::version_file)); + + // Check the content file exists. + assertTrue( + std::filesystem::exists(output_path / bundle::otio_file)); + + // Should be all missing references. + for (auto cl: result->find_clips()) + { + assertTrue(dynamic_cast( + cl->media_reference())); + + auto const& metadata = cl->media_reference()->metadata(); + assertTrue( + metadata.find("original_target_url") != metadata.end()); + } + }); + + tests.add_test( + "test_round_trip_with_sequence", + [sample_data_dir, media_example_path_rel] + { + // Create an image sequence. + std::filesystem::path const temp_dir = otio::create_temp_dir(); + std::string const name_prefix = "sequence."; + std::string const name_suffix = ".png"; + int const frame_zero_padding = 4; + int const sequence_frames = 10; + for (int frame = 0; frame < sequence_frames; ++frame) + { + std::stringstream ss; + ss << name_prefix << + std::setfill('0') << std::setw(frame_zero_padding) << + frame << + name_suffix; + std::filesystem::copy( + sample_data_dir / media_example_path_rel, + temp_dir / ss.str()); + } + + // Create a timeline with an image sequence reference. + otio::SerializableObject::Retainer timeline( + new otio::Timeline); + auto track = new otio::Track; + timeline->tracks()->append_child(track); + auto isr = new otio::ImageSequenceReference( + "", + name_prefix, + name_suffix, + 0, + 1, + 24.0, + frame_zero_padding, + otio::ImageSequenceReference::MissingFramePolicy::error, + otio::TimeRange(0.0, sequence_frames, 24.0)); + auto clip = new otio::Clip("Sequence", isr); + track->append_child(clip); + + // Write the bundle. + std::filesystem::path const temp_file = temp_dir / "test.otioz"; + bundle::WriteOptions write_options; + write_options.parent_path = temp_dir.u8string(); + assertTrue(bundle::to_otioz( + timeline, + temp_file.u8string(), + write_options)); + + // Extract the bundle. + bundle::OtiozReadOptions read_options; + std::filesystem::path const output_path = temp_dir / "extract"; + read_options.extract_path = output_path.u8string(); + auto result = bundle::from_otioz( + temp_file.u8string(), + read_options); + + // Check the media exists. + for (int frame = 0; frame < sequence_frames; ++frame) + { + std::stringstream ss; + ss << name_prefix << + std::setfill('0') << std::setw(frame_zero_padding) << + frame << + name_suffix; + assertTrue(std::filesystem::exists( + output_path / bundle::media_dir / ss.str())); + } + }); + + tests.run(argc, argv); + return 0; +} diff --git a/tests/test_otioz.py b/tests/test_otioz.py index f9337b8e9a..ddccc47af5 100644 --- a/tests/test_otioz.py +++ b/tests/test_otioz.py @@ -8,117 +8,65 @@ import unittest import os import tempfile -import shutil - -import urllib.parse as urlparse import opentimelineio as otio import opentimelineio.test_utils as otio_test_utils SAMPLE_DATA_DIR = os.path.join(os.path.dirname(__file__), "sample_data") -SCREENING_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "screening_example.otio") -MEDIA_EXAMPLE_PATH_REL = os.path.relpath( - os.path.join( - SAMPLE_DATA_DIR, - "OpenTimelineIO@3xDark.png" - ) -) -MEDIA_EXAMPLE_PATH_URL_REL = otio.url_utils.url_from_filepath( - MEDIA_EXAMPLE_PATH_REL -) -MEDIA_EXAMPLE_PATH_ABS = os.path.abspath( - MEDIA_EXAMPLE_PATH_REL.replace( - "3xDark", - "3xLight" - ) -) -MEDIA_EXAMPLE_PATH_URL_ABS = otio.url_utils.url_from_filepath( - MEDIA_EXAMPLE_PATH_ABS -) +IMAGE0_EXAMPLE = "OpenTimelineIO@3xDark.png" +IMAGE0_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, IMAGE0_EXAMPLE) +IMAGE1_EXAMPLE = "OpenTimelineIO@3xLight.png" +IMAGE1_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, IMAGE1_EXAMPLE) class OTIOZTester(unittest.TestCase, otio_test_utils.OTIOAssertions): def setUp(self): - tl = otio.adapters.read_from_file(SCREENING_EXAMPLE_PATH) - - # convert to contrived local reference - last_rel = False - for cl in tl.find_clips(): - # vary the relative and absolute paths, make sure that both work - next_rel = ( - MEDIA_EXAMPLE_PATH_URL_REL - if last_rel else MEDIA_EXAMPLE_PATH_URL_ABS - ) - last_rel = not last_rel - cl.media_reference = otio.schema.ExternalReference( - target_url=next_rel - ) - - self.tl = tl + track = otio.schema.Track(name="track") + mr0 = otio.schema.ExternalReference( + available_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(1, 24) + ), + target_url=IMAGE0_EXAMPLE_PATH + ) + cl0 = otio.schema.Clip( + name="clip 0", + media_reference=mr0, + source_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(24, 24) + ), + ) + track.append(cl0) + mr1 = otio.schema.ExternalReference( + available_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(1, 24) + ), + target_url=IMAGE1_EXAMPLE_PATH + ) + cl1 = otio.schema.Clip( + name="clip 1", + media_reference=mr1, + source_range=otio.opentime.TimeRange( + duration=otio.opentime.RationalTime(24, 24) + ), + ) + track.append(cl1) + self.tl = otio.schema.Timeline("test_round_trip", tracks=[track]) def test_dryrun(self): - # generate a fake name + with tempfile.NamedTemporaryFile(suffix=".otioz") as bogusfile: fname = bogusfile.name - # dryrun should compute what the total size of the zipfile will be. + # dryrun will compute the total size of the media size = otio.adapters.write_to_file(self.tl, fname, dryrun=True) self.assertEqual( size, - os.path.getsize(MEDIA_EXAMPLE_PATH_ABS) + - os.path.getsize(MEDIA_EXAMPLE_PATH_REL) - ) - - def test_not_a_file_error(self): - # dryrun should compute what the total size of the zipfile will be. - tmp_path = tempfile.mkstemp(suffix=".otioz", text=False)[1] - with tempfile.NamedTemporaryFile() as bogusfile: - fname = bogusfile.name - for cl in self.tl.find_clips(): - # write with a non-file schema - cl.media_reference = otio.schema.ExternalReference( - target_url=f"http://{fname}" - ) - with self.assertRaises(otio.exceptions.OTIOError): - otio.adapters.write_to_file(self.tl, tmp_path, dryrun=True) - - for cl in self.tl.find_clips(): - cl.media_reference = otio.schema.ExternalReference( - target_url=otio.url_utils.url_from_filepath(fname) - ) - with self.assertRaises(otio.exceptions.OTIOError): - otio.adapters.write_to_file(self.tl, tmp_path, dryrun=True) - - tempdir = tempfile.mkdtemp() - fname = tempdir - shutil.rmtree(tempdir) - for cl in self.tl.find_clips(): - cl.media_reference = otio.schema.ExternalReference(target_url=fname) - - def test_colliding_basename(self): - tempdir = tempfile.mkdtemp() - new_path = os.path.join( - tempdir, - os.path.basename(MEDIA_EXAMPLE_PATH_ABS) + os.path.getsize(IMAGE0_EXAMPLE_PATH) + + os.path.getsize(IMAGE1_EXAMPLE_PATH) ) - shutil.copyfile( - MEDIA_EXAMPLE_PATH_ABS, - new_path - ) - list(self.tl.find_clips())[0].media_reference.target_url = ( - otio.url_utils.url_from_filepath(new_path) - ) - - tmp_path = tempfile.mkstemp(suffix=".otioz", text=False)[1] - with self.assertRaises(otio.exceptions.OTIOError): - otio.adapters.write_to_file(self.tl, tmp_path) - - with self.assertRaises(otio.exceptions.OTIOError): - otio.adapters.write_to_file(self.tl, tmp_path, dryrun=True) - - shutil.rmtree(tempdir) def test_round_trip(self): + with tempfile.NamedTemporaryFile(suffix=".otioz") as bogusfile: tmp_path = bogusfile.name otio.adapters.write_to_file(self.tl, tmp_path) @@ -126,31 +74,16 @@ def test_round_trip(self): result = otio.adapters.read_from_file(tmp_path) - for cl in result.find_clips(): - self.assertNotIn( - cl.media_reference.target_url, - [MEDIA_EXAMPLE_PATH_URL_ABS, MEDIA_EXAMPLE_PATH_URL_REL] - ) - # ensure that unix style paths are used, so that bundles created on - # windows are compatible with ones created on unix - self.assertFalse( - urlparse.urlparse( - cl.media_reference.target_url - ).path.startswith( - "media\\" - ) - ) - - # conform media references in input to what they should be in the output - for cl in self.tl.find_clips(): - # should be only field that changed - cl.media_reference.target_url = "media/{}".format( - os.path.basename(cl.media_reference.target_url) - ) - - self.assertJsonEqual(result, self.tl) + clips = result.find_clips() + self.assertTrue( + clips[0].media_reference.target_url.endswith(IMAGE0_EXAMPLE) + ) + self.assertTrue( + clips[1].media_reference.target_url.endswith(IMAGE1_EXAMPLE) + ) def test_round_trip_with_extraction(self): + with tempfile.NamedTemporaryFile(suffix=".otioz") as bogusfile: tmp_path = bogusfile.name otio.adapters.write_to_file(self.tl, tmp_path) @@ -159,7 +92,7 @@ def test_round_trip_with_extraction(self): tempdir = tempfile.mkdtemp() result = otio.adapters.read_from_file( tmp_path, - extract_to_directory=tempdir + extract_to_directory=os.path.join(tempdir, "extracted") ) # make sure that all the references are ExternalReference @@ -169,82 +102,50 @@ def test_round_trip_with_extraction(self): otio.schema.ExternalReference ) - # conform media references in input to what they should be in the output - for cl in self.tl.find_clips(): - # should be only field that changed - cl.media_reference.target_url = "media/{}".format( - os.path.basename(cl.media_reference.target_url) - ) - - self.assertJsonEqual(result, self.tl) - - # content file + # media directory overall self.assertTrue( - os.path.exists( - os.path.join( - tempdir, - otio.adapters.file_bundle_utils.BUNDLE_PLAYLIST_PATH - ) - ) + os.path.exists(os.path.join(tempdir, "extracted", "media")) ) - # media directory overall + # actual media files self.assertTrue( os.path.exists( - os.path.join( - tempdir, - otio.adapters.file_bundle_utils.BUNDLE_DIR_NAME - ) + os.path.join(tempdir, "extracted", "media", IMAGE0_EXAMPLE) ) ) - - # actual media file self.assertTrue( os.path.exists( - os.path.join( - tempdir, - otio.adapters.file_bundle_utils.BUNDLE_DIR_NAME, - os.path.basename(MEDIA_EXAMPLE_PATH_URL_REL) - ) + os.path.join(tempdir, "extracted", "media", IMAGE1_EXAMPLE) ) ) def test_round_trip_with_extraction_no_media(self): + with tempfile.NamedTemporaryFile(suffix=".otioz") as bogusfile: tmp_path = bogusfile.name otio.adapters.write_to_file( self.tl, tmp_path, media_policy=( - otio.adapters.file_bundle_utils.MediaReferencePolicy.AllMissing + otio._otio.bundle.MediaReferencePolicy.AllMissing ), ) tempdir = tempfile.mkdtemp() - result = otio.adapters.read_from_file( + otio.adapters.read_from_file( tmp_path, - extract_to_directory=tempdir, + extract_to_directory=os.path.join(tempdir, "extracted"), ) - version_file_path = os.path.join( - tempdir, - otio.adapters.file_bundle_utils.BUNDLE_VERSION_FILE + self.assertTrue( + os.path.exists(os.path.join(tempdir, "extracted", "version.txt")) + ) + self.assertTrue( + os.path.exists(os.path.join(tempdir, "extracted", "content.otio")) + ) + self.assertFalse( + os.path.exists(os.path.join(tempdir, "extracted", "media")) ) - self.assertTrue(os.path.exists(version_file_path)) - with open(version_file_path) as fi: - self.assertEqual( - fi.read(), - otio.adapters.file_bundle_utils.BUNDLE_VERSION - ) - - # conform media references in input to what they should be in the output - for cl in result.find_clips(): - # should be all MissingReferences - self.assertIsInstance( - cl.media_reference, - otio.schema.MissingReference - ) - self.assertIn("original_target_url", cl.media_reference.metadata) if __name__ == "__main__": diff --git a/tests/test_url_conversions.cpp b/tests/test_url_conversions.cpp new file mode 100644 index 0000000000..759240ed73 --- /dev/null +++ b/tests/test_url_conversions.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Contributors to the OpenTimelineIO project + +#include "utils.h" + +#include +#include + +#include +#include + +namespace otime = opentime::OPENTIME_VERSION; +namespace otio = opentimelineio::OPENTIMELINEIO_VERSION; + +int +main(int argc, char** argv) +{ + Tests tests; + + // Sample data paths. + std::filesystem::path const sample_data_dir = + std::filesystem::current_path() / "sample_data"; + std::string const screening_example_path = otio::to_unix_separators( + (sample_data_dir / "screening_example.otio").u8string()); + + // Sample media paths. + std::string const media_example_path_rel = "OpenTimelineIO@3xDark.png"; + std::string const media_example_path_url_rel = otio::to_unix_separators( + otio::url_from_filepath(media_example_path_rel)); + std::string const media_example_path_abs = otio::to_unix_separators( + (sample_data_dir / "OpenTimelineIO@3xLight.png").u8string()); + std::string const media_example_path_url_abs = otio::to_unix_separators( + otio::url_from_filepath(media_example_path_abs)); + + // Windows test paths. + std::string const windows_encoded_url = "file://host/S%3a/path/file.ext"; + std::string const windows_drive_url = "file://S:/path/file.ext"; + std::string const windows_drive_path = "S:/path/file.ext"; + + // Windows UNC test paths. + std::string const windows_encoded_unc_url = + "file://unc/path/sub%20dir/file.ext"; + std::string const windows_unc_url = "file://unc/path/sub dir/file.ext"; + std::string const windows_unc_path = "//unc/path/sub dir/file.ext"; + + // POSIX test paths. + std::string const posix_localhost_url = + "file://localhost/path/sub dir/file.ext"; + std::string const posix_encoded_url = "file:///path/sub%20dir/file.ext"; + std::string const posix_url = "file:///path/sub dir/file.ext"; + std::string const posix_path = "/path/sub dir/file.ext"; + + tests.add_test( + "test_roundtrip_abs", + [media_example_path_abs, media_example_path_url_abs] { + assertEqual(media_example_path_url_abs.substr(0, 7), std::string("file://")); + std::string const filepath = + otio::filepath_from_url(media_example_path_url_abs); + assertEqual(filepath, media_example_path_abs); + }); + + tests.add_test( + "test_roundtrip_rel", + [media_example_path_rel, media_example_path_url_rel] { + assertNotEqual(media_example_path_url_rel.substr(0, 7), std::string("file://")); + std::string const filepath = + otio::filepath_from_url(media_example_path_url_rel); + assertEqual(filepath, media_example_path_rel); + }); + + tests.add_test( + "test_windows_urls", + [windows_encoded_url, windows_drive_url, windows_drive_path] { + for (auto const url : { windows_encoded_url, windows_drive_url }) { + std::string const filepath = otio::filepath_from_url(url); + assertEqual(filepath, windows_drive_path); + } + }); + + tests.add_test( + "test_windows_unc_urls", + [windows_encoded_unc_url, windows_unc_url, windows_unc_path] { + for (auto const url : { windows_encoded_unc_url, windows_unc_url }) { + std::string const filepath = otio::filepath_from_url(url); + assertEqual(filepath, windows_unc_path); + } + }); + + tests.add_test( + "test_posix_urls", + [posix_encoded_url, posix_url, posix_localhost_url, posix_path] { + for (auto const url : { posix_encoded_url, posix_url }) { + std::string const filepath = otio::filepath_from_url(url); + assertEqual(filepath, posix_path); + } + }); + + tests.add_test( + "test_relative_url", + [] { + // See github issue #1817 - when a relative URL has only one name after + // the "." (ie ./blah but not ./blah/blah) + std::string const filepath = otio::filepath_from_url( + (std::filesystem::path(".") / std::filesystem::path("docs")).u8string()); + assertEqual(filepath, std::string("docs")); + }); + + tests.run(argc, argv); + return 0; +} diff --git a/tests/test_url_conversions.py b/tests/test_url_conversions.py deleted file mode 100644 index 3adff3c03e..0000000000 --- a/tests/test_url_conversions.py +++ /dev/null @@ -1,92 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -""" Unit tests of functions that convert between file paths and urls. """ - -import unittest -import os - -import opentimelineio as otio - -SAMPLE_DATA_DIR = os.path.join(os.path.dirname(__file__), "sample_data") -SCREENING_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "screening_example.otio") -MEDIA_EXAMPLE_PATH_REL = os.path.relpath( - os.path.join( - os.path.dirname(__file__), - "..", # root - "docs", - "_static", - "OpenTimelineIO@3xDark.png" - ) -) -MEDIA_EXAMPLE_PATH_URL_REL = otio.url_utils.url_from_filepath( - MEDIA_EXAMPLE_PATH_REL -) -MEDIA_EXAMPLE_PATH_ABS = os.path.abspath( - MEDIA_EXAMPLE_PATH_REL.replace( - "3xDark", - "3xLight" - ) -) -MEDIA_EXAMPLE_PATH_URL_ABS = otio.url_utils.url_from_filepath( - MEDIA_EXAMPLE_PATH_ABS -) - -WINDOWS_ENCODED_URL = "file://host/S%3a/path/file.ext" -WINDOWS_DRIVE_URL = "file://S:/path/file.ext" -WINDOWS_DRIVE_PATH = "S:/path/file.ext" - -WINDOWS_ENCODED_UNC_URL = "file://unc/path/sub%20dir/file.ext" -WINDOWS_UNC_URL = "file://unc/path/sub dir/file.ext" -WINDOWS_UNC_PATH = "//unc/path/sub dir/file.ext" - -POSIX_LOCALHOST_URL = "file://localhost/path/sub dir/file.ext" -POSIX_ENCODED_URL = "file:///path/sub%20dir/file.ext" -POSIX_URL = "file:///path/sub dir/file.ext" -POSIX_PATH = "/path/sub dir/file.ext" - - -class TestConversions(unittest.TestCase): - def test_roundtrip_abs(self): - self.assertTrue(MEDIA_EXAMPLE_PATH_URL_ABS.startswith("file://")) - full_path = os.path.abspath( - otio.url_utils.filepath_from_url(MEDIA_EXAMPLE_PATH_URL_ABS) - ) - - # should have reconstructed it by this point - self.assertEqual(full_path, MEDIA_EXAMPLE_PATH_ABS) - - def test_roundtrip_rel(self): - self.assertFalse(MEDIA_EXAMPLE_PATH_URL_REL.startswith("file://")) - - result = otio.url_utils.filepath_from_url(MEDIA_EXAMPLE_PATH_URL_REL) - - # should have reconstructed it by this point - self.assertEqual(os.path.normpath(result), MEDIA_EXAMPLE_PATH_REL) - - def test_windows_urls(self): - for url in (WINDOWS_ENCODED_URL, WINDOWS_DRIVE_URL): - processed_url = otio.url_utils.filepath_from_url(url) - self.assertEqual(processed_url, WINDOWS_DRIVE_PATH) - - def test_windows_unc_urls(self): - for url in (WINDOWS_ENCODED_UNC_URL, WINDOWS_UNC_URL): - processed_url = otio.url_utils.filepath_from_url(url) - self.assertEqual(processed_url, WINDOWS_UNC_PATH) - - def test_posix_urls(self): - for url in (POSIX_ENCODED_URL, POSIX_URL, POSIX_LOCALHOST_URL): - processed_url = otio.url_utils.filepath_from_url(url) - self.assertEqual(processed_url, POSIX_PATH) - - def test_relative_url(self): - # see github issue #1817 - when a relative URL has only one name after - # the "." (ie ./blah but not ./blah/blah) - self.assertEqual( - otio.url_utils.filepath_from_url(os.path.join(".", "docs")), - "docs", - ) - - -if __name__ == "__main__": - unittest.main()