From c0b4ad99a927789dcac893c89b03c29a5ccdc990 Mon Sep 17 00:00:00 2001 From: rlahmidi <117352633+rlahmidi@users.noreply.github.com> Date: Mon, 22 Apr 2024 18:18:03 +0200 Subject: [PATCH] ADD misc fixes and updates for beta-6 (#11) --- docs/usage/rendering.md | 7 +- pyproject.toml | 2 +- pytvpaint/clip.py | 119 +++++++++++++++-------- pytvpaint/george/grg_base.py | 4 +- pytvpaint/layer.py | 181 +++++++++++++++++++++++++++++------ pytvpaint/project.py | 64 +++++++++---- pytvpaint/scene.py | 14 ++- pytvpaint/sound.py | 6 +- pytvpaint/utils.py | 13 +-- tests/conftest.py | 2 +- tests/test_clip.py | 10 +- tests/test_project.py | 8 +- 12 files changed, 320 insertions(+), 110 deletions(-) diff --git a/docs/usage/rendering.md b/docs/usage/rendering.md index 9b59849..2c5223a 100644 --- a/docs/usage/rendering.md +++ b/docs/usage/rendering.md @@ -18,7 +18,7 @@ clip.render("./out.#.png", start=10, end=22) !!! warning For more details on how we handle frame ranges in the projects and clips, please check the sections below, which go - into detail about how TVPaint handles ranges and how we changed taht to fit our needs + into detail about how TVPaint handles ranges and how we changed it to fit our needs. ## Sequence parsing with Fileseq @@ -396,3 +396,8 @@ print(c2.timeline_end) # => 63 invalid range anyways, then consider using these wrapped functions directly (`george.tv_project_save_sequence` , `george.tv_save_sequence`). This also means that you will have to do the range conversions yourself, as shown in the examples above. + +!!! Warning + + Even tough pytvpaint does a pretty good job of correcting the frame ranges for rendering, we're still + encountering some weird edge cases where TVPaint will consider the range invalid for seemingly no reason. diff --git a/pyproject.toml b/pyproject.toml index f57e214..5dac6f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pytvpaint" -version = "1.0.0b5" +version = "1.0.0b6" description = "Python scripting for TVPaint" authors = [ "Brunch Studio Developers ", diff --git a/pytvpaint/clip.py b/pytvpaint/clip.py index 11eaffc..37542ac 100644 --- a/pytvpaint/clip.py +++ b/pytvpaint/clip.py @@ -113,7 +113,11 @@ def project(self) -> Project: @property def scene(self) -> Scene: - """The clip's scene.""" + """The clip's scene. + + Raises: + ValueError: if clip cannot be found in the project + """ for scene in self.project.scenes: for other_clip in scene.clips: if other_clip == self: @@ -133,7 +137,11 @@ def camera(self) -> Camera: @property def position(self) -> int: - """The position of the clip in the scene.""" + """The position of the clip in the scene. + + Raises: + ValueError: if clip cannot be found in the project + """ for pos, clip_id in enumerate(self.scene.clip_ids): if clip_id == self.id: return pos @@ -142,6 +150,7 @@ def position(self) -> int: @position.setter def position(self, value: int) -> None: """Set the position of the clip in the scene.""" + value = max(0, value) george.tv_clip_move(self.id, self.scene.id, value) @property @@ -334,7 +343,11 @@ def layer_names(self) -> Iterator[str]: @property def current_layer(self) -> Layer: - """Get the current layer in the clip.""" + """Get the current layer in the clip. + + Raises: + ValueError: if clip cannot be found in the project + """ for layer in self.layers: if layer.is_current: return layer @@ -371,7 +384,7 @@ def load_media( start_count: tuple[int, int] | None = None, stretch: bool = False, time_stretch: bool = False, - pre_load: bool = False, + preload: bool = False, with_name: str = "", field_order: george.FieldOrder = george.FieldOrder.LOWER, ) -> Layer: @@ -382,7 +395,7 @@ def load_media( start_count: the start and number of image of sequence to load. Defaults to None. stretch: Stretch each image to the size of the layer. Defaults to None. time_stretch: Once loaded, the layer will have a new number of image corresponding to the project framerate. Defaults to None. - pre_load: Load all the images in memory, no more reference on the files. Defaults to None. + preload: Load all the images in memory, no more reference on the files. Defaults to None. with_name: the name of the new layer field_order: the field order. Defaults to None. @@ -397,7 +410,7 @@ def load_media( field_order, stretch, time_stretch, - pre_load, + preload, ) new_layer = Layer.current_layer() @@ -445,7 +458,7 @@ def render( use_camera: bool = False, layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: """Render the clip to a single frame or frame sequence or movie. @@ -457,7 +470,7 @@ def render( use_camera: use the camera for rendering, otherwise render the whole canvas. Defaults to False. layer_selection: list of layers to render, if None render all of them. Defaults to None. alpha_mode: the alpha mode for rendering. Defaults to george.AlphaSaveMode.PREMULTIPLY. - background_mode: the background mode for rendering. Defaults to george.BackgroundMode.NONE. + background_mode: the background mode for rendering. Defaults to None. format_opts: custom format options. Defaults to None. Raises: @@ -467,8 +480,12 @@ def render( Note: This functions uses the clip's range as a basis (start-end). This is different from a project range, which - uses the project timeline. For more details on the differences in frame range and the timeline in TVPaint, - please check the `Limitations` section of the documentation. + uses the project timeline. For more details on the differences in frame ranges and the timeline in TVPaint, + please check the `Usage/Rendering` section of the documentation. + + Warning: + Even tough pytvpaint does a pretty good job of correcting the frame ranges for rendering, we're still + encountering some weird edge cases where TVPaint will consider the range invalid for seemingly no reason. """ default_start = self.mark_in or self.start default_end = self.mark_out or self.end @@ -491,6 +508,7 @@ def export_tvp(self, export_path: Path | str) -> None: """Exports the clip in .tvp format which can be imported as a project in TVPaint. Raises: + ValueError: if output extension is not (.tvp) FileNotFoundError: if the render failed and no files were found on disk """ export_path = Path(export_path) @@ -515,7 +533,7 @@ def export_json( file_pattern: str = r"[%3ii] %ln", layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, all_images: bool = False, ignore_duplicates: bool = False, @@ -529,13 +547,13 @@ def export_json( file_pattern: the file name pattern (%li: layer index, %ln: layer name, %ii: image index, %in: image name, %fi: file index (added in 11.0.8)). Defaults to None. layer_selection: list of layers to render or all if None. Defaults to None. alpha_mode: the export alpha mode. Defaults to george.AlphaSaveMode.PREMULTIPLY. - background_mode: the export background mode. Defaults to george.BackgroundMode.NONE. + background_mode: the export background mode. Defaults to None. format_opts: custom format options. Defaults to None. all_images: export all images (not only the instances). Defaults to False. ignore_duplicates: Ignore duplicates images. Defaults to None. Raises: - FileNotFoundError: if the render failed and no files were found on disk + FileNotFoundError: if the export failed and no files were found on disk """ export_path = Path(export_path) export_path.parent.mkdir(exist_ok=True, parents=True) @@ -567,7 +585,7 @@ def export_psd( end: int | None = None, layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: """Save the current clip as a PSD. @@ -579,8 +597,11 @@ def export_psd( end: the end frame. Defaults to None. layer_selection: layers to render. Defaults to None (render all the layers). alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY. - background_mode: the export background mode. Defaults to george.BackgroundMode.NONE. + background_mode: the export background mode. Defaults to None. format_opts: custom format options. Defaults to None. + + Raises: + FileNotFoundError: if the export failed and no files were found on disk """ start = start or self.mark_in or self.start end = end or self.mark_out or self.end @@ -622,7 +643,7 @@ def export_csv( exposure_label: str = "", layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: """Save the current clip as a CSV. @@ -634,7 +655,7 @@ def export_csv( exposure_label: give a label when the image is an exposure. Defaults to None. layer_selection: layers to render. Defaults to None (render all the layers). alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY. - background_mode: the export background mode. Defaults to george.BackgroundMode.NONE. + background_mode: the export background mode. Defaults to None. format_opts: custom format options. Defaults to None. Raises: @@ -664,7 +685,7 @@ def export_sprites( space: int = 0, layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: """Save the current clip as sprites in one image. @@ -675,8 +696,11 @@ def export_sprites( space: the space between each sprite in the image. Defaults to None. layer_selection: layers to render. Defaults to None (render all the layers). alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY. - background_mode: the export background mode. Defaults to george.BackgroundMode.NONE. + background_mode: the export background mode. Defaults to None. format_opts: custom format options. Defaults to None. + + Raises: + FileNotFoundError: if the export failed and no files were found on disk """ export_path = Path(export_path) save_format = george.SaveFormat.from_extension(export_path.suffix) @@ -702,7 +726,7 @@ def export_flix( send: bool = False, layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: """Save the current clip for Flix. @@ -716,11 +740,12 @@ def export_flix( send: open a browser with the prefilled url. Defaults to None. layer_selection: layers to render. Defaults to None (render all the layers). alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY. - background_mode: the export background mode. Defaults to george.BackgroundMode.NONE. + background_mode: the export background mode. Defaults to None. format_opts: custom format options. Defaults to None. Raises: ValueError: if the extension is not .xml + FileNotFoundError: if the export failed and no files were found on disk """ export_path = Path(export_path) @@ -768,8 +793,12 @@ def mark_in(self) -> int | None: @set_as_current def mark_in(self, value: int | None) -> None: """Set the mark int value of the clip or None to clear it.""" - action = george.MarkAction.CLEAR if value is None else george.MarkAction.SET - value = value or self.mark_in or 0 + if value is None: + action = george.MarkAction.CLEAR + value = self.mark_in or 0 + else: + action = george.MarkAction.SET + value = value frame = value - self.project.start_frame george.tv_mark_in_set( @@ -789,8 +818,12 @@ def mark_out(self) -> int | None: @set_as_current def mark_out(self, value: int | None) -> None: """Set the mark in of the clip or None to clear it.""" - action = george.MarkAction.CLEAR if value is None else george.MarkAction.SET - value = value or self.mark_out or 0 + if value is None: + action = george.MarkAction.CLEAR + value = self.mark_out or 0 + else: + action = george.MarkAction.SET + value = value frame = value - self.project.start_frame george.tv_mark_out_set( @@ -805,27 +838,26 @@ def layer_colors(self) -> Iterator[LayerColor]: for color_index in range(26): yield LayerColor(color_index=color_index, clip=self) - def set_layer_color( - self, - index: int, - color: george.RGBColor, - name: str | None = None, - ) -> None: + def set_layer_color(self, layer_color: LayerColor) -> None: """Set the layer color at the provided index. Args: - index: the layer color index - color: the new color - name: the name to change. Defaults to None. + layer_color: the layer color instance. """ - george.tv_layer_color_set_color(self.id, index, color, name) + george.tv_layer_color_set_color( + self.id, layer_color.index, layer_color.color, layer_color.name + ) def get_layer_color( self, by_index: int | None = None, by_name: str | None = None, - ) -> LayerColor: - """Get a layer color by index or name.""" + ) -> LayerColor | None: + """Get a layer color by index or name. + + Raises: + ValueError: if none of the arguments `by_index` and `by_name` where provided + """ if not by_index and by_name: raise ValueError( "At least one value (by_index or by_name) must be provided" @@ -837,9 +869,7 @@ def get_layer_color( try: return next(c for c in self.layer_colors if c.name == by_name) except StopIteration: - raise ValueError( - f"No LayerColor found with name ({by_name}) in Clip ({self.name})" - ) + return None @property def bookmarks(self) -> Iterator[int]: @@ -889,11 +919,16 @@ def get_sound( by_id: int | None = None, by_path: Path | str | None = None, ) -> ClipSound | None: - """Get a clip sound by id or by path.""" + """Get a clip sound by id or by path. + + Raises: + ValueError: if sound object could not be found in clip + """ for sound in self.sounds: if (by_id and sound.id == by_id) or (by_path and sound.path == by_path): return sound - raise ValueError("Can't find sound") + + return None def add_sound(self, sound_path: Path | str) -> ClipSound: """Adds a new clip soundtrack.""" diff --git a/pytvpaint/george/grg_base.py b/pytvpaint/george/grg_base.py index e8e2f58..c6d6c12 100644 --- a/pytvpaint/george/grg_base.py +++ b/pytvpaint/george/grg_base.py @@ -340,8 +340,8 @@ class SaveFormat(Enum): @classmethod def from_extension(cls, extension: str) -> SaveFormat: """Returns the correct tvpaint format value from a string extension.""" - extension = extension.replace(".", "").lower() - if not hasattr(SaveFormat, extension.upper()): + extension = extension.replace(".", "").upper() + if not hasattr(SaveFormat, extension): raise ValueError( f"Could not find format ({extension}) in accepted formats ({SaveFormat})" ) diff --git a/pytvpaint/layer.py b/pytvpaint/layer.py index 4fab12a..7b17870 100644 --- a/pytvpaint/layer.py +++ b/pytvpaint/layer.py @@ -8,6 +8,9 @@ from typing import TYPE_CHECKING from uuid import uuid4 +from fileseq.filesequence import FileSequence +from fileseq.frameset import FrameSet + from pytvpaint import george, log, utils from pytvpaint.george.exceptions import GeorgeError from pytvpaint.utils import ( @@ -35,7 +38,11 @@ class LayerInstance: start: int def __post_init__(self) -> None: - """Checks if the instance exists after init.""" + """Checks if the instance exists after init. + + Raises: + ValueError: if no layer instance found at provided start frame + """ try: project_start_frame = self.layer.project.start_frame george.tv_instance_get_name(self.layer.id, self.start - project_start_frame) @@ -388,11 +395,22 @@ def position(self) -> int: @position.setter def position(self, value: int) -> None: - """Moves the layer to the provided position.""" + """Moves the layer to the provided position. + + Note: + This function fixes the issues with positions not been set correctly by TVPaint when value is superior to 0 + """ if self.position == value: return + + value = max(0, value) + # TVPaint will always set the position at (value - 1) if value is superior to 0, so we need to add +1 in + # that case to set the position correctly, I don't know why it works this way, but it honestly makes no sense + if value != 0: + value += 1 + self.make_current() - george.tv_layer_move(value + 1) + george.tv_layer_move(value) @refreshed_property def name(self) -> str: @@ -805,30 +823,53 @@ def remove(self) -> None: self.mark_removed() @set_as_current - def load_image( - self, image_path: str | Path, frame: int | None = None, stretch: bool = False + def render( + self, + output_path: Path | str | FileSequence, + start: int | None = None, + end: int | None = None, + use_camera: bool = False, + alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, + background_mode: george.BackgroundMode | None = None, + format_opts: list[str] | None = None, ) -> None: - """Load an image in the current layer at a given frame. + """Render the layer to a single frame or frame sequence or movie. Args: - image_path: path to the image to load - frame: the frame where the image will be loaded, if none provided, image will be loaded at current frame - stretch: whether to stretch the image to fit the view + output_path: a single file or file sequence pattern + start: the start frame to render the layer's start if None. Defaults to None. + end: the end frame to render or the layer's end if None. Defaults to None. + use_camera: use the camera for rendering, otherwise render the whole canvas. Defaults to False. + alpha_mode: the alpha mode for rendering. Defaults to george.AlphaSaveMode.PREMULTIPLY. + background_mode: the background mode for rendering. Defaults to None. + format_opts: custom format options. Defaults to None. Raises: - FileNotFoundError: if the file doesn't exist at provided path - """ - image_path = Path(image_path) - if not image_path.exists(): - raise FileNotFoundError(f"Image not found at : {image_path}") + ValueError: if requested range (start-end) not in clip range/bounds + ValueError: if output is a movie + FileNotFoundError: if the render failed and no files were found on disk or missing frames - frame = frame or self.clip.current_frame - with utils.restore_current_frame(self.clip, frame): - # if no instance at the specified frame, then create a new one - if not self.get_instance(frame): - self.add_instance(frame) + Note: + This functions uses the layer's range as a basis (start-end). This is different from a project range, which + uses the project timeline. For more details on the differences in frame ranges and the timeline in TVPaint, + please check the `Usage/Rendering` section of the documentation. - george.tv_load_image(image_path.as_posix(), stretch) + Warning: + Even tough pytvpaint does a pretty good job of correcting the frame ranges for rendering, we're still + encountering some weird edge cases where TVPaint will consider the range invalid for seemingly no reason. + """ + start = self.start if start is None else start + end = self.end if end is None else end + self.clip.render( + output_path=output_path, + start=start, + end=end, + use_camera=use_camera, + layer_selection=[self], + alpha_mode=alpha_mode, + background_mode=background_mode, + format_opts=format_opts, + ) @set_as_current def render_frame( @@ -836,23 +877,23 @@ def render_frame( export_path: Path | str, frame: int | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = george.BackgroundMode.NONE, format_opts: list[str] | None = None, ) -> Path: """Render a frame from the layer. Args: - export_path: the frame export path (the extension determine the output format) + export_path: the frame export path (the extension determines the output format) frame: the frame to render or the current frame if None. Defaults to None. alpha_mode: the render alpha mode background_mode: the render background mode format_opts: custom output format options to pass when rendering Raises: - FileNotFoundError: if the render failed and it can't find file on disk + FileNotFoundError: if the render failed or output not found on disk Returns: - Path: + Path: render output path """ export_path = Path(export_path) save_format = george.SaveFormat.from_extension(export_path.suffix) @@ -872,11 +913,91 @@ def render_frame( if not export_path.exists(): raise FileNotFoundError( - f"Could not find rendered image at : {export_path.as_posix()}" + f"Could not find rendered image ({frame}) at : {export_path.as_posix()}" ) return export_path + @set_as_current + def render_instances( + self, + export_path: Path | str | FileSequence, + start: int | None = None, + end: int | None = None, + alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, + background_mode: george.BackgroundMode | None = None, + format_opts: list[str] | None = None, + ) -> FileSequence: + """Render all layer instances in the provided range for the current layer. + + Args: + export_path: the export path (the extension determines the output format) + start: the start frame to render the layer's start if None. Defaults to None. + end: the end frame to render or the layer's end if None. Defaults to None. + alpha_mode: the render alpha mode + background_mode: the render background mode + format_opts: custom output format options to pass when rendering + + Raises: + ValueError: if requested range (start-end) not in layer range/bounds + ValueError: if output is a movie + FileNotFoundError: if the render failed or output not found on disk + + Returns: + FileSequence: instances output sequence + """ + file_sequence, start, end, is_sequence, is_image = utils.handle_output_range( + export_path, self.start, self.end, start, end + ) + + if start < self.start or end > self.end: + raise ValueError( + f"Render ({start}-{end}) not in clip range ({(self.start, self.end)})" + ) + if not is_image: + raise ValueError( + f"Video formats ({file_sequence.extension()}) are not supported for instance rendering !" + ) + + # render to output + frames = [] + for layer_instance in self.instances: + cur_frame = layer_instance.start + instance_output = Path(file_sequence.frame(cur_frame)) + self.render_frame( + instance_output, cur_frame, alpha_mode, background_mode, format_opts + ) + frames.append(str(cur_frame)) + + file_sequence.setFrameSet(FrameSet(",".join(frames))) + return file_sequence + + @set_as_current + def load_image( + self, image_path: str | Path, frame: int | None = None, stretch: bool = False + ) -> None: + """Load an image in the current layer at a given frame. + + Args: + image_path: path to the image to load + frame: the frame where the image will be loaded, if none provided, image will be loaded at current frame + stretch: whether to stretch the image to fit the view + + Raises: + FileNotFoundError: if the file doesn't exist at provided path + """ + image_path = Path(image_path) + if not image_path.exists(): + raise FileNotFoundError(f"Image not found at : {image_path}") + + frame = frame or self.clip.current_frame + with utils.restore_current_frame(self.clip, frame): + # if no instance at the specified frame, then create a new one + if not self.get_instance(frame): + self.add_instance(frame) + + george.tv_load_image(image_path.as_posix(), stretch) + def get_mark_color(self, frame: int) -> LayerColor | None: """Get the mark color at a specific frame. @@ -890,6 +1011,7 @@ def get_mark_color(self, frame: int) -> LayerColor | None: color_index = george.tv_layer_mark_get(self.id, frame) if not color_index: return None + return self.clip.get_layer_color(by_index=color_index) def add_mark(self, frame: int, color: LayerColor) -> None: @@ -900,10 +1022,12 @@ def add_mark(self, frame: int, color: LayerColor) -> None: color: the color index Raises: - Exception: if the layer is not an animation layer + TypeError: if the layer is not an animation layer """ if not self.is_anim_layer: - raise Exception("Can't add a mark because it's not an animation layer") + raise TypeError( + f"Can't add a mark because this is not an animation layer ({self})" + ) frame = frame - self.project.start_frame george.tv_layer_mark_set(self.id, frame, color.index) @@ -1059,6 +1183,7 @@ def add_instance( split: True to make each added frame a new image Raises: + TypeError: if the layer is not an animation layer ValueError: if the number of frames `nb_frames` is inferior or equal to 0 ValueError: if an instance already exists at the given range (start + nb_frames) @@ -1066,7 +1191,7 @@ def add_instance( LayerInstance: new layer instance """ if not self.is_anim_layer: - raise ValueError("The layer needs to be an animation layer") + raise TypeError("The layer needs to be an animation layer") if nb_frames <= 0: raise ValueError("Instance number of frames must be at least 1") diff --git a/pytvpaint/project.py b/pytvpaint/project.py index 1e82150..6ed680d 100644 --- a/pytvpaint/project.py +++ b/pytvpaint/project.py @@ -46,7 +46,11 @@ def __eq__(self, other: object) -> bool: return self.id == other.id def refresh(self) -> None: - """Refreshed the project data.""" + """Refreshed the project data. + + Raises: + ValueError: if project has been closed + """ if self._is_closed: msg = "Project already closed, load the project again to get data" raise ValueError(msg) @@ -68,6 +72,9 @@ def id(self) -> str: def position(self) -> int: """The project's position in the project tabs. + Raises: + ValueError: if project cannot be found in open projects + Note: the indices go from right to left in the UI """ @@ -309,12 +316,13 @@ def get_project( cls, by_id: str | None = None, by_name: str | None = None, - ) -> Project: + ) -> Project | None: """Find a project by id or by name.""" for project in Project.open_projects(): if (by_id and project.id == by_id) or (by_name and project.name == by_name): return project - raise ValueError(f"Can't find a project with id: {by_id} and name: {by_name}") + + return None @staticmethod def current_scene_ids() -> Iterator[int]: @@ -323,7 +331,11 @@ def current_scene_ids() -> Iterator[int]: @property def current_scene(self) -> Scene: - """Get the current scene of the project.""" + """Get the current scene of the project. + + Raises: + ValueError: if scene cannot be found in project + """ for scene in self.scenes: if scene.is_current: return scene @@ -342,12 +354,13 @@ def get_scene( self, by_id: int | None = None, by_name: str | None = None, - ) -> Scene: + ) -> Scene | None: """Find a scene in the project by id or name.""" for scene in self.scenes: if (by_id and scene.id == by_id) or (by_name and scene.name == by_name): return scene - raise ValueError("Scene not found") + + return None @set_as_current def add_scene(self) -> Scene: @@ -386,15 +399,18 @@ def get_clip( by_id: int | None = None, by_name: str | None = None, scene_id: int | None = None, - ) -> Clip: + ) -> Clip | None: """Find a clip by id, name or scene_id.""" - clips = self.get_scene(by_id=scene_id).clips if scene_id else self.clips + clips = self.clips + if scene_id: + selected_scene = self.get_scene(by_id=scene_id) + clips = selected_scene.clips if selected_scene else clips for clip in clips: if (by_id and clip.id == by_id) or (by_name and clip.name == by_name): return clip - raise ValueError("Clip not found") + return None def add_clip(self, clip_name: str, scene: Scene | None = None) -> Clip: """Add a new clip in the given scene or the current one if no scene provided.""" @@ -417,7 +433,7 @@ def add_sound(self, sound_path: Path | str) -> ProjectSound: def _validate_range(self, start: int, end: int) -> None: project_start_frame = self.start_frame - project_end_frame = self.start_frame + project_end_frame = self.end_frame project_mark_in = self.mark_in project_mark_out = self.mark_out @@ -445,7 +461,7 @@ def render( end: int | None = None, use_camera: bool = False, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: """Render the project to a single frame or frame sequence or movie. @@ -466,8 +482,12 @@ def render( Note: This functions uses the project's timeline as a basis for the range (start-end). This timeline includes all - the project's clips and is different from a clip range. For more details on the differences in frame range - and the timeline in TVPaint, please check the `Limitations` section of the documentation. + the project's clips and is different from a clip range. For more details on the differences in frame ranges + and the timeline in TVPaint, please check the `Usage/Rendering` section of the documentation. + + Warning: + Even tough pytvpaint does a pretty good job of correcting the frame ranges for rendering, we're still + encountering some weird edge cases where TVPaint will consider the range invalid for seemingly no reason. """ default_start = self.mark_in or self.start_frame default_end = self.mark_out or self.end_frame @@ -492,7 +512,7 @@ def render_clips( output_path: Path | str | FileSequence, use_camera: bool = False, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: """Render sequential clips as a single output.""" @@ -544,8 +564,12 @@ def mark_in(self) -> int | None: @mark_in.setter @set_as_current def mark_in(self, value: int | None) -> None: - action = george.MarkAction.CLEAR if value is None else george.MarkAction.SET - value = value or self.mark_in or 0 + if value is None: + action = george.MarkAction.CLEAR + value = self.mark_in or 0 + else: + action = george.MarkAction.SET + value = value frame = value - self.start_frame george.tv_mark_in_set( @@ -566,8 +590,12 @@ def mark_out(self) -> int | None: @mark_out.setter @set_as_current def mark_out(self, value: int | None) -> None: - action = george.MarkAction.CLEAR if value is None else george.MarkAction.SET - value = value or self.mark_out or 0 + if value is None: + action = george.MarkAction.CLEAR + value = self.mark_out or 0 + else: + action = george.MarkAction.SET + value = value frame = value - self.start_frame george.tv_mark_out_set( diff --git a/pytvpaint/scene.py b/pytvpaint/scene.py index 1e872e3..72b924a 100644 --- a/pytvpaint/scene.py +++ b/pytvpaint/scene.py @@ -78,14 +78,19 @@ def project(self) -> Project: @property def position(self) -> int: - """The scene's position in the project.""" + """The scene's position in the project. + + Raises: + ValueError: if scene cannot be found in the project + """ for pos, other_id in enumerate(self.project.current_scene_ids()): if other_id == self.id: return pos - raise Exception("The clip doesn't exist anymore") + raise Exception("The scene doesn't exist anymore") @position.setter def position(self, value: int) -> None: + value = max(0, value) george.tv_scene_move(self.id, value) @property @@ -106,12 +111,13 @@ def get_clip( self, by_id: int | None = None, by_name: str | None = None, - ) -> Clip: + ) -> Clip | None: """Find a clip by id or by name.""" for clip in self.clips: if (by_id and clip.id == by_id) or (by_name and clip.name == by_name): return clip - raise ValueError("Clip not found") + + return None @set_as_current def add_clip(self, clip_name: str) -> Clip: diff --git a/pytvpaint/sound.py b/pytvpaint/sound.py index be1ef8e..677572c 100644 --- a/pytvpaint/sound.py +++ b/pytvpaint/sound.py @@ -91,7 +91,11 @@ def refresh(self) -> None: @property def track_index(self) -> int: - """Get the soundtrack index in the sound stack.""" + """Get the soundtrack index in the sound stack. + + Raises: + ValueError: if sound object no longer exists + """ # Recomputes the track_index each time because some track # can be deleted in the meantime for index, data in enumerate(self.iter_sounds_data(self._parent.id)): diff --git a/pytvpaint/utils.py b/pytvpaint/utils.py index 30d4c08..37aee3e 100644 --- a/pytvpaint/utils.py +++ b/pytvpaint/utils.py @@ -139,7 +139,7 @@ def _render( use_camera: bool = False, layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, - background_mode: george.BackgroundMode = george.BackgroundMode.NONE, + background_mode: george.BackgroundMode | None = None, format_opts: list[str] | None = None, ) -> None: file_sequence, start, end, is_sequence, is_image = handle_output_range( @@ -147,6 +147,7 @@ def _render( ) self._validate_range(start, end) + origin_start = int(start) start, end = self._get_real_range(start, end) if not is_image and start == end: raise ValueError( @@ -158,7 +159,6 @@ def _render( first_frame = Path(file_sequence.frame(file_sequence.start())) else: first_frame = Path(str(output_path)) - first_frame.parent.mkdir(exist_ok=True, parents=True) save_format = george.SaveFormat.from_extension( @@ -170,7 +170,7 @@ def _render( alpha_mode, background_mode, save_format, format_opts, layer_selection ): if start == end: - with restore_current_frame(self, file_sequence.start()): + with restore_current_frame(self, origin_start): george.tv_save_display(first_frame) else: # not using tv_save_sequence since it doesn't handle camera and would require different range math @@ -463,7 +463,8 @@ def handle_output_range( ) -> tuple[FileSequence, int, int, bool, bool]: """Handle the different options for output paths and range. - Whether the user provides a range (start-end) or a filesequence with a range or not, this functions ensures we always end up with a valid range to render + Whether the user provides a range (start-end) or a filesequence with a range or not, this functions ensures we + always end up with a valid range to render Args: output_path: user provided output path @@ -497,8 +498,8 @@ def handle_output_range( fseq_has_range = frame_set and len(frame_set) > 1 fseq_is_single_image = frame_set and len(frame_set) == 1 fseq_no_range_padding = not frame_set and file_sequence.padding() - range_is_seq = start and end and start != end - range_is_single_image = start and end and start == end + range_is_seq = start is not None and end is not None and start != end + range_is_single_image = start is not None and end is not None and start == end is_single_image = bool( is_image and (fseq_is_single_image or not frame_set) and range_is_single_image diff --git a/tests/conftest.py b/tests/conftest.py index 86502ba..903112f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -340,5 +340,5 @@ def with_loaded_sequence( ppm_sequence[0], with_name="images", stretch=False, - pre_load=True, + preload=True, ) diff --git a/tests/test_clip.py b/tests/test_clip.py index b649c11..403be8f 100644 --- a/tests/test_clip.py +++ b/tests/test_clip.py @@ -9,7 +9,7 @@ from pytvpaint import george from pytvpaint.clip import Clip from pytvpaint.george import RGBColor -from pytvpaint.layer import Layer, LayerInstance +from pytvpaint.layer import Layer, LayerColor, LayerInstance from pytvpaint.project import Project from pytvpaint.scene import Scene from tests.conftest import FixtureYield @@ -451,8 +451,14 @@ def random_color() -> RGBColor: def test_clip_set_layer_color( test_clip_obj: Clip, index: int, random_color: RGBColor ) -> None: - test_clip_obj.set_layer_color(index, random_color, "test") + expected = LayerColor(index, test_clip_obj) + expected.color = random_color + expected.name = "test" + + test_clip_obj.set_layer_color(expected) + result = test_clip_obj.get_layer_color(by_index=index) + assert result is not None assert result.name == "test" assert result.color == random_color diff --git a/tests/test_project.py b/tests/test_project.py index 971bb32..81ddb6a 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -255,13 +255,13 @@ def test_project_get_project(test_project_obj: Project) -> None: def test_project_get_project_wrong_id(test_project_obj: Project) -> None: - with pytest.raises(ValueError, match="Can't find a project"): - Project.get_project(by_id="unknown") + res = Project.get_project(by_id="unknown") + assert res is None def test_project_get_project_wrong_name(test_project_obj: Project) -> None: - with pytest.raises(ValueError, match="Can't find a project"): - Project.get_project(by_name="name") + res = Project.get_project(by_name="name") + assert res is None def test_project_current_scene_ids(