diff --git a/contrib/opentimelineio_contrib/adapters/extern_rv.py b/contrib/opentimelineio_contrib/adapters/extern_rv.py index eaa65a986e..a2c20fb66d 100755 --- a/contrib/opentimelineio_contrib/adapters/extern_rv.py +++ b/contrib/opentimelineio_contrib/adapters/extern_rv.py @@ -250,6 +250,19 @@ def _create_media_reference(item, src, track_kind=None): src.setMedia(media) return True + elif isinstance(item.media_reference, otio.schema.ImageSequenceReference): + frame_sub = "%0{n}d".format( + n=item.media_reference.frame_zero_padding + ) + + media = [ + str(item.media_reference.abstract_target_url(symbol=frame_sub)) + ] + + src.setMedia(media) + + return True + elif isinstance(item.media_reference, otio.schema.GeneratorReference): if item.media_reference.generator_kind == "SMPTEBars": kind = "smptebars" @@ -291,20 +304,27 @@ def _write_item(it, to_session, track_kind=None): ) ) - # because OTIO has no global concept of FPS, the rate of the duration is - # used as the rate for the range of the source. - # RationalTime.value_rescaled_to returns the time value of the object in - # time rate of the argument. - src.setCutIn( - range_to_read.start_time.value_rescaled_to( - range_to_read.duration + in_frame = out_frame = None + if hasattr(it, "media_reference") and it.media_reference: + if isinstance(it.media_reference, otio.schema.ImageSequenceReference): + in_frame, out_frame = it.media_reference.frame_range_for_time_range( + range_to_read + ) + + if not in_frame and not out_frame: + # because OTIO has no global concept of FPS, the rate of the duration + # is used as the rate for the range of the source. + in_frame = otio.opentime.to_frames( + range_to_read.start_time, + rate=range_to_read.duration.rate ) - ) - src.setCutOut( - range_to_read.end_time_inclusive().value_rescaled_to( - range_to_read.duration + out_frame = otio.opentime.to_frames( + range_to_read.end_time_inclusive(), + rate=range_to_read.duration.rate ) - ) + + src.setCutIn(in_frame) + src.setCutOut(out_frame) src.setFPS(range_to_read.duration.rate) # if the media reference is missing diff --git a/contrib/opentimelineio_contrib/adapters/tests/sample_data/image_sequence_example.otio b/contrib/opentimelineio_contrib/adapters/tests/sample_data/image_sequence_example.otio new file mode 100644 index 0000000000..ad2e18a30c --- /dev/null +++ b/contrib/opentimelineio_contrib/adapters/tests/sample_data/image_sequence_example.otio @@ -0,0 +1,77 @@ +{ + "OTIO_SCHEMA": "Timeline.1", + "metadata": {}, + "name": "", + "global_start_time": null, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "metadata": {}, + "name": "tracks", + "source_range": null, + "effects": [], + "markers": [], + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "V", + "source_range": null, + "effects": [], + "markers": [], + "children": [ + { + "OTIO_SCHEMA": "Clip.1", + "metadata": { + "cmx_3600": { + "reel": "sample_sequence" + } + }, + "name": "sample_sequence", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 30.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 86410.0 + } + }, + "effects": [], + "markers": [], + "media_reference": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 50.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 86400.0 + } + }, + "target_url_base": "./sample_sequence/", + "name_prefix": "sample_sequence.", + "name_suffix": ".exr", + "start_frame": 1001, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + } + ], + "kind": "Video" + } + ] + } +} diff --git a/contrib/opentimelineio_contrib/adapters/tests/test_rvsession.py b/contrib/opentimelineio_contrib/adapters/tests/test_rvsession.py index b7647a4b97..6766f485e8 100644 --- a/contrib/opentimelineio_contrib/adapters/tests/test_rvsession.py +++ b/contrib/opentimelineio_contrib/adapters/tests/test_rvsession.py @@ -47,6 +47,10 @@ BASELINE_TRANSITION_PATH = os.path.join(SAMPLE_DATA_DIR, "transition_test.rv") METADATA_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "rv_metadata.otio") METADATA_BASELINE_PATH = os.path.join(SAMPLE_DATA_DIR, "rv_metadata.rv") +IMAGE_SEQUENCE_EXAMPLE_PATH = os.path.join( + SAMPLE_DATA_DIR, + "image_sequence_example.otio" +) SAMPLE_DATA = """{ @@ -466,14 +470,21 @@ "RV Adapter does not work in python 3." ) class RVSessionAdapterReadTest(unittest.TestCase): + def setUp(self): + fd, self.tmp_path = tempfile.mkstemp(suffix=".rv", text=True) + + # Close file descriptor to avoid leak. We only need the tmp_path. + os.close(fd) + + def tearDown(self): + os.unlink(self.tmp_path) + def test_basic_rvsession_read(self): timeline = otio.adapters.read_from_file(SCREENING_EXAMPLE_PATH) - tmp_path = tempfile.mkstemp(suffix=".rv", text=True)[1] - otio.adapters.write_to_file(timeline, tmp_path) - self.assertTrue(os.path.exists(tmp_path)) + otio.adapters.write_to_file(timeline, self.tmp_path) - with open(tmp_path) as fo: + with open(self.tmp_path) as fo: test_data = fo.read() with open(BASELINE_PATH) as fo: @@ -484,12 +495,11 @@ def test_basic_rvsession_read(self): def test_transition_rvsession_read(self): timeline = otio.adapters.read_from_file(TRANSITION_EXAMPLE_PATH) - tmp_path = tempfile.mkstemp(suffix=".rv", text=True)[1] - otio.adapters.write_to_file(timeline, tmp_path) - self.assertTrue(os.path.exists(tmp_path)) + otio.adapters.write_to_file(timeline, self.tmp_path) + self.assertTrue(os.path.exists(self.tmp_path)) - with open(tmp_path) as fo: + with open(self.tmp_path) as fo: test_data = fo.read() with open(BASELINE_TRANSITION_PATH) as fo: @@ -498,16 +508,35 @@ def test_transition_rvsession_read(self): self.maxDiff = None self.assertMultiLineEqual(baseline_data, test_data) + def test_image_sequence_example(self): + # SETUP + timeline = otio.adapters.read_from_file(IMAGE_SEQUENCE_EXAMPLE_PATH) + + # EXERCISE + otio.adapters.write_to_file(timeline, self.tmp_path) + + # VERIFY + self.assertTrue(os.path.exists(self.tmp_path)) + + with open(self.tmp_path) as f: + rv_session = f.read() + + self.assertEqual( + rv_session.count( + 'string movie = "./sample_sequence/sample_sequence.%04d.exr"' + ), + 1 + ) + def test_transition_rvsession_covers_entire_shots(self): # SETUP timeline = otio.adapters.read_from_string(SAMPLE_DATA, "otio_json") - tmp_path = tempfile.mkstemp(suffix=".rv", text=True)[1] # EXERCISE - otio.adapters.write_to_file(timeline, tmp_path) + otio.adapters.write_to_file(timeline, self.tmp_path) # VERIFY - with open(tmp_path, "r") as f: + with open(self.tmp_path, "r") as f: rv_session = f.read() self.assertEqual(rv_session.count('movie = "blank'), 1) @@ -516,13 +545,12 @@ def test_transition_rvsession_covers_entire_shots(self): def test_audio_video_tracks(self): # SETUP timeline = otio.adapters.read_from_string(AUDIO_VIDEO_SAMPLE_DATA, "otio_json") - tmp_path = tempfile.mkstemp(suffix=".rv", text=True)[1] # EXERCISE - otio.adapters.write_to_file(timeline, tmp_path) + otio.adapters.write_to_file(timeline, self.tmp_path) # VERIFY - self.assertTrue(os.path.exists(tmp_path)) + self.assertTrue(os.path.exists(self.tmp_path)) audio_video_source = ( 'string movie = ' @@ -531,7 +559,7 @@ def test_audio_video_tracks(self): ' "/path/to/audio.wav" ]' ) - with open(tmp_path, "r") as f: + with open(self.tmp_path, "r") as f: rv_session = f.read() self.assertEqual(rv_session.count("string movie"), 2) @@ -544,13 +572,12 @@ def test_nested_stack(self): NESTED_STACK_SAMPLE_DATA, "otio_json" ) - tmp_path = tempfile.mkstemp(suffix=".rv", text=True)[1] # EXERCISE - otio.adapters.write_to_file(timeline, tmp_path) + otio.adapters.write_to_file(timeline, self.tmp_path) # VERIFY - self.assertTrue(os.path.exists(tmp_path)) + self.assertTrue(os.path.exists(self.tmp_path)) audio_video_source = ( 'string movie = ' @@ -562,7 +589,7 @@ def test_nested_stack(self): 'string movie = "/path/to/some/video.mov"' ) - with open(tmp_path, "r") as f: + with open(self.tmp_path, "r") as f: rv_session = f.read() self.assertEqual(rv_session.count(video_source), 2) self.assertEqual(rv_session.count(audio_video_source), 2) diff --git a/contrib/opentimelineio_contrib/application_plugins/rv/example_otio_reader/otio_reader.py b/contrib/opentimelineio_contrib/application_plugins/rv/example_otio_reader/otio_reader.py index f85b2da787..aa3afd3441 100644 --- a/contrib/opentimelineio_contrib/application_plugins/rv/example_otio_reader/otio_reader.py +++ b/contrib/opentimelineio_contrib/application_plugins/rv/example_otio_reader/otio_reader.py @@ -206,6 +206,17 @@ def _create_media_reference(item, track_kind=None): # Appending blank to media promotes name of audio file in RV media.append(blank) + return media + elif isinstance(item.media_reference, + otio.schema.ImageSequenceReference): + frame_sub = "%0{n}d".format( + n=item.media_reference.frame_zero_padding + ) + + media = [ + str(item.media_reference.abstract_target_url(symbol=frame_sub)) + ] + return media elif isinstance(item.media_reference, otio.schema.GeneratorReference): if item.media_reference.generator_kind == "SMPTEBars": @@ -250,19 +261,28 @@ def _create_item(it, track_kind=None): if hasattr(it, "media_reference") and it.media_reference: _add_metadata_to_node(it.media_reference, src) - # because OTIO has no global concept of FPS, the rate of the duration is - # used as the rate for the range of the source. - # RationalTime.value_rescaled_to returns the time value of the object in - # time rate of the argument. - cut_in = range_to_read.start_time.value_rescaled_to( - range_to_read.duration - ) - commands.setIntProperty(src + ".cut.in", [int(cut_in)]) + in_frame = out_frame = None + if hasattr(it, "media_reference") and it.media_reference: + if isinstance(it.media_reference, otio.schema.ImageSequenceReference): + in_frame, out_frame = \ + it.media_reference.frame_range_for_time_range( + range_to_read + ) + + if not in_frame and not out_frame: + # because OTIO has no global concept of FPS, the rate of the duration + # is used as the rate for the range of the source. + in_frame = otio.opentime.to_frames( + range_to_read.start_time, + rate=range_to_read.duration.rate + ) + out_frame = otio.opentime.to_frames( + range_to_read.end_time_inclusive(), + rate=range_to_read.duration.rate + ) - cut_out = range_to_read.end_time_inclusive().value_rescaled_to( - range_to_read.duration - ) - commands.setIntProperty(src + ".cut.out", [int(cut_out)]) + commands.setIntProperty(src + ".cut.in", [in_frame]) + commands.setIntProperty(src + ".cut.out", [out_frame]) commands.setFloatProperty(src + ".group.fps", [float(range_to_read.duration.rate)]) diff --git a/docs/tutorials/otio-serialized-schema-only-fields.md b/docs/tutorials/otio-serialized-schema-only-fields.md index 50adfdaab1..945496172e 100644 --- a/docs/tutorials/otio-serialized-schema-only-fields.md +++ b/docs/tutorials/otio-serialized-schema-only-fields.md @@ -179,6 +179,21 @@ parameters: - *name* - *parameters* +### ImageSequenceReference.1 + +parameters: +- *available_range* +- *frame_step* +- *frame_zero_padding* +- *metadata* +- *missing_frame_policy* +- *name* +- *name_prefix* +- *name_suffix* +- *rate* +- *start_frame* +- *target_url_base* + ### LinearTimeWarp.1 parameters: diff --git a/docs/tutorials/otio-serialized-schema.md b/docs/tutorials/otio-serialized-schema.md index ba03350d60..3af377f7ca 100644 --- a/docs/tutorials/otio-serialized-schema.md +++ b/docs/tutorials/otio-serialized-schema.md @@ -354,6 +354,89 @@ parameters: - *name*: - *parameters*: +### ImageSequenceReference.1 + +*full module path*: `opentimelineio.schema.ImageSequenceReference` + +*documentation*: + +``` + +An ImageSequenceReference refers to a numbered series of single-frame image files. Each file can be referred to by a URL generated by the ImageSequenceReference. + +Image sequncences can have URLs with discontinuous frame numbers, for instance if you've only rendered every other frame in a sequence, your frame numbers may be 1, 3, 5, etc. This is configured using the ``frame_step`` attribute. In this case, the 0th image in the sequence is frame 1 and the 1st image in the sequence is frame 3. Because of this there are two numbering concepts in the image sequence, the image number and the frame number. + +Frame numbers are the integer numbers used in the frame file name. Image numbers are the 0-index based numbers of the frames available in the reference. Frame numbers can be discontinuous, image numbers will always be zero to the total count of frames minus 1. + +An example for 24fps media with a sample provided each frame numbered 1-1000 with a path ``/show/sequence/shot/sample_image_sequence.%04d.exr`` might be:: + + { + "available_range": { + "start_time": { + "value": 0, + "rate": 24 + }, + "duration": { + "value": 1000, + "rate": 24 + } + }, + "start_frame": 1, + "frame_step": 1, + "rate": 24, + "target_url_base": "file:///show/sequence/shot/", + "name_prefix": "sample_image_sequence.", + "name_suffix": ".exr" + "frame_zero_padding": 4, + } + +The same duration sequence but with only every 2nd frame available in the sequence would be:: + + { + "available_range": { + "start_time": { + "value": 0, + "rate": 24 + }, + "duration": { + "value": 1000, + "rate": 24 + } + }, + "start_frame": 1, + "frame_step": 2, + "rate": 24, + "target_url_base": "file:///show/sequence/shot/", + "name_prefix": "sample_image_sequence.", + "name_suffix": ".exr" + "frame_zero_padding": 4, + } + +A list of all the frame URLs in the sequence can be generated, regardless of frame step, with the following list comprehension:: + + [ref.target_url_for_image_number(i) for i in range(ref.number_of_images_in_sequence())] + +Negative ``start_frame`` is also handled. The above example with a ``start_frame`` of ``-1`` would yield the first three target urls as: + +- ``file:///show/sequence/shot/sample_image_sequence.-0001.exr`` +- ``file:///show/sequence/shot/sample_image_sequence.0000.exr`` +- ``file:///show/sequence/shot/sample_image_sequence.0001.exr`` + +``` + +parameters: +- *available_range*: +- *frame_step*: Step between frame numbers in file names. +- *frame_zero_padding*: Number of digits to pad zeros out to in frame numbers. +- *metadata*: +- *missing_frame_policy*: Enum ``ImageSequenceReference.MissingFramePolicy`` directive for how frames in sequence not found on disk should be handled. +- *name*: +- *name_prefix*: Everything in the file name leading up to the frame number. +- *name_suffix*: Everything after the frame number in the file name. +- *rate*: Frame rate if every frame in the sequence were played back. +- *start_frame*: The first frame number used in file names. +- *target_url_base*: Everything leading up to the file name in the ``target_url``. + ### LinearTimeWarp.1 *full module path*: `opentimelineio.schema.LinearTimeWarp` diff --git a/src/opentimelineio/CMakeLists.txt b/src/opentimelineio/CMakeLists.txt index c55aca05da..b78ebd1b38 100644 --- a/src/opentimelineio/CMakeLists.txt +++ b/src/opentimelineio/CMakeLists.txt @@ -12,6 +12,7 @@ set(OPENTIMELINEIO_HEADER_FILES freezeFrame.h gap.h generatorReference.h + imageSequenceReference.h item.h linearTimeWarp.h marker.h @@ -47,6 +48,7 @@ add_library(opentimelineio SHARED freezeFrame.cpp gap.cpp generatorReference.cpp + imageSequenceReference.cpp item.cpp linearTimeWarp.cpp marker.cpp diff --git a/src/opentimelineio/imageSequenceReference.cpp b/src/opentimelineio/imageSequenceReference.cpp new file mode 100644 index 0000000000..c5f1d2055d --- /dev/null +++ b/src/opentimelineio/imageSequenceReference.cpp @@ -0,0 +1,174 @@ +#include "opentimelineio/imageSequenceReference.h" + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { + +ImageSequenceReference::ImageSequenceReference(std::string const& target_url_base, + std::string const& name_prefix, + std::string const& name_suffix, + int start_frame, + int frame_step, + double const rate, + int frame_zero_padding, + MissingFramePolicy const missing_frame_policy, + optional const& available_range, + AnyDictionary const& metadata) + : Parent(std::string(), available_range, metadata), + _target_url_base(target_url_base), + _name_prefix(name_prefix), + _name_suffix(name_suffix), + _start_frame {start_frame}, + _frame_step {frame_step}, + _rate {rate}, + _frame_zero_padding {frame_zero_padding}, + _missing_frame_policy {missing_frame_policy} { + } + + ImageSequenceReference::~ImageSequenceReference() { + } + + RationalTime + ImageSequenceReference::frame_duration() const { + return RationalTime((double)_frame_step, _rate); + } + + int ImageSequenceReference::end_frame() const { + if (!this->available_range().has_value()) { + return _start_frame; + } + + int num_frames = this->available_range().value().duration().to_frames(_rate); + + // Subtract 1 for inclusive frame ranges + return (_start_frame + num_frames - 1); + } + + int ImageSequenceReference::number_of_images_in_sequence() const { + if (!this->available_range().has_value()) { + return 0; + } + + double playback_rate = (_rate / (double)_frame_step); + int num_frames = this->available_range().value().duration().to_frames(playback_rate); + return num_frames; + } + + int ImageSequenceReference::frame_for_time(RationalTime const time, ErrorStatus* error_status) const { + if (!this->available_range().has_value() || !this->available_range().value().contains(time)) { + *error_status = ErrorStatus(ErrorStatus::INVALID_TIME_RANGE); + return 0; + } + + RationalTime start = this->available_range().value().start_time(); + RationalTime duration_from_start = (time - start); + int frame_offset = duration_from_start.to_frames(_rate); + + *error_status = ErrorStatus(ErrorStatus::OK); + + return (_start_frame + frame_offset); + } + + std::string + ImageSequenceReference::target_url_for_image_number(int const image_number, ErrorStatus* error_status) const { + if (image_number >= this->number_of_images_in_sequence()) { + *error_status = ErrorStatus(ErrorStatus::ILLEGAL_INDEX); + return std::string(); + } + const int file_image_num = _start_frame + (image_number * _frame_step); + const bool is_negative = (file_image_num < 0); + + std::string image_num_string = std::to_string(abs(file_image_num)); + + std::string zero_pad = std::string(); + if (image_num_string.length() < _frame_zero_padding) { + zero_pad = std::string(_frame_zero_padding - image_num_string.length(), '0'); + } + + std::string sign = std::string(); + if (is_negative) { + sign = "-"; + } + + // If the base does not include a trailing slash, add it + std::string path_sep = std::string(); + if (_target_url_base.compare(_target_url_base.length() - 1, 1, "/") != 0) { + path_sep = "/"; + } + + std::string out_string = _target_url_base + path_sep + _name_prefix + sign + zero_pad + image_num_string + _name_suffix; + *error_status = ErrorStatus(ErrorStatus::OK); + return out_string; + } + + RationalTime + ImageSequenceReference::presentation_time_for_image_number(int const image_number, ErrorStatus* error_status) const { + if (image_number >= this->number_of_images_in_sequence()) { + *error_status = ErrorStatus(ErrorStatus::ILLEGAL_INDEX); + return RationalTime(); + } + + auto first_frame_time = this->available_range().value().start_time(); + auto time_multiplier = TimeTransform(first_frame_time, image_number, -1); + return time_multiplier.applied_to(frame_duration()); + } + + bool ImageSequenceReference::read_from(Reader& reader) { + auto result = reader.read("target_url_base", &_target_url_base) && + reader.read("name_prefix", &_name_prefix) && + reader.read("name_suffix", &_name_suffix) && + reader.read("start_frame", &_start_frame) && + reader.read("frame_step", &_frame_step) && + reader.read("rate", &_rate) && + reader.read("frame_zero_padding", &_frame_zero_padding); + + std::string missing_frame_policy_value; + result && reader.read("missing_frame_policy", &missing_frame_policy_value); + if (!result) { + return result; + } + + if (missing_frame_policy_value == "error") { + _missing_frame_policy = MissingFramePolicy::error; + } + else if (missing_frame_policy_value == "black") { + _missing_frame_policy = MissingFramePolicy::black; + } + else if (missing_frame_policy_value == "hold") { + _missing_frame_policy = MissingFramePolicy::hold; + } + else { + // Unrecognized value + ErrorStatus error_status = ErrorStatus(ErrorStatus::JSON_PARSE_ERROR, + "Unknown missing_frame_policy: " + missing_frame_policy_value); + reader.error(error_status); + return false; + } + + return result && Parent::read_from(reader); + } + + void ImageSequenceReference::write_to(Writer& writer) const { + Parent::write_to(writer); + writer.write("target_url_base", _target_url_base); + writer.write("name_prefix", _name_prefix); + writer.write("name_suffix", _name_suffix); + writer.write("start_frame", _start_frame); + writer.write("frame_step", _frame_step); + writer.write("rate", _rate); + writer.write("frame_zero_padding", _frame_zero_padding); + + std::string missing_frame_policy_value; + switch (_missing_frame_policy) + { + case MissingFramePolicy::error: + missing_frame_policy_value = "error"; + break; + case MissingFramePolicy::black: + missing_frame_policy_value = "black"; + break; + case MissingFramePolicy::hold: + missing_frame_policy_value = "hold"; + break; + } + writer.write("missing_frame_policy", missing_frame_policy_value); + } +} } diff --git a/src/opentimelineio/imageSequenceReference.h b/src/opentimelineio/imageSequenceReference.h new file mode 100644 index 0000000000..91b091c49e --- /dev/null +++ b/src/opentimelineio/imageSequenceReference.h @@ -0,0 +1,127 @@ +#pragma once + +#include "opentimelineio/version.h" +#include "opentimelineio/mediaReference.h" + +namespace opentimelineio { namespace OPENTIMELINEIO_VERSION { + +class ImageSequenceReference final : public MediaReference { +public: + enum MissingFramePolicy { + error = 0, + hold = 1, + black = 2 + }; + + struct Schema { + static auto constexpr name = "ImageSequenceReference"; + static int constexpr version = 1; + }; + + using Parent = MediaReference; + + ImageSequenceReference(std::string const& target_url_base = std::string(), + std::string const& name_prefix = std::string(), + std::string const& name_suffix = std::string(), + int start_frame = 1, + int frame_step = 1, + double const rate = 1, + int frame_zero_padding = 0, + MissingFramePolicy const missing_frame_policy = MissingFramePolicy::error, + optional const& available_range = nullopt, + AnyDictionary const& metadata = AnyDictionary()); + + std::string const& target_url_base() const { + return _target_url_base; + } + + void set_target_url_base(std::string const& target_url_base) { + _target_url_base = target_url_base; + } + + std::string const& name_prefix() const { + return _name_prefix; + } + + void set_name_prefix(std::string const& target_url_base) { + _name_prefix = target_url_base; + } + + std::string const& name_suffix() const { + return _name_suffix; + } + + void set_name_suffix(std::string const& target_url_base) { + _name_suffix = target_url_base; + } + + int start_frame() const { + return _start_frame; + } + + void set_start_frame(int const start_frame) { + _start_frame = start_frame; + } + + int frame_step() const { + return _frame_step; + } + + void set_frame_step(int const frame_step) { + _frame_step = frame_step; + } + + double const& rate() const { + return _rate; + } + + void set_rate(double const rate) { + _rate = rate; + } + + int frame_zero_padding() const { + return _frame_zero_padding; + } + + void set_frame_zero_padding(int const frame_zero_padding) { + _frame_zero_padding = frame_zero_padding; + } + + void set_missing_frame_policy(MissingFramePolicy const missing_frame_policy) { + _missing_frame_policy = missing_frame_policy; + } + + MissingFramePolicy missing_frame_policy() const { + return _missing_frame_policy; + } + + int end_frame() const; + int number_of_images_in_sequence() const; + int frame_for_time(RationalTime const time, ErrorStatus* error_status) const; + + std::string + target_url_for_image_number(int const image_number, ErrorStatus* error_status) const; + + RationalTime + presentation_time_for_image_number(int const image_number, ErrorStatus* error_status) const; + +protected: + virtual ~ImageSequenceReference(); + + virtual bool read_from(Reader&); + virtual void write_to(Writer&) const; + +private: + std::string _target_url_base; + std::string _name_prefix; + std::string _name_suffix; + int _start_frame; + int _frame_step; + double _rate; + int _frame_zero_padding; + MissingFramePolicy _missing_frame_policy; + + RationalTime frame_duration() const; +}; + +} } diff --git a/src/opentimelineio/typeRegistry.cpp b/src/opentimelineio/typeRegistry.cpp index 2bf9b25106..984b4f946b 100644 --- a/src/opentimelineio/typeRegistry.cpp +++ b/src/opentimelineio/typeRegistry.cpp @@ -9,6 +9,7 @@ #include "opentimelineio/freezeFrame.h" #include "opentimelineio/gap.h" #include "opentimelineio/generatorReference.h" +#include "opentimelineio/imageSequenceReference.h" #include "opentimelineio/item.h" #include "opentimelineio/linearTimeWarp.h" #include "opentimelineio/marker.h" @@ -55,6 +56,7 @@ TypeRegistry::TypeRegistry() { register_type_from_existing_type("Filler", 1, "Gap", nullptr); register_type(); + register_type(); register_type(); register_type(); register_type(); diff --git a/src/py-opentimelineio/opentimelineio-bindings/otio_serializableObjects.cpp b/src/py-opentimelineio/opentimelineio-bindings/otio_serializableObjects.cpp index 139fdb77bf..c83f2af56f 100644 --- a/src/py-opentimelineio/opentimelineio-bindings/otio_serializableObjects.cpp +++ b/src/py-opentimelineio/opentimelineio-bindings/otio_serializableObjects.cpp @@ -10,6 +10,7 @@ #include "opentimelineio/freezeFrame.h" #include "opentimelineio/gap.h" #include "opentimelineio/generatorReference.h" +#include "opentimelineio/imageSequenceReference.h" #include "opentimelineio/item.h" #include "opentimelineio/linearTimeWarp.h" #include "opentimelineio/marker.h" @@ -90,11 +91,11 @@ class ContainerIterator { : _container(container), _it(0) { } - + ContainerIterator* iter() { return this; } - + ITEM next() { if (_it == _container->children().size()) { throw pybind11::stop_iteration(); @@ -251,7 +252,7 @@ static void define_items_and_compositions(py::module m) { .def(py::init([](std::string name, optional source_range, py::object effects, py::object markers, py::object metadata) { return new Item(name, source_range, - py_to_any_dictionary(metadata), + py_to_any_dictionary(metadata), py_to_vector(effects), py_to_vector(markers)); }), name_arg, @@ -321,7 +322,7 @@ static void define_items_and_compositions(py::module m) { py::class_(transition_class, "Type") .def_property_readonly_static("SMPTE_Dissolve", [](py::object /* self */) { return Transition::Type::SMPTE_Dissolve; }) .def_property_readonly_static("Custom", [](py::object /* self */) { return Transition::Type::Custom; }); - + py::class_>(m, "Gap", py::dynamic_attr()) .def(py::init([](std::string name, TimeRange source_range, py::object effects, @@ -365,7 +366,7 @@ static void define_items_and_compositions(py::module m) { py::class_>(m, "Composition", py::dynamic_attr()) .def(py::init([](std::string name, - py::object children, + py::object children, optional source_range, py::object metadata) { Composition* c = new Composition(name, source_range, py_to_any_dictionary(metadata)); @@ -431,7 +432,7 @@ static void define_items_and_compositions(py::module m) { .def("__iter__", [](Composition* c) { return new CompositionIterator(c); }); - + auto track_class = py::class_>(m, "Track", py::dynamic_attr()); py::enum_(track_class, "NeighborGapPolicy") @@ -468,7 +469,7 @@ static void define_items_and_compositions(py::module m) { .def_property_readonly_static("Audio", [](py::object /* self */) { return Track::Kind::audio; }) .def_property_readonly_static("Video", [](py::object /* self */) { return Track::Kind::video; }); - + py::class_>(m, "Stack", py::dynamic_attr()) .def(py::init([](py::object name, py::object children, @@ -543,7 +544,7 @@ static void define_effects(py::module m) { py::object metadata) { return new TimeEffect(name, effect_name, py_to_any_dictionary(metadata)); }), name_arg, - "effect_name"_a = std::string(), + "effect_name"_a = std::string(), metadata_arg); py::class_>(m, "LinearTimeWarp", py::dynamic_attr()) @@ -625,6 +626,136 @@ static void define_media_references(py::module m) { "available_range"_a = nullopt, metadata_arg) .def_property("target_url", &ExternalReference::target_url, &ExternalReference::set_target_url); + + auto imagesequencereference_class = py:: class_>(m, "ImageSequenceReference", py::dynamic_attr(), R"docstring( +An ImageSequenceReference refers to a numbered series of single-frame image files. Each file can be referred to by a URL generated by the ImageSequenceReference. + +Image sequncences can have URLs with discontinuous frame numbers, for instance if you've only rendered every other frame in a sequence, your frame numbers may be 1, 3, 5, etc. This is configured using the ``frame_step`` attribute. In this case, the 0th image in the sequence is frame 1 and the 1st image in the sequence is frame 3. Because of this there are two numbering concepts in the image sequence, the image number and the frame number. + +Frame numbers are the integer numbers used in the frame file name. Image numbers are the 0-index based numbers of the frames available in the reference. Frame numbers can be discontinuous, image numbers will always be zero to the total count of frames minus 1. + +An example for 24fps media with a sample provided each frame numbered 1-1000 with a path ``/show/sequence/shot/sample_image_sequence.%04d.exr`` might be:: + + { + "available_range": { + "start_time": { + "value": 0, + "rate": 24 + }, + "duration": { + "value": 1000, + "rate": 24 + } + }, + "start_frame": 1, + "frame_step": 1, + "rate": 24, + "target_url_base": "file:///show/sequence/shot/", + "name_prefix": "sample_image_sequence.", + "name_suffix": ".exr" + "frame_zero_padding": 4, + } + +The same duration sequence but with only every 2nd frame available in the sequence would be:: + + { + "available_range": { + "start_time": { + "value": 0, + "rate": 24 + }, + "duration": { + "value": 1000, + "rate": 24 + } + }, + "start_frame": 1, + "frame_step": 2, + "rate": 24, + "target_url_base": "file:///show/sequence/shot/", + "name_prefix": "sample_image_sequence.", + "name_suffix": ".exr" + "frame_zero_padding": 4, + } + +A list of all the frame URLs in the sequence can be generated, regardless of frame step, with the following list comprehension:: + + [ref.target_url_for_image_number(i) for i in range(ref.number_of_images_in_sequence())] + +Negative ``start_frame`` is also handled. The above example with a ``start_frame`` of ``-1`` would yield the first three target urls as: + +- ``file:///show/sequence/shot/sample_image_sequence.-0001.exr`` +- ``file:///show/sequence/shot/sample_image_sequence.0000.exr`` +- ``file:///show/sequence/shot/sample_image_sequence.0001.exr`` +)docstring"); + + py::enum_(imagesequencereference_class, "MissingFramePolicy", "Behavior that should be used by applications when an image file in the sequence can't be found on disk.") + .value("error", ImageSequenceReference::MissingFramePolicy::error, "Application should abort and raise an error.") + .value("hold", ImageSequenceReference::MissingFramePolicy::hold, "Application should hold the last available frame before the missing frame.") + .value("black", ImageSequenceReference::MissingFramePolicy::black, "Application should use a black frame in place of the missing frame"); + + imagesequencereference_class + .def(py::init([](std::string target_url_base, + std::string name_prefix, + std::string name_suffix, + int start_frame, + int frame_step, + double const rate, + int frame_zero_padding, + ImageSequenceReference::MissingFramePolicy const missing_frame_policy, + optional const& available_range, + py::object metadata) { + return new ImageSequenceReference(target_url_base, + name_prefix, + name_suffix, + start_frame, + frame_step, + rate, + frame_zero_padding, + missing_frame_policy, + available_range, + py_to_any_dictionary(metadata)); }), + "target_url_base"_a = std::string(), + "name_prefix"_a = std::string(), + "name_suffix"_a = std::string(), + "start_frame"_a = 1L, + "frame_step"_a = 1L, + "rate"_a = 1, + "frame_zero_padding"_a = 0, + "missing_frame_policy"_a = ImageSequenceReference::MissingFramePolicy::error, + "available_range"_a = nullopt, + metadata_arg) + .def_property("target_url_base", &ImageSequenceReference::target_url_base, &ImageSequenceReference::set_target_url_base, "Everything leading up to the file name in the ``target_url``.") + .def_property("name_prefix", &ImageSequenceReference::name_prefix, &ImageSequenceReference::set_name_prefix, "Everything in the file name leading up to the frame number.") + .def_property("name_suffix", &ImageSequenceReference::name_suffix, &ImageSequenceReference::set_name_suffix, "Everything after the frame number in the file name.") + .def_property("start_frame", &ImageSequenceReference::start_frame, &ImageSequenceReference::set_start_frame, "The first frame number used in file names.") + .def_property("frame_step", &ImageSequenceReference::frame_step, &ImageSequenceReference::set_frame_step, "Step between frame numbers in file names.") + .def_property("rate", &ImageSequenceReference::rate, &ImageSequenceReference::set_rate, "Frame rate if every frame in the sequence were played back.") + .def_property("frame_zero_padding", &ImageSequenceReference::frame_zero_padding, &ImageSequenceReference::set_frame_zero_padding, "Number of digits to pad zeros out to in frame numbers.") + .def_property("missing_frame_policy", &ImageSequenceReference::missing_frame_policy, &ImageSequenceReference::set_missing_frame_policy, "Enum ``ImageSequenceReference.MissingFramePolicy`` directive for how frames in sequence not found on disk should be handled.") + .def("end_frame", &ImageSequenceReference::end_frame, "Last frame number in the sequence based on the ``rate`` and ``available_range``.") + .def("number_of_images_in_sequence", &ImageSequenceReference::number_of_images_in_sequence, "Returns the number of images based on the ``rate`` and ``available_range``.") + .def("frame_for_time", [](ImageSequenceReference *seq_ref, RationalTime time) { + return seq_ref->frame_for_time(time, ErrorStatusHandler()); + }, "time"_a, "Given a :class:`RationalTime` within the available range, returns the frame number.") + .def("target_url_for_image_number", [](ImageSequenceReference *seq_ref, int image_number) { + return seq_ref->target_url_for_image_number( + image_number, + ErrorStatusHandler() + ); + }, "image_number"_a, R"docstring(Given an image number, returns the ``target_url`` for that image. + +This is roughly equivalent to: + ``f"{target_url_prefix}{(start_frame + (image_number * frame_step)):0{value_zero_padding}}{target_url_postfix}`` +)docstring") + .def("presentation_time_for_image_number", [](ImageSequenceReference *seq_ref, int image_number) { + return seq_ref->presentation_time_for_image_number( + image_number, + ErrorStatusHandler() + ); + }, "image_number"_a, "Given an image number, returns the :class:`RationalTime` at which that image should be shown in the space of `available_range`."); + } void otio_serializable_object_bindings(py::module m) { @@ -634,4 +765,4 @@ void otio_serializable_object_bindings(py::module m) { define_media_references(m); define_items_and_compositions(m); } - + diff --git a/src/py-opentimelineio/opentimelineio/schema/__init__.py b/src/py-opentimelineio/opentimelineio/schema/__init__.py index 325a986570..f390bbbd4d 100644 --- a/src/py-opentimelineio/opentimelineio/schema/__init__.py +++ b/src/py-opentimelineio/opentimelineio/schema/__init__.py @@ -35,6 +35,7 @@ FreezeFrame, Gap, GeneratorReference, + ImageSequenceReference, Marker, MissingReference, SerializableCollection, @@ -54,11 +55,17 @@ ) from . import ( - clip, effect, external_reference, - generator_reference, marker, + clip, + effect, + external_reference, + generator_reference, + image_sequence_reference, + marker, serializable_collection, - stack, timeline, track, - transition + stack, + timeline, + track, + transition, ) track.TrackKind = TrackKind diff --git a/src/py-opentimelineio/opentimelineio/schema/image_sequence_reference.py b/src/py-opentimelineio/opentimelineio/schema/image_sequence_reference.py new file mode 100644 index 0000000000..e59e01671c --- /dev/null +++ b/src/py-opentimelineio/opentimelineio/schema/image_sequence_reference.py @@ -0,0 +1,81 @@ +from .. core._core_utils import add_method +from .. import _otio + + +@add_method(_otio.ImageSequenceReference) +def __str__(self): + return ( + 'ImageSequenceReference(' + '"{}", "{}", "{}", {}, {}, {}, {}, {}, {}, {})' .format( + self.target_url_base, + self.name_prefix, + self.name_suffix, + self.start_frame, + self.frame_step, + self.rate, + self.frame_zero_padding, + self.missing_frame_policy, + self.available_range, + self.metadata, + ) + ) + + +@add_method(_otio.ImageSequenceReference) +def __repr__(self): + return ( + 'ImageSequenceReference(' + 'target_url_base={}, ' + 'name_prefix={}, ' + 'name_suffix={}, ' + 'start_frame={}, ' + 'frame_step={}, ' + 'rate={}, ' + 'frame_zero_padding={}, ' + 'missing_frame_policy={}, ' + 'available_range={}, ' + 'metadata={}' + ')' .format( + repr(self.target_url_base), + repr(self.name_prefix), + repr(self.name_suffix), + repr(self.start_frame), + repr(self.frame_step), + repr(self.rate), + repr(self.frame_zero_padding), + repr(self.missing_frame_policy), + repr(self.available_range), + repr(self.metadata), + ) + ) + + +@add_method(_otio.ImageSequenceReference) +def frame_range_for_time_range(self, time_range): + """ + Returns a :class:`tuple` containing the first and last frame numbers for + the given time range in the reference. + + Raises ValueError if the provided time range is outside the available + range. + """ + return ( + self.frame_for_time(time_range.start_time), + self.frame_for_time(time_range.end_time_inclusive()) + ) + + +@add_method(_otio.ImageSequenceReference) +def abstract_target_url(self, symbol): + """ + Generates a target url for a frame where :param:``symbol`` is used in place + of the frame number. This is often used to generate wildcard target urls. + """ + if not self.target_url_base.endswith("/"): + base = self.target_url_base + "/" + else: + base = self.target_url_base + + return "{}{}{}{}".format( + base, self.name_prefix, symbol, self.name_suffix + ) diff --git a/tests/test_image_sequence_reference.py b/tests/test_image_sequence_reference.py new file mode 100644 index 0000000000..9d682ecf67 --- /dev/null +++ b/tests/test_image_sequence_reference.py @@ -0,0 +1,613 @@ +"""Test harness for Image Sequence References.""" +import unittest +import sys + +import opentimelineio as otio +import opentimelineio.test_utils as otio_test_utils + + +IS_PYTHON_2 = (sys.version_info < (3, 0)) + + +class ImageSequenceReferenceTests( + unittest.TestCase, otio_test_utils.OTIOAssertions +): + def test_create(self): + frame_policy = otio.schema.ImageSequenceReference.MissingFramePolicy.hold + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=5, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 30), + otio.opentime.RationalTime(60, 30), + ), + frame_step=3, + missing_frame_policy=frame_policy, + rate=30, + metadata={"custom": {"foo": "bar"}}, + ) + + # Check Values + self.assertEqual(ref.target_url_base, "file:///show/seq/shot/rndr/") + self.assertEqual(ref.name_prefix, "show_shot.") + self.assertEqual(ref.name_suffix, ".exr") + self.assertEqual(ref.frame_zero_padding, 5) + self.assertEqual( + ref.available_range, + otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 30), + otio.opentime.RationalTime(60, 30), + ) + ) + self.assertEqual(ref.frame_step, 3) + self.assertEqual(ref.rate, 30) + self.assertEqual(ref.metadata, {"custom": {"foo": "bar"}}) + self.assertEqual( + ref.missing_frame_policy, + otio.schema.ImageSequenceReference.MissingFramePolicy.hold, + ) + + @unittest.skipIf(IS_PYTHON_2, "unicode strings do funny things in python2") + def test_str(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + start_frame=1, + frame_step=3, + rate=30, + frame_zero_padding=5, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 30), + otio.opentime.RationalTime(60, 30), + ), + metadata={"custom": {"foo": "bar"}}, + ) + self.assertEqual( + str(ref), + 'ImageSequenceReference(' + '"file:///show/seq/shot/rndr/", ' + '"show_shot.", ' + '".exr", ' + '1, ' + '3, ' + '30.0, ' + '5, ' + 'MissingFramePolicy.error, ' + 'TimeRange(RationalTime(0, 30), RationalTime(60, 30)), ' + "{'custom': {'foo': 'bar'}}" + ')' + ) + + @unittest.skipIf(IS_PYTHON_2, "unicode strings do funny things in python2") + def test_repr(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + start_frame=1, + frame_step=3, + rate=30, + frame_zero_padding=5, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 30), + otio.opentime.RationalTime(60, 30), + ), + metadata={"custom": {"foo": "bar"}}, + ) + ref_value = ( + 'ImageSequenceReference(' + "target_url_base='file:///show/seq/shot/rndr/', " + "name_prefix='show_shot.', " + "name_suffix='.exr', " + 'start_frame=1, ' + 'frame_step=3, ' + 'rate=30.0, ' + 'frame_zero_padding=5, ' + 'missing_frame_policy=MissingFramePolicy.error, ' + 'available_range={}, ' + "metadata={{'custom': {{'foo': 'bar'}}}}" + ')'.format(repr(ref.available_range)) + ) + self.assertEqual(repr(ref), ref_value) + + def test_serialize_roundtrip(self): + frame_policy = otio.schema.ImageSequenceReference.MissingFramePolicy.hold + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=5, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 30), + otio.opentime.RationalTime(60, 30), + ), + frame_step=3, + rate=30, + missing_frame_policy=frame_policy, + metadata={"custom": {"foo": "bar"}}, + ) + + encoded = otio.adapters.otio_json.write_to_string(ref) + decoded = otio.adapters.otio_json.read_from_string(encoded) + self.assertIsOTIOEquivalentTo(ref, decoded) + + encoded2 = otio.adapters.otio_json.write_to_string(decoded) + self.assertEqual(encoded, encoded2) + + # Check Values + self.assertEqual( + decoded.target_url_base, "file:///show/seq/shot/rndr/" + ) + self.assertEqual(decoded.name_prefix, "show_shot.") + self.assertEqual(decoded.name_suffix, ".exr") + self.assertEqual(decoded.frame_zero_padding, 5) + self.assertEqual( + decoded.available_range, + otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 30), + otio.opentime.RationalTime(60, 30), + ) + ) + self.assertEqual(decoded.frame_step, 3) + self.assertEqual(decoded.rate, 30) + self.assertEqual( + decoded.missing_frame_policy, + otio.schema.ImageSequenceReference.MissingFramePolicy.hold + ) + self.assertEqual(decoded.metadata, {"custom": {"foo": "bar"}}) + + def test_deserialize_invalid_enum_value(self): + encoded = """{ + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "custom": { + "foo": "bar" + } + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 30.0, + "value": 60.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 30.0, + "value": 0.0 + } + }, + "target_url_base": "file:///show/seq/shot/rndr/", + "name_prefix": "show_shot.", + "name_suffix": ".exr", + "start_frame": 1, + "frame_step": 3, + "rate": 30.0, + "frame_zero_padding": 5, + "missing_frame_policy": "BOGUS" + }""" + with self.assertRaises(ValueError): + otio.adapters.otio_json.read_from_string(encoded) + + def test_number_of_images_in_sequence(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + rate=24, + ) + + self.assertEqual(ref.number_of_images_in_sequence(), 48) + + def test_number_of_images_in_sequence_with_skip(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + frame_step=2, + rate=24, + ) + + self.assertEqual(ref.number_of_images_in_sequence(), 24) + + ref.frame_step = 3 + self.assertEqual(ref.number_of_images_in_sequence(), 16) + + def test_target_url_for_image_number(self): + all_images_urls = [ + "file:///show/seq/shot/rndr/show_shot.{:04}.exr".format(i) + for i in range(1, 49) + ] + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + + generated_urls = [ + ref.target_url_for_image_number(i) + for i in range(ref.number_of_images_in_sequence()) + ] + self.assertEqual(all_images_urls, generated_urls) + + def test_target_url_for_image_number_steps(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=2, + rate=24, + ) + + all_images_urls = [ + "file:///show/seq/shot/rndr/show_shot.{:04}.exr".format(i) + for i in range(1, 49, 2) + ] + generated_urls = [ + ref.target_url_for_image_number(i) + for i in range(ref.number_of_images_in_sequence()) + ] + self.assertEqual(all_images_urls, generated_urls) + + ref.frame_step = 3 + all_images_urls_threes = [ + "file:///show/seq/shot/rndr/show_shot.{:04}.exr".format(i) + for i in range(1, 49, 3) + ] + generated_urls_threes = [ + ref.target_url_for_image_number(i) + for i in range(ref.number_of_images_in_sequence()) + ] + self.assertEqual(all_images_urls_threes, generated_urls_threes) + + ref.frame_step = 2 + ref.start_frame = 0 + all_images_urls_zero_first = [ + "file:///show/seq/shot/rndr/show_shot.{:04}.exr".format(i) + for i in range(0, 48, 2) + ] + generated_urls_zero_first = [ + ref.target_url_for_image_number(i) + for i in range(ref.number_of_images_in_sequence()) + ] + self.assertEqual(all_images_urls_zero_first, generated_urls_zero_first) + + def test_target_url_for_image_number_with_missing_slash(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + + self.assertEqual( + ref.target_url_for_image_number(0), + "file:///show/seq/shot/rndr/show_shot.0001.exr" + ) + + def test_abstract_target_url(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + + self.assertEqual( + ref.abstract_target_url("@@@@"), + "file:///show/seq/shot/rndr/show_shot.@@@@.exr" + ) + + def test_abstract_target_url_with_missing_slash(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + + self.assertEqual( + ref.abstract_target_url("@@@@"), + "file:///show/seq/shot/rndr/show_shot.@@@@.exr" + ) + + def test_presentation_time_for_image_number(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(0, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=2, + rate=24, + ) + + reference_values = [ + otio.opentime.RationalTime(i * 2, 24) for i in range(24) + ] + + generated_values = [ + ref.presentation_time_for_image_number(i) + for i in range(ref.number_of_images_in_sequence()) + ] + + self.assertEqual(generated_values, reference_values) + + def test_presentation_time_for_image_number_with_offset(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=2, + rate=24, + ) + + first_frame_time = otio.opentime.RationalTime(12, 24) + reference_values = [ + first_frame_time + otio.opentime.RationalTime(i * 2, 24) + for i in range(24) + ] + + generated_values = [ + ref.presentation_time_for_image_number(i) + for i in range(ref.number_of_images_in_sequence()) + ] + + self.assertEqual(generated_values, reference_values) + + def test_end_frame(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + + self.assertEqual(ref.end_frame(), 48) + + # Frame step should not affect this + ref.frame_step = 2 + self.assertEqual(ref.end_frame(), 48) + + def test_end_frame_with_offset(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=101, + frame_step=1, + rate=24, + ) + + self.assertEqual(ref.end_frame(), 148) + + # Frame step should not affect this + ref.frame_step = 2 + self.assertEqual(ref.end_frame(), 148) + + def test_frame_for_time(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + + # The start time should be frame 1 + self.assertEqual( + ref.frame_for_time(ref.available_range.start_time), 1 + ) + + # Test a sample in the middle + self.assertEqual( + ref.frame_for_time(otio.opentime.RationalTime(15, 24)), 4 + ) + + # The end time (inclusive) should map to the last frame number + self.assertEqual( + ref.frame_for_time(ref.available_range.end_time_inclusive()), 48 + ) + + # make sure frame step and RationalTime rate have no effect + ref.frame_step = 2 + self.assertEqual( + ref.frame_for_time(otio.opentime.RationalTime(118, 48)), 48 + ) + + def test_frame_for_time_out_of_range(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 30), + otio.opentime.RationalTime(60, 30), + ), + start_frame=1, + frame_step=1, + rate=30, + ) + with self.assertRaises(ValueError): + ref.frame_for_time(otio.opentime.RationalTime(73, 30)) + + def test_frame_range_for_time_range(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 24), + otio.opentime.RationalTime(60, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + time_range = otio.opentime.TimeRange( + otio.opentime.RationalTime(24, 24), + otio.opentime.RationalTime(17, 24), + ) + + self.assertEqual(ref.frame_range_for_time_range(time_range), (13, 29)) + + def test_frame_range_for_time_range_out_of_bounds(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 24), + otio.opentime.RationalTime(60, 24), + ), + start_frame=1, + frame_step=1, + rate=24, + ) + time_range = otio.opentime.TimeRange( + otio.opentime.RationalTime(24, 24), + otio.opentime.RationalTime(60, 24), + ) + + with self.assertRaises(ValueError): + ref.frame_range_for_time_range(time_range) + + def test_negative_frame_numbers(self): + ref = otio.schema.ImageSequenceReference( + "file:///show/seq/shot/rndr/", + "show_shot.", + ".exr", + frame_zero_padding=4, + available_range=otio.opentime.TimeRange( + otio.opentime.RationalTime(12, 24), + otio.opentime.RationalTime(48, 24), + ), + start_frame=-1, + frame_step=2, + rate=24, + ) + + self.assertEqual(ref.number_of_images_in_sequence(), 24) + self.assertEqual( + ref.presentation_time_for_image_number(0), + otio.opentime.RationalTime(12, 24), + ) + self.assertEqual( + ref.presentation_time_for_image_number(1), + otio.opentime.RationalTime(14, 24), + ) + self.assertEqual( + ref.presentation_time_for_image_number(2), + otio.opentime.RationalTime(16, 24), + ) + self.assertEqual( + ref.presentation_time_for_image_number(23), + otio.opentime.RationalTime(58, 24), + ) + + self.assertEqual( + ref.target_url_for_image_number(0), + "file:///show/seq/shot/rndr/show_shot.-0001.exr", + ) + + self.assertEqual( + ref.target_url_for_image_number(1), + "file:///show/seq/shot/rndr/show_shot.0001.exr", + ) + self.assertEqual( + ref.target_url_for_image_number(2), + "file:///show/seq/shot/rndr/show_shot.0003.exr", + ) + self.assertEqual( + ref.target_url_for_image_number(17), + "file:///show/seq/shot/rndr/show_shot.0033.exr", + ) + self.assertEqual( + ref.target_url_for_image_number(23), + "file:///show/seq/shot/rndr/show_shot.0045.exr", + ) + + # Check values by ones + ref.frame_step = 1 + for i in range(1, ref.number_of_images_in_sequence()): + self.assertEqual( + ref.target_url_for_image_number(i), + "file:///show/seq/shot/rndr/show_shot.{:04}.exr".format(i - 1), + )