diff --git a/HISTORY.md b/HISTORY.md index dde399e8..bdabf07b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,16 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [5.0.2](https://github.com/uploadcare/pyuploadcare/compare/v5.0.1...v5.0.2) - unreleased +## [5.1.0](https://github.com/uploadcare/pyuploadcare/compare/v5.0.1...v5.1.0) - 2024-04-09 + +### Added + +- For `File`: + - `detect_faces()` method to [detect faces in images](https://uploadcare.com/docs/intelligence/face-detection/). +- For `ImageTransformation`: + - `text()` method to allow adding [text overlays](https://uploadcare.com/docs/transformations/image/overlay/#overlay-text) to images. + - `rect()` method to allow adding [solid color overlays](https://uploadcare.com/docs/transformations/image/overlay/#overlay-solid) to images. + - `strip_meta()` method to [control the presence of EXIF metadata](https://uploadcare.com/docs/transformations/image/compression/#meta-information-control) in the resulting image. + - `border_radius()` method to [add rounded corners](https://uploadcare.com/docs/transformations/image/resize-crop/#operation-border-radius). + - `zoom_objects()` method to [zoom in on objects](https://uploadcare.com/docs/transformations/image/resize-crop/#operation-zoom-objects). + - `rasterize()` method to [rasterize SVG images](https://uploadcare.com/docs/transformations/image/svg/). + - `detect_faces()` method, which provides [face detection in images](https://uploadcare.com/docs/intelligence/face-detection/). While `ImageTransformation.detect_faces()` ensures consistency within the `ImageTransformation` API, you are more likely to use `File.detect_faces()` to obtain face detection results. ### Changed - [Blocks](https://github.com/uploadcare/blocks) have been updated to [v0.36.0](https://github.com/uploadcare/blocks/releases) +- For `ImageTransformation`: + - The `overlay()` and `overlay_self()` methods now treat `overlay_width` and `overlay_height` parameters as optional. + - Unified `gif2video()`, `gif2video_format()`, and `gif2video_quality()` methods into a single `gif2video()` method. The `format` and `quality` parameters can now be accepted directly in the `gif2video()` method. ### Fixed - Django forms: Any modifications made in an image editor are now correctly restored when editing the same image again. Previously, the editor state was not restored, and the original image was displayed instead. [via uploadcare/blocks#615](https://github.com/uploadcare/blocks/issues/615). +### Deprecated + +- For `ImageTransformation`: + - Deprecated the separate `gif2video_format` and `gif2video_quality` methods. Please use the `format` and `quality` parameters directly in the `gif2video` method for setting these properties. + ## [5.0.1](https://github.com/uploadcare/pyuploadcare/compare/v5.0.0...v5.0.1) - 2024-03-02 ### Changed @@ -50,14 +71,14 @@ Additionally, please take note that some settings have been renamed in this upda - [Pydantic](https://docs.pydantic.dev) has been updated to Version 2. Projects dependent on Pydantic Version 1 may encounter errors due to incompatibility between Versions 1 and 2. - Removed `tox.ini`. The recommended method for running tests locally is now through [act](https://github.com/nektos/act) with Docker. -- for Django settings (`UPLOADCARE = {...}`): +- For Django settings (`UPLOADCARE = {...}`): - `widget_*` settings were renamed and moved: - `UPLOADCARE["widget_version"]` to `UPLOADCARE["legacy_widget"]["version"]` - `UPLOADCARE["widget_build"]` to `UPLOADCARE["legacy_widget"]["build"]` - `UPLOADCARE["widget_variant"]` to `UPLOADCARE["legacy_widget"]["build"]` (this is not a typo: former `widget_build` and `widget_variant` settings were equialent) - `UPLOADCARE["widget_url"]` to `UPLOADCARE["legacy_widget"]["override_js_url"]` and works regardless of `use_hosted_assets` value. -- for `pyuploadcare.dj.conf`: +- For `pyuploadcare.dj.conf`: - Individual variables were moved into one dict called `config`. If you've accessed these settings from `pyuploadcare.dj.conf` module in your code, please migrate: - `pub_key` to `config["pub_key"]` - `secret` to `config["secret"]` @@ -72,7 +93,7 @@ Additionally, please take note that some settings have been renamed in this upda - `hosted_url` - `local_url` -- for `pyuploadcare.dj.forms`: +- For `pyuploadcare.dj.forms`: - `FileWidget` renamed to `LegacyFileWidget`. `FileWidget` is an all-new implementation now. - By default, `FileWidget` is used. To use `LegacyFileWidget`, please set `UPLOADCARE["use_legacy_widget"]` to `True` diff --git a/docs/core_api.rst b/docs/core_api.rst index 79a0f42f..e7dade63 100644 --- a/docs/core_api.rst +++ b/docs/core_api.rst @@ -318,7 +318,7 @@ To check status of performed task use `status` method:: addon_task_status = uploadcare.addons_api.status(request_id, addon) -If addon execution produces new data for file (like an AWS recognition does), this data will be placed at `appdata` complex attribute of `File.info` (see `addons documentation`_) +If addon execution produces new data for file (like an AWS recognition does), this data will be placed at `appdata` complex attribute of `File.info` (see `addons documentation`_):: file.update_info(include_appdata=True) print(file.info["appdata"]["aws_rekognition_detect_labels"]) @@ -436,6 +436,8 @@ or by image transformation builder:: >>> file_.cdn_url https://ucarecdn.com/a771f854-c2cb-408a-8c36-71af77811f3b/-/grayscale/-/flip/ +To check out the list of available transformations, please refer to the `URL`_ API reference and to `ImageTransformation`_ class source code. + Useful links ------------ @@ -455,3 +457,4 @@ Useful links .. _addons documentation: https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons .. _metadata documentation: https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata .. _file uploader: https://uploadcare.com/products/file-uploader/?utm_source=github&utm_campaign=pyuploadcare +.. _ImageTransformation: https://github.com/uploadcare/pyuploadcare/blob/main/pyuploadcare/transformations/image.py diff --git a/pyproject.toml b/pyproject.toml index 8bb279c7..d701ecf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyuploadcare" -version = "5.0.1" +version = "5.1.0" description = "Python library for Uploadcare.com" authors = ["Uploadcare Inc "] readme = "README.md" diff --git a/pyuploadcare/__init__.py b/pyuploadcare/__init__.py index 1ef7e869..e9f8d162 100644 --- a/pyuploadcare/__init__.py +++ b/pyuploadcare/__init__.py @@ -1,5 +1,5 @@ # isort: skip_file -__version__ = "5.0.1" +__version__ = "5.1.0" from pyuploadcare.resources.file import File # noqa: F401 from pyuploadcare.resources.file_group import FileGroup # noqa: F401 diff --git a/pyuploadcare/api/api.py b/pyuploadcare/api/api.py index 93e196b4..7f8ce473 100644 --- a/pyuploadcare/api/api.py +++ b/pyuploadcare/api/api.py @@ -574,3 +574,19 @@ def status( json_response = self._client.get(url).json() response = self._parse_response(json_response, response_class) return cast(responses.AddonResponse, response) + + +class URLAPI(API): + resource_type = "" + response_classes = { + "detect_faces": entities.ImageInfoWithFaces, + } + + def detect_faces( + self, file_uuid: Union[UUID, str] + ) -> entities.ImageInfoWithFaces: + url = self._build_url(file_uuid, suffix="detect_faces/") + response_class = self._get_response_class("detect_faces") + json_response = self._client.get(url).json() + response = self._parse_response(json_response, response_class) + return cast(entities.ImageInfoWithFaces, response) diff --git a/pyuploadcare/api/entities.py b/pyuploadcare/api/entities.py index 2511e297..476fe97a 100644 --- a/pyuploadcare/api/entities.py +++ b/pyuploadcare/api/entities.py @@ -5,7 +5,7 @@ from uuid import UUID from pydantic import BaseModel, EmailStr, Field, PrivateAttr -from typing_extensions import Annotated, Literal +from typing_extensions import Annotated, Literal, NamedTuple from .metadata import META_KEY_MAX_LEN, META_KEY_PATTERN, META_VALUE_MAX_LEN @@ -52,6 +52,13 @@ class GEOPoint(Entity): longitude: float +class Face(NamedTuple): + x: int + y: int + width: int + height: int + + WebhookEvent = Literal[ "file.uploaded", "file.infected", # it will be deprecated in favor of info_upldated in the future updates @@ -62,10 +69,10 @@ class GEOPoint(Entity): class ImageInfo(Entity): - color_mode: ColorMode + color_mode: Optional[ColorMode] = None orientation: Optional[int] = None format: str - sequence: bool + sequence: Optional[bool] = None height: int width: int geo_location: Optional[GEOPoint] = None @@ -73,6 +80,10 @@ class ImageInfo(Entity): dpi: Optional[Tuple[int, int]] = None +class ImageInfoWithFaces(ImageInfo): + faces: List[Face] + + class AudioStreamInfo(Entity): bitrate: Optional[Decimal] = None codec: Optional[str] = None diff --git a/pyuploadcare/client.py b/pyuploadcare/client.py index a9e3e350..1985bd83 100644 --- a/pyuploadcare/client.py +++ b/pyuploadcare/client.py @@ -28,6 +28,7 @@ VideoConvertAPI, WebhooksAPI, ) +from pyuploadcare.api.api import URLAPI from pyuploadcare.api.auth import UploadcareAuth from pyuploadcare.api.client import Client from pyuploadcare.api.entities import ProjectInfo, Webhook, WebhookEvent @@ -150,6 +151,19 @@ def __init__( public_key=public_key, ) + self.cdn_client = Client( + base_url=cdn_base, + verify=( + DEFAULT_SSL_CONTEXT + if verify_upload_ssl is True + else verify_upload_ssl + ), + timeout=timeout, + user_agent_extension=user_agent_extension, + retry_throttled=retry_throttled, + public_key=public_key, + ) + api_config = { "public_key": public_key, "secret_key": secret_key, @@ -168,6 +182,7 @@ def __init__( self.project_api = ProjectAPI(client=self.rest_client, **api_config) # type: ignore self.metadata_api = MetadataAPI(client=self.rest_client, **api_config) # type: ignore self.addons_api = AddonsAPI(client=self.rest_client, **api_config) # type: ignore + self.url_api = URLAPI(client=self.cdn_client, **api_config) def file( self, diff --git a/pyuploadcare/resources/file.py b/pyuploadcare/resources/file.py index 544c1c10..bfcb3052 100644 --- a/pyuploadcare/resources/file.py +++ b/pyuploadcare/resources/file.py @@ -2,12 +2,13 @@ import logging import re import time -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union from uuid import UUID from pyuploadcare.api.entities import ( DocumentConvertFormatInfo, DocumentConvertInfo, + Face, VideoConvertInfo, ) from pyuploadcare.exceptions import ( @@ -499,6 +500,10 @@ def get_converted_document_group( group_id = response.format.converted_groups[format] return self._client.file_group(group_id) + def detect_faces(self) -> List[Face]: + response = self._client.url_api.detect_faces(self.uuid) + return response.faces + class FileFromUrl: """Contains the logic around an upload from url. diff --git a/pyuploadcare/transformations/base.py b/pyuploadcare/transformations/base.py index c8306720..38de04da 100644 --- a/pyuploadcare/transformations/base.py +++ b/pyuploadcare/transformations/base.py @@ -1,5 +1,6 @@ from enum import Enum from typing import List, Optional, Union +from urllib.parse import quote from typing_extensions import Self @@ -28,6 +29,14 @@ def set(self, transformation_name: str, parameters: List[str]) -> Self: self._effects.append(effect) return self + def _escape_percent(self, value: Union[str, int]) -> str: + return str(value).replace("%", "p") + + def _escape_text(self, value: str) -> str: + return quote( + value.replace("~", "~~").replace("/", "~s").replace("\n", "~n") + ) + def _prefix(self, file_id: str) -> str: return f"{file_id}/" diff --git a/pyuploadcare/transformations/image.py b/pyuploadcare/transformations/image.py index 38728005..35919b00 100644 --- a/pyuploadcare/transformations/image.py +++ b/pyuploadcare/transformations/image.py @@ -1,3 +1,4 @@ +import warnings from typing import List, Optional, Union from pyuploadcare.transformations.base import BaseTransformation, StrEnum @@ -31,10 +32,15 @@ class ScaleCropMode(StrEnum): class ImageFormat(StrEnum): + """ + https://uploadcare.com/docs/transformations/image/compression/#operation-format + """ + jpeg = "jpeg" png = "png" webp = "webp" auto = "auto" + preserve = "preserve" class ImageQuality(StrEnum): @@ -47,6 +53,12 @@ class ImageQuality(StrEnum): smart_retina = "smart_retina" +class StripMetaMode(StrEnum): + all = "all" + none = "none" + sensitive = "sensitive" + + class Gif2VideoFormat(StrEnum): mp4 = "mp4" webm = "webm" @@ -127,6 +139,37 @@ class OverlayOffset(StrEnum): center = "center" # type: ignore +class HorizontalTextAlignment(StrEnum): + """ + https://uploadcare.com/docs/transformations/image/overlay/#text-alignment + """ + + left = "left" + center = "center" # type: ignore + right = "right" + + +class VerticalTextAlignment(StrEnum): + """ + https://uploadcare.com/docs/transformations/image/overlay/#text-alignment + """ + + top = "top" + center = "center" # type: ignore + bottom = "bottom" + + +class TextBoxMode(StrEnum): + """ + https://uploadcare.com/docs/transformations/image/overlay/#text-background-box + """ + + none = "none" + fit = "fit" + line = "line" + fill = "fill" + + class ImageTransformation(BaseTransformation): def preview( self, @@ -194,14 +237,56 @@ def scale_crop( self.set("scale_crop", parameters) return self + def border_radius( + self, + radii: Union[int, str, List[Union[int, str]]], + vertical_radii: Optional[ + Union[int, str, List[Union[int, str]]] + ] = None, + ) -> "ImageTransformation": + def _format_radii( + radii: Union[int, str, List[Union[int, str]]] + ) -> str: + """ + >>> _format_radii(10) + '10' + >>> _format_radii([10, "20%"]) + '10,20p' + """ + radii_as_list: List[str] = ( + [str(r) for r in radii] + if isinstance(radii, list) + else [str(radii)] + ) + return ",".join(self._escape_percent(r) for r in radii_as_list) + + parameters: List[str] = [_format_radii(radii)] + + if vertical_radii: + parameters.append(_format_radii(vertical_radii)) + + self.set("border_radius", parameters) + return self + def setfill(self, color: str) -> "ImageTransformation": self.set("setfill", [color]) return self + def zoom_objects(self, amount: int) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/resize-crop/#operation-zoom-objects + """ + self.set("zoom_objects", [str(amount)]) + return self + def format(self, image_format: ImageFormat) -> "ImageTransformation": self.set("format", [image_format]) return self + def rasterize(self) -> "ImageTransformation": + self.set("rasterize", []) + return self + def quality(self, image_quality: ImageQuality) -> "ImageTransformation": self.set("quality", [image_quality]) return self @@ -210,22 +295,64 @@ def progressive(self, is_progressive=True) -> "ImageTransformation": self.set("progressive", ["yes" if is_progressive else "no"]) return self - def gif2video(self) -> "ImageTransformation": + def detect_faces(self) -> "ImageTransformation": + self.set("detect_faces", []) + return self + + def strip_meta(self, mode: StripMetaMode) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/compression/#meta-information-control + """ + self.set("strip_meta", [mode]) + return self + + def gif2video( + self, + format: Optional[Gif2VideoFormat] = None, + quality: Optional[Gif2VideoQuality] = None, + ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/gif-to-video/ + """ self.set("gif2video", []) + if format: + self._gif2video_format(format) + if quality: + self._gif2video_quality(quality) return self - def gif2video_format( + def _gif2video_format( self, format: Gif2VideoFormat ) -> "ImageTransformation": self.set("format", [format]) return self - def gif2video_quality( + def _gif2video_quality( self, quality: Gif2VideoQuality ) -> "ImageTransformation": self.set("quality", [quality]) return self + def gif2video_format( + self, format: Gif2VideoFormat + ) -> "ImageTransformation": + warnings.warn( + "The method `gif2video_format` is deprecated. " + "Use the `format` parameter of `gif2video` instead.", + DeprecationWarning, + ) + return self._gif2video_format(format) + + def gif2video_quality( + self, quality: Gif2VideoQuality + ) -> "ImageTransformation": + warnings.warn( + "The method `gif2video_quality` is deprecated. " + "Use the `quality` parameter of `gif2video` instead.", + DeprecationWarning, + ) + return self._gif2video_quality(quality) + def adjust_color( self, adjustment: ColorAdjustment, value: Optional[int] = None ) -> "ImageTransformation": @@ -313,26 +440,55 @@ def sharp(self, strength: Optional[int] = None) -> "ImageTransformation": self.set("sharp", parameters) return self + def _get_parameters_for_overlay_position( + self, + overlay_width: Optional[Union[str, int]], + overlay_height: Optional[Union[str, int]], + offset: Optional[OverlayOffset], + offset_x: Optional[Union[str, int]], + offset_y: Optional[Union[str, int]], + ) -> List[str]: + """ + relative_dimensions and relative_coordinates for overlays. + https://uploadcare.com/docs/transformations/image/overlay/#overlay-image + """ + + parameters: List[str] = [] + if overlay_width is not None and overlay_height is not None: + overlay_width = self._escape_percent(overlay_width) + overlay_height = self._escape_percent(overlay_height) + parameters.append(f"{overlay_width}x{overlay_height}") + + if offset: + parameters.append(str(offset)) + elif offset_x is not None and offset_y is not None: + offset_x = self._escape_percent(offset_x) + offset_y = self._escape_percent(offset_y) + parameters.append(f"{offset_x},{offset_y}") + + return parameters + def overlay( self, uuid: str, - overlay_width: Union[str, int], - overlay_height: Union[str, int], + overlay_width: Optional[Union[str, int]] = None, + overlay_height: Optional[Union[str, int]] = None, offset: Optional[OverlayOffset] = None, offset_x: Optional[Union[str, int]] = None, offset_y: Optional[Union[str, int]] = None, strength: Optional[int] = None, ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#overlay-image + """ + parameters: List[str] = [ uuid, - f"{overlay_width}x{overlay_height}", + *self._get_parameters_for_overlay_position( + overlay_width, overlay_height, offset, offset_x, offset_y + ), ] - if offset: - parameters.append(str(offset)) - else: - parameters.append(f"{offset_x},{offset_y}") - if strength is not None: parameters.append(f"{strength}p") @@ -341,27 +497,154 @@ def overlay( def overlay_self( self, - overlay_width: Union[str, int], - overlay_height: Union[str, int], + overlay_width: Optional[Union[str, int]] = None, + overlay_height: Optional[Union[str, int]] = None, offset: Optional[OverlayOffset] = None, offset_x: Optional[Union[str, int]] = None, offset_y: Optional[Union[str, int]] = None, strength: Optional[int] = None, ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#overlay-self + """ + return self.overlay( + uuid="self", + overlay_width=overlay_width, + overlay_height=overlay_height, + offset=offset, + offset_x=offset_x, + offset_y=offset_y, + strength=strength, + ) + + def text( + self, + text: str, + overlay_width: Union[str, int], + overlay_height: Union[str, int], + offset: Optional[OverlayOffset] = None, + offset_x: Optional[str] = None, + offset_y: Optional[str] = None, + horizontal_alignment: Optional[HorizontalTextAlignment] = None, + vertical_alignment: Optional[VerticalTextAlignment] = None, + font_size: Optional[int] = None, + font_color: Optional[str] = None, + box_mode: Optional[TextBoxMode] = None, + box_color: Optional[str] = None, + box_padding: Optional[int] = None, + ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#overlay-text + """ + + if horizontal_alignment and vertical_alignment: + self._text_align( + horizontal_alignment=horizontal_alignment, + vertical_alignment=vertical_alignment, + ) + + if font_size or font_color: + self._font(font_size=font_size, font_color=font_color) + + if box_mode: + self._text_box( + box_mode=box_mode, box_color=box_color, box_padding=box_padding + ) + + self._text( + text=text, + overlay_width=overlay_width, + overlay_height=overlay_height, + offset=offset, + offset_x=offset_x, + offset_y=offset_y, + ) + return self + + def _text_align( + self, + horizontal_alignment: HorizontalTextAlignment, + vertical_alignment: VerticalTextAlignment, + ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#text-alignment + """ + self.set("text_align", [horizontal_alignment, vertical_alignment]) + return self + + def _font( + self, font_size: Optional[int], font_color: Optional[str] + ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#font-size-and-color + """ + parameters: List[str] = [] + if font_size: + parameters.append(str(font_size)) + if font_color: + parameters.append(font_color) + self.set("font", parameters) + return self + + def _text_box( + self, + box_mode: TextBoxMode, + box_color: Optional[str], + box_padding: Optional[int], + ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#text-background-box + """ + parameters: List[str] = [box_mode] + if box_color: + parameters.append(box_color) + if box_padding: + parameters.append(str(box_padding)) + self.set("text_box", parameters) + return self + + def _text( + self, + text: str, + overlay_width: Union[str, int], + overlay_height: Union[str, int], + offset: Optional[OverlayOffset], + offset_x: Optional[str], + offset_y: Optional[str], + ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#overlay-text + """ parameters: List[str] = [ - "self", - f"{overlay_width}x{overlay_height}", + *self._get_parameters_for_overlay_position( + overlay_width, overlay_height, offset, offset_x, offset_y + ), + self._escape_text(text), ] - if offset: - parameters.append(offset) - else: - parameters.append(f"{offset_x},{offset_y}") + self.set("text", parameters) + return self - if strength is not None: - parameters.append(f"{strength}p") + def rect( + self, + color: str, + overlay_width: Union[str, int], + overlay_height: Union[str, int], + offset: Optional[OverlayOffset] = None, + offset_x: Optional[str] = None, + offset_y: Optional[str] = None, + ) -> "ImageTransformation": + """ + https://uploadcare.com/docs/transformations/image/overlay/#overlay-solid + """ + parameters: List[str] = [ + color, + *self._get_parameters_for_overlay_position( + overlay_width, overlay_height, offset, offset_x, offset_y + ), + ] - self.set("overlay", parameters) + self.set("rect", parameters) return self def autorotate(self, enabled=True) -> "ImageTransformation": @@ -385,4 +668,5 @@ def mirror(self) -> "ImageTransformation": def path(self, file_id: str) -> str: path_ = super().path(file_id) path_ = path_.replace("/-/gif2video", "/gif2video") + path_ = path_.replace("/-/detect_faces", "/detect_faces") return path_ diff --git a/tests/functional/resources/cassettes/test_file_detect_faces.yaml b/tests/functional/resources/cassettes/test_file_detect_faces.yaml new file mode 100644 index 00000000..cd4758ca --- /dev/null +++ b/tests/functional/resources/cassettes/test_file_detect_faces.yaml @@ -0,0 +1,46 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - ucarecdn.com + user-agent: + - PyUploadcare/5.0.1/demopublickey (CPython/3.11.7) + method: GET + uri: https://ucarecdn.com/5128ec65-9957-47b8-a6ad-4c2f172ef660/detect_faces// + response: + content: '{"id": "5128ec65-9957-47b8-a6ad-4c2f172ef660", "orientation": null, + "format": "JPEG", "height": 450, "width": 1200, "geo_location": null, "datetime_original": + null, "dpi": null, "faces": [[482, 105, 199, 271], [74, 124, 194, 261], [890, + 54, 260, 316]]}' + headers: + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Content-Length, Etag, X-Image-Width, X-Image-Height, X-Image-Acceptable-Original, + X-Image-Acceptable-Improved + Cache-Control: + - public, max-age=81270 + Connection: + - keep-alive + Content-Length: + - '252' + Content-Type: + - application/json + Date: + - Fri, 22 Mar 2024 19:20:47 GMT + ETag: + - '"06166c7b7ab26e36f8cb6f4809ec20fb1d3a534c"' + Server: + - Uploadcare + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/functional/resources/test_resources.py b/tests/functional/resources/test_resources.py index 737d908f..d7291976 100644 --- a/tests/functional/resources/test_resources.py +++ b/tests/functional/resources/test_resources.py @@ -234,6 +234,19 @@ def test_file_get_converted_document_group(uploadcare): assert group.id == "f56f1e80-31f8-426e-9213-690861252070~4" +@pytest.mark.vcr +def test_file_detect_faces(uploadcare): + file = uploadcare.file("5128ec65-9957-47b8-a6ad-4c2f172ef660") + faces = file.detect_faces() + assert faces + assert len(faces) == 3 + face = faces[0] + assert face.x == 482 + assert face.y == 105 + assert face.width == 199 + assert face.height == 271 + + @pytest.mark.vcr def test_file_info_has_new_structure(uploadcare): """ diff --git a/tests/functional/transformations/test_image.py b/tests/functional/transformations/test_image.py index 7ae309b5..9d61074e 100644 --- a/tests/functional/transformations/test_image.py +++ b/tests/functional/transformations/test_image.py @@ -3,6 +3,7 @@ CropAlignment, Gif2VideoFormat, Gif2VideoQuality, + HorizontalTextAlignment, ImageFilter, ImageFormat, ImageQuality, @@ -11,6 +12,9 @@ ScaleCropMode, SRGBConversion, StretchMode, + StripMetaMode, + TextBoxMode, + VerticalTextAlignment, ) @@ -68,6 +72,17 @@ def test_image_scale_crop(): assert str(transformation) == "scale_crop/1024x1024/center/" +def test_border_radius(): + transformation = ( + ImageTransformation() + .border_radius("10%") + .border_radius([10, "20", "40%", "80p"], "30%") + ) + assert str(transformation) == ( + "border_radius/10p/-/border_radius/10,20,40p,80p/30p/" + ) + + def test_setfill(): transformation = ( ImageTransformation().setfill("ece3d2").format(ImageFormat.jpeg) @@ -76,6 +91,16 @@ def test_setfill(): assert str(transformation) == "setfill/ece3d2/-/format/jpeg/" +def test_zoom_objects(): + transformation = ImageTransformation().zoom_objects(50) + assert str(transformation) == "zoom_objects/50/" + + +def test_rasterize(): + transformation = ImageTransformation().rasterize().blur(20) + assert str(transformation) == "rasterize/-/blur/20/" + + def test_image_progressive(): transformation = ( ImageTransformation() @@ -87,18 +112,32 @@ def test_image_progressive(): assert str(transformation) == "preview/-/quality/best/-/progressive/yes/" +def test_detect_faces(): + transformation = ImageTransformation().detect_faces() + + assert str(transformation) == "detect_faces/" + + assert transformation.path("af0136cc-c60a-49a3-a10f-f9319f0ce7e1") == ( + "af0136cc-c60a-49a3-a10f-f9319f0ce7e1/detect_faces/" + ) + + +def test_strip_meta(): + transformation = ImageTransformation().strip_meta(mode=StripMetaMode.all) + + assert str(transformation) == "strip_meta/all/" + + def test_image_gif2video(): - transformation = ( - ImageTransformation() - .gif2video() - .gif2video_quality(Gif2VideoQuality.lighter) - .gif2video_format(Gif2VideoFormat.webm) + transformation = ImageTransformation().gif2video( + format=Gif2VideoFormat.webm, + quality=Gif2VideoQuality.lighter, ) - assert str(transformation) == "gif2video/-/quality/lighter/-/format/webm/" + assert str(transformation) == "gif2video/-/format/webm/-/quality/lighter/" assert transformation.path("af0136cc-c60a-49a3-a10f-f9319f0ce7e1") == ( - "af0136cc-c60a-49a3-a10f-f9319f0ce7e1/gif2video/-/quality/lighter/-/format/webm/" + "af0136cc-c60a-49a3-a10f-f9319f0ce7e1/gif2video/-/format/webm/-/quality/lighter/" ) @@ -199,7 +238,7 @@ def test_image_unsharp(): assert str(transformation) == "scale_crop/880x600/-/blur/200/-120/" -def tes_image_sharp(): +def test_image_sharp(): transformation = ImageTransformation().preview(600, 600).sharp(20) assert str(transformation) == "preview/600x600/-/sharp/20/" @@ -280,6 +319,52 @@ def test_overlay_self(): ) +def test_text(): + transformation = ( + ImageTransformation() + .preview(440, 440) + .text( + "Up~load\nca/re 👍", + overlay_width="80p", + overlay_height="80p", + offset=OverlayOffset.center, + horizontal_alignment=HorizontalTextAlignment.right, + vertical_alignment=VerticalTextAlignment.bottom, + font_size=20, + font_color="fff", + box_mode=TextBoxMode.fill, + box_color="fcba0355", + ) + ) + assert str(transformation) == ( + "preview/440x440/-/text_align/right/bottom/-/font/20/fff/-/text_box/fill/fcba0355/" + "-/text/80px80p/center/Up~~load~nca~sre%20%F0%9F%91%8D/" + ) + + +def test_rect(): + transformation = ( + ImageTransformation() + .preview(440, 440) + .rect( + color="ff000080", + overlay_width="50%", + overlay_height="33%", + offset_x="50%", + offset_y="50%", + ) + .rect( + color="00ff0080", + overlay_width="33p", + overlay_height="50p", + offset=OverlayOffset.center, + ) + ) + assert str(transformation) == ( + "preview/440x440/-/rect/ff000080/50px33p/50p,50p/-/rect/00ff0080/33px50p/center/" + ) + + def test_image_autorotate(): transformation = ImageTransformation().preview().autorotate(False) assert str(transformation) == "preview/-/autorotate/no/"