From 5e0509ca488ddb6254f0fae19b5bae9a2835345f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 00:49:33 +0100 Subject: [PATCH] :art: enhance range validations --- client/ayon_core/pipeline/traits/content.py | 149 ++++++++++++++++---- client/ayon_core/pipeline/traits/time.py | 97 +++++++++++-- 2 files changed, 205 insertions(+), 41 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index cf31669336..36b373036c 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -1,6 +1,8 @@ """Content traits for the pipeline.""" from __future__ import annotations +import contextlib + # TC003 is there because Path in TYPECHECKING will fail in tests from pathlib import Path # noqa: TC003 from typing import ClassVar, Optional @@ -8,7 +10,7 @@ from typing import ClassVar, Optional from pydantic import Field from .representation import Representation -from .time import FrameRanged, Sequence +from .time import FrameRanged, Handles, Sequence from .trait import ( MissingTraitError, TraitBase, @@ -125,20 +127,55 @@ class FileLocations(TraitBase): # If there are no file paths, we can't validate msg = "No file locations defined (empty list)" raise TraitValidationError(self.name, msg) + if representation.contains_trait(FrameRanged): + self._validate_frame_range(representation) + def _validate_frame_range(self, representation: Representation) -> None: + """Validate the frame range against the file paths. + + If the representation contains a FrameRanged trait, this method will + validate the frame range against the file paths. If the frame range + does not match the file paths, the trait is invalid. It takes into + account the Handles and Sequence traits. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ tmp_frame_ranged: FrameRanged = get_sequence_from_files( [f.file_path for f in self.file_paths]) frames_from_spec = None - try: + with contextlib.suppress(MissingTraitError): sequence: Sequence = representation.get_trait(Sequence) if sequence.frame_spec: frames_from_spec: list[int] = sequence.get_frame_list( self, sequence.frame_regex) - except MissingTraitError: - # If there is no sequence trait, we can't validate it - pass + frame_start_with_handles, frame_end_with_handles = \ + self._get_frame_info_with_handles(representation, frames_from_spec) + + if frame_start_with_handles \ + and tmp_frame_ranged.frame_start != frame_start_with_handles: + # If the detected frame range does not match the combined + # FrameRanged and Handles trait, the + # trait is invalid. + msg = ( + f"Frame range defined by {self.name} " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + "in files does not match " + "frame range " + f"({frame_start_with_handles}-" + f"{frame_end_with_handles}) defined in FrameRanged trait." + ) + + raise TraitValidationError(self.name, msg) + if frames_from_spec: if len(frames_from_spec) != len(self.file_paths) : # If the number of file paths does not match the frame range, @@ -155,40 +192,94 @@ class FileLocations(TraitBase): # the rest is validated by Sequence validators. return + length_with_handles: int = ( + frame_end_with_handles - frame_start_with_handles + 1 + ) - if len(self.file_paths) - 1 != \ - tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start: + if len(self.file_paths) != length_with_handles: # If the number of file paths does not match the frame range, # the trait is invalid msg = ( - f"Number of file locations ({len(self.file_paths) - 1}) " + f"Number of file locations ({len(self.file_paths)}) " "does not match frame range " - f"({tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start})" # noqa: E501 + f"({length_with_handles})" ) raise TraitValidationError(self.name, msg) - try: - frame_ranged: FrameRanged = representation.get_trait(FrameRanged) + frame_ranged: FrameRanged = representation.get_trait(FrameRanged) - if frame_ranged.frame_start != tmp_frame_ranged.frame_start or \ - frame_ranged.frame_end != tmp_frame_ranged.frame_end: - # If the frame range does not match the sequence trait, the - # trait is invalid. Note that we don't check the frame rate - # because it is not stored in the file paths and is not - # determined by `get_sequence_from_files`. - msg = ( - "Frame range " - f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " - "in sequence trait does not match " - "frame range " - f"({tmp_frame_ranged.frame_start}-{tmp_frame_ranged.frame_end}) " # noqa: E501 - "defined in files." - ) - raise TraitValidationError(self.name, msg) + if frame_start_with_handles != tmp_frame_ranged.frame_start or \ + frame_end_with_handles != tmp_frame_ranged.frame_end: + # If the frame range does not match the FrameRanged trait, the + # trait is invalid. Note that we don't check the frame rate + # because it is not stored in the file paths and is not + # determined by `get_sequence_from_files`. + msg = ( + "Frame range " + f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " + "in sequence trait does not match " + "frame range " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + ) + raise TraitValidationError(self.name, msg) - except MissingTraitError: - # If there is no frame_ranged trait, we can't validate it - pass + def _get_frame_info_with_handles( + self, + representation: Representation, + frames_from_spec: list[int]) -> tuple[int, int]: + """Get the frame range with handles from the representation. + + This will return frame start and frame end with handles calculated + in if there actually is the Handles trait in the representation. + + Args: + representation (Representation): Representation to get the frame + range from. + frames_from_spec (list[int]): List of frames from the frame spec. + This list is modified in place to take into + account the handles. + + Mutates: + frames_from_spec: List of frames from the frame spec. + + Returns: + tuple[int, int]: Start and end frame with handles. + + """ + frame_start = frame_end = 0 + frame_start_handle = frame_end_handle = 0 + # If there is no sequence trait, we can't validate it + if frames_from_spec and representation.contains_trait(FrameRanged): + # if there is no FrameRanged trait (but really there should be) + # we can use the frame range from the frame spec + frame_start = min(frames_from_spec) + frame_end = max(frames_from_spec) + + # Handle the frame range + with contextlib.suppress(MissingTraitError): + frame_start = representation.get_trait(FrameRanged).frame_start + frame_end = representation.get_trait(FrameRanged).frame_end + + # Handle the handles :P + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + if not handles.inclusive: + # if handless are exclusive, we need to adjust the frame range + frame_start_handle = handles.frame_start_handle + frame_end_handle = handles.frame_end_handle + if frames_from_spec: + frames_from_spec.extend( + range(frame_start - frame_start_handle, frame_start) + ) + frames_from_spec.extend( + range(frame_end + 1, frame_end_handle + frame_end + 1) + ) + + frame_start_with_handles = frame_start - frame_start_handle + frame_end_with_handles = frame_end + frame_end_handle + + return frame_start_with_handles, frame_end_with_handles class RootlessLocation(TraitBase): diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index eacaa72235..0ab7cfaa9e 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -1,6 +1,7 @@ """Temporal (time related) traits.""" from __future__ import annotations +import contextlib from enum import Enum, auto from typing import TYPE_CHECKING, ClassVar, Optional @@ -137,20 +138,62 @@ class Sequence(TraitBase): # if there is FileLocations trait, run validation # on it as well - try: - from .content import FileLocations - file_locs: FileLocations = representation.get_trait( - FileLocations) - file_locs.validate(representation) - # validate if file locations on representation - # matches the frame list (if any) - self.validate_frame_list(file_locs) - self.validate_frame_padding(file_locs) - except MissingTraitError: - pass + + with contextlib.suppress(MissingTraitError): + self._validate_file_locations(representation) + + def _validate_file_locations(self, representation: Representation) -> None: + """Validate file locations trait. + + If along with the Sequence trait, there is a FileLocations trait, + then we need to validate if the file locations match the frame + list specification. + + Args: + representation (Representation): Representation instance. + + Raises: + TraitValidationError: If file locations do not match the + frame list specification + + """ + from .content import FileLocations + file_locs: FileLocations = representation.get_trait( + FileLocations) + # validate if file locations on representation + # matches the frame list (if any) + # we need to extend the expected frames with Handles + frame_start = None + frame_end = None + handles_frame_start = None + handles_frame_end = None + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + # if handles are inclusive, they should be already + # accounted in the FrameRaged frame spec + if not handles.inclusive: + handles_frame_start = handles.frame_start_handle + handles_frame_end = handles.frame_end_handle + with contextlib.suppress(MissingTraitError): + frame_ranged: FrameRanged = representation.get_trait( + FrameRanged) + frame_start = frame_ranged.frame_start + frame_end = frame_ranged.frame_end + self.validate_frame_list( + file_locs, + frame_start, + frame_end, + handles_frame_start, + handles_frame_end) + self.validate_frame_padding(file_locs) def validate_frame_list( - self, file_locations: FileLocations) -> None: + self, + file_locations: FileLocations, + frame_start: Optional[int] = None, + frame_end: Optional[int] = None, + handles_frame_start: Optional[int] = None, + handles_frame_end: Optional[int] = None) -> None: """Validate frame list. This will take FileLocations trait and validate if the @@ -164,6 +207,10 @@ class Sequence(TraitBase): Args: file_locations (FileLocations): File locations trait. + frame_start (Optional[int]): Frame start. + frame_end (Optional[int]): Frame end. + handles_frame_start (Optional[int]): Frame start handle. + handles_frame_end (Optional[int]): Frame end handle. Raises: TraitValidationError: If frame list does not match @@ -177,6 +224,32 @@ class Sequence(TraitBase): file_locations, self.frame_regex) expected_frames = self.list_spec_to_frames(self.frame_spec) + if frame_start is None or frame_end is None: + if min(expected_frames) != frame_start: + msg = ( + "Frame start does not match the expected frame start. " + f"Expected: {frame_start}, Found: {min(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + if max(expected_frames) != frame_end: + msg = ( + "Frame end does not match the expected frame end. " + f"Expected: {frame_end}, Found: {max(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + # we need to extend the expected frames with Handles + if handles_frame_start is not None: + expected_frames.extend( + range( + min(frames) - handles_frame_start, min(frames) + 1)) + + if handles_frame_end is not None: + expected_frames.extend( + range( + max(frames), max(frames) + handles_frame_end + 1)) + if set(frames) != set(expected_frames): msg = ( "Frame list does not match the expected frames. "