diff --git a/docs/content/ingest.md b/docs/content/ingest.md index bc8cc5e5..2aba58ca 100644 --- a/docs/content/ingest.md +++ b/docs/content/ingest.md @@ -52,10 +52,10 @@ reports every item as deduplicated, and is how a folder that grew by three files | | image directory | video | | --- | --- | --- | | what is read | every file at the **top level**, in filename order | one frame per extraction slot, at the rate the source records | -| decoded by | `ImageProcessor.probe` - dimensions and format from the bytes | `VideoProcessor.frames` - ffmpeg, deterministic within one build | -| `uri` | the file's absolute path | `/path/clip.mp4#frame=7` | -| frame position | none | `frame_index` and `frame_timestamp` | -| damage | one report line per file; the run carries on | the frames ffmpeg managed are kept, plus one report line | +| decoded by | `ImageProcessor.stills` - anything Pillow reads, normalized to JPEG/PNG | `VideoProcessor.frames` - ffmpeg, deterministic within one build | +| `uri` | the file's absolute path; `/path/anim.gif#frame=3` for a decomposed animation frame | `/path/clip.mp4#frame=7` | +| frame position | none for a still; `frame_index` and `frame_timestamp` for an animation frame | `frame_index` and `frame_timestamp` | +| damage | one report line per file, the whole file refused; the run carries on | the frames ffmpeg managed are kept, plus one report line | Subdirectories are stepped over and recorded nowhere. Recursion is not a per-run option but a question about what *the source is* - "the same source yields the same assets" - so it belongs to diff --git a/docs/content/media.md b/docs/content/media.md index 85728bb7..d7db6a12 100644 --- a/docs/content/media.md +++ b/docs/content/media.md @@ -45,19 +45,32 @@ disk, and a blob in the default blob store is a path too. | Produces | `thumbnail` → JPEG bytes | `frames` → an iterator of `VideoFrame` | | Needs | Pillow, a dependency | ffmpeg, a binary on `PATH` | -## The accepted list is two formats, and widening it is a decision - -`ImageFormat` names everything VisionSet decodes: **JPEG and PNG**. Extending it is exactly two -edits - a member on the enum, and the decoder's own spelling of it in the adapter's -`_FORMAT_BY_PILLOW_NAME` - and a test asserts the two cover the same set, so a half-done -extension fails on the first run rather than at the first file. - -That cost is the point. Accepting a format is a promise that VisionSet will decode it, hash it, -thumbnail it and export it for as long as the workspaces written today are readable. A -`try: decode` that admitted whatever the installed Pillow happened to support would make that -promise depend on a wheel, and it would quietly let in the long tail where image-decoder CVEs -live. WEBP is the obvious next member; it is not here yet because a format with no generated -fixture is a format nobody is testing. +## The dataset holds two formats; ingest reads far more + +`ImageFormat` names everything a dataset may contain: **JPEG and PNG**, frozen. Acceptance is +deliberately wider. `ImageProcessor.stills` reads anything Pillow decodes - WebP, HEIC and HEIF +(what an iPhone writes), BMP, TIFF, GIF and the rest - and normalizes everything outside the two +into them at ingest, so a dataset consumer never needs a third decoder. + +| You give VisionSet | The dataset holds | +| --- | --- | +| JPG / JPEG, PNG | your exact bytes, untouched | +| WebP, HEIC / HEIF, BMP, TIFF, anything else Pillow decodes | a JPEG, re-encoded at ingest | +| animated GIF, animated WebP | one PNG per frame, `anim.gif#frame=3` | +| MP4, MOV, AVI, WebM, MKV - anything ffmpeg reads | one PNG per extracted frame | + +A converted or decomposed asset keeps the original path in its `uri`, so provenance stays +legible: an asset at `photo.heic` whose `format` reads `jpeg` was transcoded on the way in, and +the original file on disk is untouched. Transcoding is repeatable within one Pillow build, not +across builds - the same caveat extracted video frames carry. + +Out of scope for this distribution: RAW camera formats (CR2, NEF, DNG), AVIF, and JPEG XL. A +Live Photo is two files; the photo ingests as a still, and its clip may be registered as an +ordinary video source. + +The frozen enum is the promise that matters: VisionSet will decode, hash, thumbnail and export +JPEG and PNG for as long as the workspaces written today are readable. What can be *read* may +drift with the installed Pillow; what a dataset *holds* may not. `MPO` is a special case worth knowing about and is **not** a third format. It is a multi-picture JPEG container - what phones write in portrait and burst modes - so it is an diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts index 8d36de7b..9f2584bb 100644 --- a/frontend/ui-core/src/generated/api.ts +++ b/frontend/ui-core/src/generated/api.ts @@ -4349,11 +4349,12 @@ export interface components { }; /** * ImageFormat - * @description Every still-image encoding VisionSet accepts. See the module docstring. + * @description Every still-image encoding a dataset may contain. See the module docstring. * * A ``StrEnum`` rather than a ``Literal``, unlike ``Asset.modality``: that one - * has a single member, where an enum would be ceremony, and this one is a - * closed set whose whole purpose is to grow deliberately. It costs the + * has a single member, where an enum would be ceremony. This set is **frozen + * at JPEG and PNG**: acceptance is wider — anything Pillow decodes is read — + * but everything else is normalized into these two at ingest. It costs the * persistence layer nothing — a ``StrEnum`` member *is* a ``str``, and the * tables already store every other enum as ``String``. * @enum {string} diff --git a/openapi.json b/openapi.json index 8e8802b0..74f79233 100644 --- a/openapi.json +++ b/openapi.json @@ -3088,7 +3088,7 @@ "type": "object" }, "ImageFormat": { - "description": "Every still-image encoding VisionSet accepts. See the module docstring.\n\nA ``StrEnum`` rather than a ``Literal``, unlike ``Asset.modality``: that one\nhas a single member, where an enum would be ceremony, and this one is a\nclosed set whose whole purpose is to grow deliberately. It costs the\npersistence layer nothing \u2014 a ``StrEnum`` member *is* a ``str``, and the\ntables already store every other enum as ``String``.", + "description": "Every still-image encoding a dataset may contain. See the module docstring.\n\nA ``StrEnum`` rather than a ``Literal``, unlike ``Asset.modality``: that one\nhas a single member, where an enum would be ceremony. This set is **frozen\nat JPEG and PNG**: acceptance is wider \u2014 anything Pillow decodes is read \u2014\nbut everything else is normalized into these two at ingest. It costs the\npersistence layer nothing \u2014 a ``StrEnum`` member *is* a ``str``, and the\ntables already store every other enum as ``String``.", "enum": [ "jpeg", "png" diff --git a/pyproject.toml b/pyproject.toml index a87bfb4d..290519ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,11 @@ dependencies = [ # so it is allowed inside kernel/adapters/ and needs no import-linter change. The 11.0 # floor is the first release with Python 3.13 wheels, which the classifiers promise. "pillow>=11.0", + # HEIC/HEIF decoding, registered as a Pillow plugin inside kernel/adapters/ — + # the same third-party-library allowance pillow has. Core rather than an + # extra: phone photos are a first-contact input, and an ingest that refuses + # them until a second install is a broken first run. + "pillow-heif>=1.5.0", # Settings for the embedded job executor (#328). The first pydantic-settings # use in this repository; `server/settings.py` argues why the executor is what # finally earned one, and why the four existing bare `os.environ` reads stay @@ -208,6 +213,12 @@ python_version = "3.12" warn_unused_configs = true warn_redundant_casts = true +[[tool.mypy.overrides]] +# pillow-heif publishes no py.typed marker, so the one symbol imported from it +# (register_heif_opener) arrives untyped. +module = "pillow_heif.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "visionset.kernel.*" disallow_untyped_defs = true diff --git a/src/visionset/kernel/adapters/pillow_image_processor.py b/src/visionset/kernel/adapters/pillow_image_processor.py index baf0ada9..ffbac95c 100644 --- a/src/visionset/kernel/adapters/pillow_image_processor.py +++ b/src/visionset/kernel/adapters/pillow_image_processor.py @@ -3,21 +3,27 @@ from __future__ import annotations import io +from collections.abc import Iterator from typing import BinaryIO, Final from PIL import Image, ImageOps, UnidentifiedImageError +from pillow_heif import register_heif_opener -from visionset.kernel.domain import ImageFormat, ImageMetadata +from visionset.kernel.domain import DecodedStill, ImageFormat, ImageMetadata from visionset.kernel.errors import CorruptMedia, UnsupportedMedia from visionset.kernel.ports.image_processor import DEFAULT_THUMBNAIL_MAX_EDGE -#: Pillow's spelling of every format we accept. Pillow's vocabulary stops here, -#: the way SQLAlchemy's stops at ``_tables.py``: a second image adapter would -#: bring its own table rather than teach the domain a third set of names. +register_heif_opener() + +#: Pillow's spelling of every format that passes through untouched. Pillow's +#: vocabulary stops here, the way SQLAlchemy's stops at ``_tables.py``: a second +#: image adapter would bring its own table rather than teach the domain a third +#: set of names. #: -#: Extending the accepted list is this dict plus a member on ``ImageFormat``, and -#: ``test_image_processor.py`` asserts the two cover the same set, so a half-done -#: extension fails on the first run instead of at the first file. +#: This is the pass-through table, not the accepted list — ``stills`` reads +#: anything Pillow decodes and transcodes what is not in here. It covers exactly +#: ``ImageFormat``, which ``test_image_processor.py`` asserts, because the bytes +#: that skip the transcode are precisely the bytes a dataset may hold. #: #: ``MPO`` is not a fourth format: it is a multi-picture JPEG container — what #: phones write in portrait and burst modes — and VisionSet reads its primary @@ -52,6 +58,15 @@ "progressive": False, } +#: The dataset-still encoder, pinned for the reason ``_THUMBNAIL_ENCODER`` is. +#: ``quality=95`` because these bytes are training input, not a preview. +_STILL_ENCODER: Final[dict[str, object]] = { + "quality": 95, + "subsampling": 0, + "optimize": False, + "progressive": False, +} + #: Pinned by name rather than by its integer value, which says nothing. _RESAMPLING: Final = Image.Resampling.LANCZOS @@ -135,14 +150,24 @@ def _fit(image: Image.Image, max_edge: int) -> Image.Image: """ working = image.convert("RGBA") if _has_alpha(image) else image.convert("RGB") working.thumbnail((max_edge, max_edge), _RESAMPLING, reducing_gap=None) + return _opaque_rgb(working) + +def _opaque_rgb(image: Image.Image) -> Image.Image: + """The compositing tail of :func:`_fit`, alone: full-size, no resampling. + + What a convertible still goes through before the dataset encoder sees it — + the same fresh-canvas and known-background guarantees, at the image's own + size. + """ + working = image.convert("RGBA") if _has_alpha(image) else image.convert("RGB") canvas = Image.new("RGB", working.size, _BACKGROUND) canvas.paste(working, mask=working.getchannel("A") if working.mode == "RGBA" else None) return canvas class PillowImageProcessor: - """Decodes JPEG and PNG, reports oriented dimensions, encodes fixed thumbnails. + """Decodes what Pillow reads, passes JPEG and PNG through, transcodes the rest. Holds no state at all — no cache, no handle, no configuration — which is why ``WorkspaceService`` builds one per workspace from a zero-argument factory and @@ -215,6 +240,81 @@ def thumbnail( canvas.save(buffer, format=_THUMBNAIL_PILLOW_NAME, **_THUMBNAIL_ENCODER) return buffer.getvalue() + def stills(self, content: BinaryIO, *, name: str | None = None) -> Iterator[DecodedStill]: + """Every dataset-ready still in this file. See the port docstring. + + The native gate runs before the frame count on purpose: MPO decodes + with ``n_frames > 1``, and checking frames first would decompose every + burst photo instead of passing its primary frame through. + """ + source = _stream_name(content, name) + image = self._open(io.BytesIO(_read_all(content)), source) + + native = _FORMAT_BY_PILLOW_NAME.get(image.format or "") + if native is not None: + self._load(image, source) + with image: + width, height = image.size + return iter( + [DecodedStill(metadata=ImageMetadata(width=width, height=height, format=native))] + ) + + if getattr(image, "n_frames", 1) > 1: + with image: + return iter(self._decomposed(image, source)) + + self._load(image, source) + with image: + flat = _opaque_rgb(image) + buffer = io.BytesIO() + flat.save(buffer, format="JPEG", **_STILL_ENCODER) + return iter( + [ + DecodedStill( + metadata=ImageMetadata( + width=flat.width, height=flat.height, format=ImageFormat.JPEG + ), + payload=buffer.getvalue(), + ) + ] + ) + + def _decomposed(self, image: Image.Image, source: str | None) -> list[DecodedStill]: + """One PNG still per frame — a list, not a generator, on purpose. + + Every frame is decoded and encoded before the caller sees the first + one, so damage at frame four of six raises with nothing yielded and a + caller that stores as it consumes leaves no partial file behind. The + cost is the whole animation's encoded frames in memory at once. + """ + stills: list[DecodedStill] = [] + elapsed_ms = 0.0 + for index in range(int(getattr(image, "n_frames", 1))): + try: + image.seek(index) + frame = _opaque_rgb(image) + except Image.DecompressionBombError as exc: + raise UnsupportedMedia(str(exc), name=source) from exc + except (EOFError, OSError, SyntaxError, ValueError) as exc: + raise CorruptMedia( + f"the animation is damaged or truncated at frame {index} ({exc})", + name=source, + ) from exc + buffer = io.BytesIO() + frame.save(buffer, format="PNG") + stills.append( + DecodedStill( + metadata=ImageMetadata( + width=frame.width, height=frame.height, format=ImageFormat.PNG + ), + payload=buffer.getvalue(), + frame_index=index, + frame_timestamp=elapsed_ms / 1000.0, + ) + ) + elapsed_ms += float(image.info.get("duration", 0) or 0) + return stills + def _decode(self, content: BinaryIO, source: str | None) -> tuple[Image.Image, ImageFormat]: """Open, identify, refuse, decode, orient — in that order. @@ -223,10 +323,25 @@ def _decode(self, content: BinaryIO, source: str | None) -> tuple[Image.Image, I bytes; the decode comes before the dimensions, so a truncated file is refused rather than measured. """ - buffer = io.BytesIO(_read_all(content)) + image = self._open(io.BytesIO(_read_all(content)), source) + + # Read off the ImageFile before anything transforms it: convert() and the + # copying form of exif_transpose() both hand back a format of None. + image_format = _FORMAT_BY_PILLOW_NAME.get(image.format or "") + if image_format is None: + found = image.format or "an unrecognized encoding" + accepted = ", ".join(sorted(member.value for member in ImageFormat)) + image.close() + raise UnsupportedMedia( + f"{found} is not accepted; VisionSet reads {accepted}", name=source + ) + + self._load(image, source) + return image, image_format + def _open(self, buffer: io.BytesIO, source: str | None) -> Image.Image: try: - image = Image.open(buffer) + return Image.open(buffer) except UnidentifiedImageError as exc: # Before the OSError clause, which this subclasses: the other way # round, every file that is not an image reads as a corrupt one. @@ -241,17 +356,7 @@ def _decode(self, content: BinaryIO, source: str | None) -> tuple[Image.Image, I f"the image header is damaged or truncated ({exc})", name=source ) from exc - # Read off the ImageFile before anything transforms it: convert() and the - # copying form of exif_transpose() both hand back a format of None. - image_format = _FORMAT_BY_PILLOW_NAME.get(image.format or "") - if image_format is None: - found = image.format or "an unrecognized encoding" - accepted = ", ".join(sorted(member.value for member in ImageFormat)) - image.close() - raise UnsupportedMedia( - f"{found} is not accepted; VisionSet reads {accepted}", name=source - ) - + def _load(self, image: Image.Image, source: str | None) -> None: try: image.load() ImageOps.exif_transpose(image, in_place=True) @@ -263,5 +368,3 @@ def _decode(self, content: BinaryIO, source: str | None) -> tuple[Image.Image, I raise CorruptMedia( f"the image data is damaged or truncated ({exc})", name=source ) from exc - - return image, image_format diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index 61775882..8645b278 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -151,6 +151,7 @@ from visionset.kernel.domain.media import ( MEDIA_TYPES, OCTET_STREAM, + DecodedStill, ImageFormat, ImageMetadata, VideoFrame, @@ -407,6 +408,7 @@ "DatasetMember", "DatasetOperation", "DatasetStats", + "DecodedStill", "DomainEvent", "DownloadSize", "DraftAttribute", diff --git a/src/visionset/kernel/domain/media.py b/src/visionset/kernel/domain/media.py index fe58a9a9..428824ff 100644 --- a/src/visionset/kernel/domain/media.py +++ b/src/visionset/kernel/domain/media.py @@ -11,18 +11,14 @@ for one of them: :class:`ImageMetadata` for a still, :class:`VideoMetadata` and :class:`VideoFrame` for a clip and the frames taken out of it. -**The accepted list is** :class:`ImageFormat` **, and nothing else.** Extending it -is two adjacent edits — a member here, and the decoder's own spelling of it in -``PillowImageProcessor._FORMAT_BY_PILLOW_NAME`` — tied together by a test that -asserts the two cover the same set, so a half-done extension fails on the first -run rather than at the first file. That cost is the point. Accepting a format is -a promise that VisionSet will decode it, hash it, thumbnail it and export it for -as long as the workspaces written today are readable; a ``try: decode`` that -admits whatever the installed Pillow happens to support would make that promise -depend on a wheel. - -WEBP is the obvious next member. It is not here yet because a format with no -generated fixture is a format nobody is testing. +**The dataset vocabulary is** :class:`ImageFormat` **, frozen at two members.** +Acceptance is wider than the dataset: ``ImageProcessor.stills`` reads anything +Pillow decodes, but everything outside these two is normalized *into* them at +ingest — a converted still becomes JPEG, a decomposed animation frame becomes +PNG — so the promise the enum makes (VisionSet will decode, hash, thumbnail and +export these for as long as today's workspaces are readable) is made about two +encodings only, and a dataset consumer never needs a third decoder. What can be +*read* may drift with the installed Pillow; what a dataset *holds* may not. **There is deliberately no ``VideoFormat`` beside it.** The asymmetry is the point: an image is an asset, a video is a source. Curating :class:`ImageFormat` @@ -45,11 +41,12 @@ class ImageFormat(StrEnum): - """Every still-image encoding VisionSet accepts. See the module docstring. + """Every still-image encoding a dataset may contain. See the module docstring. A ``StrEnum`` rather than a ``Literal``, unlike ``Asset.modality``: that one - has a single member, where an enum would be ceremony, and this one is a - closed set whose whole purpose is to grow deliberately. It costs the + has a single member, where an enum would be ceremony. This set is **frozen + at JPEG and PNG**: acceptance is wider — anything Pillow decodes is read — + but everything else is normalized into these two at ingest. It costs the persistence layer nothing — a ``StrEnum`` member *is* a ``str``, and the tables already store every other enum as ``String``. """ @@ -118,6 +115,27 @@ class ImageMetadata(BaseModel): format: ImageFormat +class DecodedStill(BaseModel): + """One dataset-ready still that came out of reading a file. + + ``payload`` is ``None`` when the original stream's bytes are already the + asset (a native JPEG or PNG passes through untouched), and the transcoded + bytes otherwise. ``metadata.format`` is always what the asset will record — + JPEG or PNG — never the source encoding. + + ``frame_index`` and ``frame_timestamp`` are set only when a multi-frame + file decomposed; the timestamp is the elapsed animation time before this + frame, in seconds, with a missing per-frame duration counted as zero. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + metadata: ImageMetadata + payload: bytes | None = None + frame_index: int | None = Field(default=None, ge=0) + frame_timestamp: float | None = Field(default=None, ge=0) + + class VideoMetadata(BaseModel): """What one clip turns out to be: how big, how fast, how long, and in what codec. diff --git a/src/visionset/kernel/ports/image_processor.py b/src/visionset/kernel/ports/image_processor.py index a6005e96..b47c3946 100644 --- a/src/visionset/kernel/ports/image_processor.py +++ b/src/visionset/kernel/ports/image_processor.py @@ -8,9 +8,10 @@ a runtime failure where a compile-time absence was available. """ +from collections.abc import Iterator from typing import BinaryIO, Final, Protocol, runtime_checkable -from visionset.kernel.domain import ImageFormat, ImageMetadata +from visionset.kernel.domain import DecodedStill, ImageFormat, ImageMetadata #: Longest edge of a generated thumbnail, in pixels, unless a caller says otherwise. #: @@ -28,6 +29,14 @@ #: decision, not an edit. THUMBNAIL_FORMAT: Final = ImageFormat.JPEG +#: What a convertible single-frame image becomes. Frozen with ``ImageFormat``: +#: a dataset consumer decodes JPEG and PNG and nothing else. +CONVERTED_STILL_FORMAT: Final = ImageFormat.JPEG + +#: What one frame of a decomposed animation becomes — the same answer the video +#: pipeline gives, so decomposed motion is PNG wherever it came from. +DECOMPOSED_FRAME_FORMAT: Final = ImageFormat.PNG + @runtime_checkable class ImageProcessor(Protocol): @@ -55,10 +64,20 @@ class ImageProcessor(Protocol): stream's own filename when the caller passes nothing, but it never invents one. See ``MediaError``. + ``stills`` is the wide door, and it obeys the three rules above. Where + ``probe`` answers "is this already dataset-ready", ``stills`` reads anything + the decoder can and normalizes it: a native JPEG or PNG yields exactly one + pass-through item whose bytes are the caller's own; any other decodable + image yields transcoded items — one ``CONVERTED_STILL_FORMAT`` still for a + single frame, one ``DECOMPOSED_FRAME_FORMAT`` still per frame of an + animation. The decode is complete before the first item is yielded, so a + damaged file raises before a caller has stored anything. + Raises: - UnsupportedMedia: the bytes are not an image, are not one of the formats - in ``ImageFormat``, or declare more pixels than the decoder will take. - CorruptMedia: the format is accepted and the bytes will not decode. + UnsupportedMedia: the bytes are not an image the decoder reads (for + ``probe``, not one of the formats in ``ImageFormat``), or declare + more pixels than the decoder will take. + CorruptMedia: the bytes are an image we read, and they will not decode. """ def probe(self, content: BinaryIO, *, name: str | None = None) -> ImageMetadata: ... @@ -70,3 +89,5 @@ def thumbnail( max_edge: int = DEFAULT_THUMBNAIL_MAX_EDGE, name: str | None = None, ) -> bytes: ... + + def stills(self, content: BinaryIO, *, name: str | None = None) -> Iterator[DecodedStill]: ... diff --git a/src/visionset/kernel/services/ingest_service.py b/src/visionset/kernel/services/ingest_service.py index 34336968..5042a294 100644 --- a/src/visionset/kernel/services/ingest_service.py +++ b/src/visionset/kernel/services/ingest_service.py @@ -186,13 +186,14 @@ def assets(self, project_id: UUID) -> list[Asset]: 1. ``source_id``, so one clip's frames stay together rather than interleaving with a directory's stills; - 2. ``frame_index``, so a clip's frames come out **in order** — the one - place a sequence genuinely exists. Lexicographic ``uri`` would not do - it: a frame's uri is ``{path}#frame={n}``, and that sorts ``#frame=10`` - before ``#frame=2``; - 3. ``uri``, which for a directory ingest is the filename, and a directory - is walked sorted — so stills come back in the order somebody sees them - in their own file browser; + 2. the **base uri** — the path with any ``#frame=`` fragment cut off — + which for a directory ingest is the filename, and a directory is + walked sorted, so files come back in the order somebody sees them in + their own file browser, each with its own frames beside it; + 3. ``frame_index``, so frames come out **in order** within their file — + the one place a sequence genuinely exists. Lexicographic ``uri`` + would not do it: a frame's uri is ``{path}#frame={n}``, and that + sorts ``#frame=10`` before ``#frame=2``; 4. ``id``, so the order is total and two calls can never disagree. **An asset with no arrival sorts last, never first.** ``_store`` stamps @@ -611,24 +612,46 @@ def _read_directory( paths = sorted(item for item in directory.iterdir() if item.is_file()) total = len(paths) self._record_progress(job_id, processed=0, total=total, failures=failures) - for path in paths: + for files_dealt_with, path in enumerate(paths, start=1): try: with path.open("rb") as handle: - # Probe first: a file that is going to be refused should - # never leave a blob behind. The ``seek(0)`` between is not - # decoration — ``ImageProcessor`` promises to seek to 0 and - # not to close, and ``BlobStore.put`` promises neither, so - # rewinding for it is the caller's job. - metadata = self._workspace.image_processor.probe(handle, name=str(path)) - handle.seek(0) - content_hash = self._workspace.blob_store.put(handle) - # No rewind before this one, and the asymmetry is the port - # contract rather than an oversight: ``ImageProcessor`` - # promises to seek to 0 itself, which is exactly what lets - # one open handle serve a probe, a hash and a thumbnail in - # any order. ``BlobStore.put`` promises nothing, which is - # why the rewind above is still the caller's job. - thumbnail_hash = self._cache_thumbnail(handle, name=str(path)) + # A file that is going to be refused never leaves a blob + # behind: ``stills`` has decoded the whole file — every + # frame of an animation included — before it yields its + # first item, so a refusal arrives before anything below + # stores a byte. + for still in self._workspace.image_processor.stills(handle, name=str(path)): + uri = ( + str(path) + if still.frame_index is None + else f"{path}#frame={still.frame_index}" + ) + if still.payload is None: + # The rewind is the caller's job: ``BlobStore.put`` + # promises nothing about position, where + # ``ImageProcessor`` seeks to 0 itself — which is + # what lets one handle serve both calls. + handle.seek(0) + content_hash = self._workspace.blob_store.put(handle) + thumbnail_hash = self._cache_thumbnail(handle, name=uri) + else: + content = BytesIO(still.payload) + content_hash = self._workspace.blob_store.put(content) + thumbnail_hash = self._cache_thumbnail(content, name=uri) + candidates.append( + Asset( + project_id=source.project_id, + content_hash=content_hash, + uri=uri, + width=still.metadata.width, + height=still.metadata.height, + format=still.metadata.format, + source_id=source.id, + frame_index=still.frame_index, + frame_timestamp=still.frame_timestamp, + thumbnail_hash=thumbnail_hash, + ) + ) except MediaError as exc: # Relative to the directory being read, never the absolute path # the loop is holding — see ``report_name``. The walk is top @@ -636,25 +659,14 @@ def _read_directory( # ``recursive=True`` would introduce; the rule is applied here # rather than left for that task to remember. failures.append(_failure(report_name(path, root=directory), exc)) - else: - candidates.append( - Asset( - project_id=source.project_id, - content_hash=content_hash, - uri=str(path), - width=metadata.width, - height=metadata.height, - format=metadata.format, - source_id=source.id, - thumbnail_hash=thumbnail_hash, - ) - ) - # After every item, read or refused alike: ``processed`` counts what - # the run has dealt with, and a report that only appeared at the end - # would be invisible for exactly as long as it is interesting. + # After every file, read or refused alike: ``processed`` counts the + # files the run has dealt with — not the assets they became, which + # a decomposed animation would inflate past ``total`` — and a + # report that only appeared at the end would be invisible for + # exactly as long as it is interesting. self._record_progress( job_id, - processed=len(candidates) + len(failures), + processed=files_dealt_with, total=total, failures=failures, ) @@ -971,21 +983,24 @@ def _subject(job_id: UUID) -> str: return f"ingest job {job_id}" -def _in_stable_order(asset: Asset) -> tuple[str, int, str, str]: +def _in_stable_order(asset: Asset) -> tuple[str, str, int, str]: """The sort key :meth:`IngestService.assets` documents. Module level rather than a lambda so the reasoning has somewhere to live and a test can exercise it against assets alone. - ``-1`` stands in for a NULL ``frame_index`` because a still has none and - ``None`` cannot be compared with an ``int``. It sorts stills before frames - within one source, which is a distinction no source actually makes: a source - is a directory or a clip, never both. + The base uri — the path with any ``#frame=`` fragment cut off — groups a + file with its own frames, because a directory source can hold stills *and* + a decomposed animation at once. Within the group, ``frame_index`` orders + numerically (``#frame=10`` sorts after ``#frame=2``, which text would not), + with ``-1`` standing in for a still's NULL because ``None`` cannot be + compared with an ``int``. A path and its fragments never collide: the + fragment exists precisely because the file itself did not become an asset. """ return ( str(asset.source_id or ""), + asset.uri.partition("#frame=")[0], -1 if asset.frame_index is None else asset.frame_index, - asset.uri, str(asset.id), ) diff --git a/tests/fixtures/media.py b/tests/fixtures/media.py index 95eb68ed..ceaa67b8 100644 --- a/tests/fixtures/media.py +++ b/tests/fixtures/media.py @@ -236,6 +236,11 @@ def write_video( require_ffmpeg() width, height = size path.parent.mkdir(parents=True, exist_ok=True) + # The WebM muxer refuses h264, and `-movflags` is a mov/mp4 private option + # that other muxers reject — so both follow the suffix. + webm = path.suffix.lower() == ".webm" + codec = ["-c:v", "libvpx-vp9"] if webm else ["-c:v", "libx264", "-preset", "ultrafast"] + movflags = [] if webm else ["-movflags", "+faststart"] _run_ffmpeg( [ "ffmpeg", @@ -248,14 +253,10 @@ def write_video( f"testsrc=size={width}x{height}:rate={fps}:duration={duration_seconds}", "-pix_fmt", "yuv420p", - "-c:v", - "libx264", - "-preset", - "ultrafast", + *codec, "-g", str(fps), - "-movflags", - "+faststart", + *movflags, "-fflags", "+bitexact", "-flags:v", diff --git a/tests/kernel/test_image_processor.py b/tests/kernel/test_image_processor.py index ccfb862f..5c817c61 100644 --- a/tests/kernel/test_image_processor.py +++ b/tests/kernel/test_image_processor.py @@ -30,11 +30,12 @@ import hashlib import io -from collections.abc import Callable +from collections.abc import Callable, Iterator from pathlib import Path import pytest from PIL import Image +from pydantic import ValidationError from tests.fixtures.media import ( DEFAULT_IMAGE_SIZE, write_corrupt_image, @@ -51,7 +52,7 @@ _THUMBNAIL_ENCODER, _THUMBNAIL_PILLOW_NAME, ) -from visionset.kernel.domain import ImageFormat, ImageMetadata +from visionset.kernel.domain import DecodedStill, ImageFormat, ImageMetadata from visionset.kernel.errors import CorruptMedia, MediaError, UnsupportedMedia, VisionSetError from visionset.kernel.ports import DEFAULT_THUMBNAIL_MAX_EDGE, THUMBNAIL_FORMAT, ImageProcessor from visionset.kernel.services import WorkspaceService @@ -532,6 +533,9 @@ def thumbnail( ) -> bytes: return b"" + def stills(self, content: io.IOBase, *, name: str | None = None) -> Iterator[DecodedStill]: + return iter(()) + def test_a_workspace_exposes_an_image_processor_by_default(tmp_path: Path) -> None: with WorkspaceService.init(tmp_path / "ws") as workspace: @@ -562,3 +566,156 @@ def test_an_image_processor_can_be_injected_at_open(tmp_path: Path) -> None: tmp_path / "ws", image_processor_factory=_NullImageProcessor ) as workspace: assert isinstance(workspace.image_processor, _NullImageProcessor) + + +def test_a_decoded_still_is_frozen_and_defaults_to_pass_through() -> None: + still = DecodedStill(metadata=ImageMetadata(width=2, height=1, format=ImageFormat.JPEG)) + assert still.payload is None + assert still.frame_index is None + assert still.frame_timestamp is None + with pytest.raises(ValidationError): + still.payload = b"x" # type: ignore[misc] + + +# --- stills: accept what Pillow decodes, normalized to JPEG and PNG ------------ + + +def _stills(path: Path) -> list[DecodedStill]: + with path.open("rb") as handle: + return list(PillowImageProcessor().stills(handle, name=str(path))) + + +def test_a_native_jpeg_passes_through_with_no_payload(tmp_path: Path) -> None: + path = tmp_path / "native.jpg" + Image.new("RGB", (32, 24), (200, 10, 10)).save(path, format="JPEG") + + (still,) = _stills(path) + + assert still.payload is None + assert still.frame_index is None + assert still.metadata == ImageMetadata(width=32, height=24, format=ImageFormat.JPEG) + + +def test_a_native_png_passes_through_with_no_payload(tmp_path: Path) -> None: + path = tmp_path / "native.png" + Image.new("RGB", (32, 24), (10, 200, 10)).save(path, format="PNG") + + (still,) = _stills(path) + + assert still.payload is None + assert still.metadata.format is ImageFormat.PNG + + +def test_a_webp_arrives_as_a_jpeg_payload(tmp_path: Path) -> None: + path = tmp_path / "photo.webp" + Image.new("RGB", (32, 24), (10, 10, 200)).save(path, format="WEBP") + + (still,) = _stills(path) + + assert still.payload is not None + assert still.metadata.format is ImageFormat.JPEG + decoded = _decoded(still.payload) + assert decoded.format == "JPEG" + assert decoded.size == (32, 24) + + +def test_an_heic_arrives_as_a_jpeg_payload(tmp_path: Path) -> None: + path = tmp_path / "photo.heic" + Image.new("RGB", (32, 24), (120, 60, 30)).save(path, format="HEIF") + + (still,) = _stills(path) + + assert still.payload is not None + assert still.metadata.format is ImageFormat.JPEG + + +def test_an_oriented_webp_is_transposed_before_encoding(tmp_path: Path) -> None: + path = tmp_path / "turned.webp" + exif = Image.Exif() + exif[0x0112] = 6 + Image.new("RGB", (32, 24), (1, 2, 3)).save(path, format="WEBP", exif=exif) + + (still,) = _stills(path) + + assert (still.metadata.width, still.metadata.height) == (24, 32) + + +def test_transparency_composites_onto_white(tmp_path: Path) -> None: + path = tmp_path / "logo.webp" + Image.new("RGBA", (8, 8), (255, 0, 0, 0)).save(path, format="WEBP", lossless=True) + + (still,) = _stills(path) + + assert still.payload is not None + assert _decoded(still.payload).getpixel((4, 4)) == (255, 255, 255) + + +def test_a_transcode_is_repeatable_within_this_build(tmp_path: Path) -> None: + path = tmp_path / "photo.webp" + Image.new("RGB", (32, 24), (9, 9, 9)).save(path, format="WEBP") + + assert _stills(path)[0].payload == _stills(path)[0].payload + + +def test_stills_refuses_what_pillow_cannot_decode(tmp_path: Path) -> None: + path = write_unsupported_file(tmp_path / "notes.txt") + + with pytest.raises(UnsupportedMedia, match="not a recognizable image"): + _stills(path) + + +def _animated_gif(path: Path, frames: int, *, duration_ms: int = 100) -> None: + shades = [Image.new("L", (16, 12), 30 + i * 40) for i in range(frames)] + shades[0].save( + path, format="GIF", save_all=True, append_images=shades[1:], duration=duration_ms + ) + + +def test_an_animated_gif_decomposes_into_png_frames_in_order(tmp_path: Path) -> None: + path = tmp_path / "anim.gif" + _animated_gif(path, frames=3) + + stills = _stills(path) + + assert [still.frame_index for still in stills] == [0, 1, 2] + assert all(still.metadata.format is ImageFormat.PNG for still in stills) + payloads = [still.payload for still in stills] + assert all(payload is not None for payload in payloads) + assert all(_decoded(payload).format == "PNG" for payload in payloads if payload is not None) + + +def test_frame_timestamps_are_elapsed_time_before_each_frame(tmp_path: Path) -> None: + path = tmp_path / "anim.gif" + _animated_gif(path, frames=3, duration_ms=250) + + assert [still.frame_timestamp for still in _stills(path)] == [0.0, 0.25, 0.5] + + +def test_a_single_frame_gif_is_one_converted_jpeg(tmp_path: Path) -> None: + path = tmp_path / "static.gif" + Image.new("P", (16, 12), 40).save(path, format="GIF") + + (still,) = _stills(path) + + assert still.frame_index is None + assert still.metadata.format is ImageFormat.JPEG + + +def test_a_multi_picture_jpeg_still_passes_through_whole(tmp_path: Path) -> None: + """MPO reports n_frames > 1; the native gate must win or every burst photo decomposes.""" + path = write_multi_picture_jpeg(tmp_path / "burst.jpg") + + (still,) = _stills(path) + + assert still.payload is None + assert still.metadata.format is ImageFormat.JPEG + + +def test_a_truncated_animation_yields_nothing_and_says_corrupt(tmp_path: Path) -> None: + path = tmp_path / "cut.gif" + _animated_gif(path, frames=6) + whole = path.read_bytes() + path.write_bytes(whole[: len(whole) - len(whole) // 3]) + + with pytest.raises(CorruptMedia): + _stills(path) diff --git a/tests/kernel/test_ingest_service.py b/tests/kernel/test_ingest_service.py index 727112fc..189c887f 100644 --- a/tests/kernel/test_ingest_service.py +++ b/tests/kernel/test_ingest_service.py @@ -12,7 +12,7 @@ same asset" compares ids rather than row counts. """ -from collections.abc import Callable +from collections.abc import Callable, Iterator from datetime import UTC, datetime from io import BytesIO from pathlib import Path @@ -20,13 +20,13 @@ from uuid import UUID, uuid4 import pytest +from PIL import Image from pydantic import ValidationError from tests.fixtures.media import ( GeneratedVideo, write_corrupt_image, write_corrupt_video, write_image, - write_image_in_unsupported_format, write_images, write_rotated_video, write_unsupported_file, @@ -52,6 +52,7 @@ INGEST_TRANSITIONS, Asset, BatchState, + DecodedStill, GeometryType, ImageFormat, ImageMetadata, @@ -135,6 +136,10 @@ def probe(self, content: BinaryIO, *, name: str | None = None) -> ImageMetadata: self._observe() return self._real.probe(content, name=name) + def stills(self, content: BinaryIO, *, name: str | None = None) -> Iterator[DecodedStill]: + self._observe() + return self._real.stills(content, name=name) + def thumbnail( self, content: BinaryIO, *, max_edge: int = 256, name: str | None = None ) -> bytes: @@ -155,10 +160,13 @@ def __init__(self, nth: int) -> None: self._real = PillowImageProcessor() def probe(self, content: BinaryIO, *, name: str | None = None) -> ImageMetadata: + return self._real.probe(content, name=name) + + def stills(self, content: BinaryIO, *, name: str | None = None) -> Iterator[DecodedStill]: self._calls += 1 if self._calls == self._nth: raise OSError("the disk went away") - return self._real.probe(content, name=name) + return self._real.stills(content, name=name) def thumbnail( self, content: BinaryIO, *, max_edge: int = 256, name: str | None = None @@ -180,6 +188,9 @@ def __init__(self) -> None: def probe(self, content: BinaryIO, *, name: str | None = None) -> ImageMetadata: return self._real.probe(content, name=name) + def stills(self, content: BinaryIO, *, name: str | None = None) -> Iterator[DecodedStill]: + return self._real.stills(content, name=name) + def thumbnail( self, content: BinaryIO, *, max_edge: int = 256, name: str | None = None ) -> bytes: @@ -901,7 +912,6 @@ def test_the_report_separates_data_loss_from_operator_noise(tmp_path: Path) -> N """Why `IngestFailureKind` exists: a reason sentence cannot be grouped on.""" fixture = Fixture(tmp_path) write_unsupported_file(fixture.stills / "notes.txt") - write_image_in_unsupported_format(fixture.stills / "old.bmp") write_corrupt_image(fixture.stills / "half.png") source = fixture.sources.register_images(fixture.project.id, fixture.stills) @@ -910,7 +920,6 @@ def test_the_report_separates_data_loss_from_operator_noise(tmp_path: Path) -> N by_name = {failure.name: failure.kind for failure in result.failures} assert by_name == { "notes.txt": IngestFailureKind.UNSUPPORTED, - "old.bmp": IngestFailureKind.UNSUPPORTED, "half.png": IngestFailureKind.CORRUPT, } fixture.close() @@ -2418,3 +2427,98 @@ def test_the_key_is_a_total_order_so_two_identical_rows_still_have_one() -> None backward = sorted(list(reversed(twins)), key=_in_stable_order) assert [asset.id for asset in forward] == [asset.id for asset in backward] + + +def test_the_key_interleaves_a_files_frames_at_its_own_name() -> None: + """A directory can hold stills and a decomposed animation at once, so the + base path groups a file with its frames and the index orders within it.""" + project, source = uuid4(), uuid4() + scrambled = [ + _sortable(project, uri="c.png", source=source), + _sortable(project, uri="b.gif#frame=1", source=source, frame=1), + _sortable(project, uri="a.jpg", source=source), + _sortable(project, uri="b.gif#frame=0", source=source, frame=0), + ] + + ordered = sorted(scrambled, key=_in_stable_order) + + assert [asset.uri for asset in ordered] == [ + "a.jpg", + "b.gif#frame=0", + "b.gif#frame=1", + "c.png", + ] + + +# --- the wide door: convertible stills and decomposed animations ---------------- + + +def _write_animated_gif(path: Path, frames: int) -> None: + shades = [Image.new("L", (16, 12), 30 + i * 40) for i in range(frames)] + shades[0].save(path, format="GIF", save_all=True, append_images=shades[1:], duration=100) + + +def test_a_mixed_directory_yields_stills_frames_and_one_refusal(tmp_path: Path) -> None: + fixture = Fixture(tmp_path) + write_image(fixture.stills / "a.jpg") + _write_animated_gif(fixture.stills / "b.gif", frames=3) + Image.new("RGB", (32, 24), (120, 60, 30)).save(fixture.stills / "c.heic", format="HEIF") + write_unsupported_file(fixture.stills / "notes.txt") + source = fixture.sources.register_images(fixture.project.id, fixture.stills) + + result = fixture.ingest.ingest(source.id) + + assert (result.created, result.failed) == (5, 1) + by_uri = {asset.uri: asset for asset in fixture.assets()} + gif = str(fixture.stills / "b.gif") + assert {uri for uri in by_uri if "#" in uri} == {f"{gif}#frame={i}" for i in range(3)} + assert all(by_uri[f"{gif}#frame={i}"].format is ImageFormat.PNG for i in range(3)) + assert all(by_uri[f"{gif}#frame={i}"].frame_index == i for i in range(3)) + assert by_uri[str(fixture.stills / "c.heic")].format is ImageFormat.JPEG + assert by_uri[str(fixture.stills / "a.jpg")].format is ImageFormat.JPEG + (failure,) = result.failures + assert (failure.name, failure.kind) == ("notes.txt", IngestFailureKind.UNSUPPORTED) + job = fixture.ingest.get(result.job_id) + assert (job.processed, job.total) == (4, 4) + fixture.close() + + +def test_a_decomposed_frame_records_its_position_and_a_converted_still_does_not( + tmp_path: Path, +) -> None: + fixture = Fixture(tmp_path) + _write_animated_gif(fixture.stills / "anim.gif", frames=2) + Image.new("RGB", (8, 8), (1, 2, 3)).save(fixture.stills / "photo.webp", format="WEBP") + source = fixture.sources.register_images(fixture.project.id, fixture.stills) + + fixture.ingest.ingest(source.id) + + by_uri = {asset.uri: asset for asset in fixture.assets()} + still = by_uri[str(fixture.stills / "photo.webp")] + assert (still.frame_index, still.frame_timestamp) == (None, None) + frames = sorted( + (asset for asset in by_uri.values() if asset.frame_index is not None), + key=lambda asset: asset.frame_index or 0, + ) + assert [frame.frame_timestamp for frame in frames] == [0.0, 0.1] + fixture.close() + + +def test_a_truncated_animation_leaves_no_assets_and_no_blobs(tmp_path: Path) -> None: + """The refused-file invariant holds for a file that would have been many + assets: the decode completes before anything is stored.""" + fixture = Fixture(tmp_path) + cut = fixture.stills / "cut.gif" + _write_animated_gif(cut, frames=6) + whole = cut.read_bytes() + cut.write_bytes(whole[: len(whole) - len(whole) // 3]) + source = fixture.sources.register_images(fixture.project.id, fixture.stills) + + result = fixture.ingest.ingest(source.id) + + assert (result.created, result.failed) == (0, 1) + (failure,) = result.failures + assert failure.kind is IngestFailureKind.CORRUPT + assert fixture.assets() == [] + assert fixture.blob_count() == 0 + fixture.close() diff --git a/tests/kernel/test_video_processor.py b/tests/kernel/test_video_processor.py index f55ba661..d0d8feeb 100644 --- a/tests/kernel/test_video_processor.py +++ b/tests/kernel/test_video_processor.py @@ -674,3 +674,19 @@ def test_injecting_a_video_processor_leaves_the_other_ports_alone(tmp_path: Path ) as workspace: assert isinstance(workspace.image_processor, PillowImageProcessor) assert workspace.root == (tmp_path / "ws").resolve() + + +@pytest.mark.parametrize("suffix", [".mov", ".avi", ".webm", ".mkv"]) +def test_common_containers_probe_and_extract(tmp_path: Path, suffix: str) -> None: + """The no-gate design means breadth is a doc claim; this turns it into a test. + + An ffmpeg upgrade that drops a container goes red here instead of in a + user's ingest report. + """ + generated = write_video(tmp_path / f"clip{suffix}") + + metadata = FfmpegVideoProcessor().probe(generated.path) + frames = _frames(generated.path, fps=1.0) + + assert (metadata.width, metadata.height) == (generated.width, generated.height) + assert len(frames) == round(generated.duration_seconds) diff --git a/tests/server/test_ingest.py b/tests/server/test_ingest.py index 4db2e5aa..3b580eda 100644 --- a/tests/server/test_ingest.py +++ b/tests/server/test_ingest.py @@ -14,6 +14,7 @@ from __future__ import annotations +import io import threading from collections.abc import Iterator from pathlib import Path @@ -22,6 +23,7 @@ import pytest from fastapi.testclient import TestClient +from PIL import Image from tests.fixtures.media import write_corrupt_video, write_image, write_video from tests.server._api import api_client from tests.server._jobs import JOIN_TIMEOUT, InlineDispatcher, ManualDispatcher @@ -115,6 +117,48 @@ def launch(client: TestClient, source: str, **body: Any) -> Any: return client.post(f"/sources/{source}/ingest-jobs", json=body or None) +def _part(name: str, payload: bytes, media_type: str) -> tuple[str, tuple[str, bytes, str]]: + return ("files", (name, payload, media_type)) + + +def test_uploaded_wide_formats_ingest_through_the_same_door( + client: TestClient, project: str, runner: InlineDispatcher +) -> None: + """An upload is staged to disk and read by the ordinary ingest, so an HEIC + arrives as a JPEG asset and an animated GIF as PNG frames without any route + or client knowing a format was involved.""" + heic = io.BytesIO() + Image.new("RGB", (32, 24), (120, 60, 30)).save(heic, format="HEIF") + shades = [Image.new("L", (16, 12), 30 + i * 40) for i in range(3)] + gif = io.BytesIO() + shades[0].save(gif, format="GIF", save_all=True, append_images=shades[1:], duration=100) + + source = registered_images( + client, + project, + _part("photo.heic", heic.getvalue(), "image/heic"), + _part("anim.gif", gif.getvalue(), "image/gif"), + ) + started = launch(client, source) + assert started.status_code == 202, started.text + runner.wait() + + polled = client.get(f"/ingest-jobs/{started.json()['id']}").json() + assert polled["state"] == "completed" + assert polled["failures"] == [] + assert (polled["processed"], polled["total"]) == (2, 2) + + # The wire deliberately publishes no path, so the claim is read off what it + # does publish: the decomposed frames in order as PNG, the converted still + # beside them as JPEG. + body = client.get(f"/batches/{polled['batch_id']}/assets").json() + seen = sorted( + ((asset["format"], asset["frame_index"]) for asset in body["items"]), + key=lambda pair: (pair[0], -1 if pair[1] is None else pair[1]), + ) + assert seen == [("jpeg", None), ("png", 0), ("png", 1), ("png", 2)] + + # --- the acceptance walk ----------------------------------------------------- diff --git a/uv.lock b/uv.lock index 1bc34ed4..adb44df9 100644 --- a/uv.lock +++ b/uv.lock @@ -409,7 +409,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "python_full_version < '3.15' and sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, @@ -440,43 +440,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -581,7 +581,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, @@ -589,7 +591,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, @@ -597,7 +601,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, @@ -605,14 +611,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, @@ -620,7 +630,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, @@ -1334,7 +1346,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1373,7 +1385,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1385,7 +1397,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1415,9 +1427,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1429,7 +1441,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1610,6 +1622,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] +[[package]] +name = "pillow-heif" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/6ab7a469ced899fbdbbc27b00067e4da02c21ff1ce7e9c6110f14e8fea6c/pillow_heif-1.5.0.tar.gz", hash = "sha256:16b11a37b762ff42da2d36527bb5cb14bd9194c24c389ee911155d6e23c53065", size = 17114105, upload-time = "2026-07-22T14:27:50.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/2c/6135e0f6d76d5c5e31dbeb8ea50a1c4f2f872122055bf5c2db714a772c1b/pillow_heif-1.5.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:115d538c6b4ebbf13dcbadfa5215ffe56365a0de10849ae3a75329d36bc04ee6", size = 4743136, upload-time = "2026-07-22T14:26:49.693Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/96c6143c006fa6188898c19cde2d131ca7f32f7f2d720514912d56cd29df/pillow_heif-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f14cde17b9288a9cbabc59c98a5928c54150ab98e3577ef2e4fd9f77f524b61", size = 4264318, upload-time = "2026-07-22T14:26:51.163Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/05177861cec40bf8645e5194d5bac8a88b409f4c63a447298df7ecc2ef67/pillow_heif-1.5.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a0e2b2966418cd2fb9a2b8427f60516980e3c9146ddf0a00e443f254d7ae291", size = 6346272, upload-time = "2026-07-22T14:26:52.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/70/5f9c81a022339914ea8b8370c284e3a4327d21dcf4242b9461baa44047dc/pillow_heif-1.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c8032c6c2bddde009e703e045a0e17f7724925403982821ddd1a8d78559f28d", size = 5601930, upload-time = "2026-07-22T14:26:54.412Z" }, + { url = "https://files.pythonhosted.org/packages/68/91/ce4fbd148e76daeb147a50ed761dc43d7f7d4b95c4796182b6f36e408afa/pillow_heif-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36742b4f3f5bee57a90d9492b17ad0336b95ab7fc358172cfae144505433ac94", size = 7374394, upload-time = "2026-07-22T14:26:56.061Z" }, + { url = "https://files.pythonhosted.org/packages/20/5c/32af6c25df15783a068b524648a83f854abddcd681155b6320737875c144/pillow_heif-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5533c061cfef1ed508137bb7303093ef7b3b1ea045c70a778554f6d551ed540", size = 6633463, upload-time = "2026-07-22T14:26:57.722Z" }, + { url = "https://files.pythonhosted.org/packages/eb/14/0a73f6d2605a20d32bbf7d20ee033d103b49215957d0330f09c64b3d0e19/pillow_heif-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:9aa2cdc94d8d0b26dd3c16c3421273eb394c24e708899d8740dc314904ea0453", size = 6520223, upload-time = "2026-07-22T14:26:59.31Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2c/102cfc58546ce2c00c312174ebc14d89efae8670f5c4f3c6bbe2e4f23197/pillow_heif-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:901121ed12b15bd1451e70ba7fca0233990ba958ec9c6f8751838402e69e1078", size = 3811009, upload-time = "2026-07-22T14:27:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/502ae6bebaf1fc48850f690858040932fb6fe0bd0de25b5e715df2517d14/pillow_heif-1.5.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b736040d566271143c09bf87967549daf3d3090e9b4283ca2076c3383ab141d8", size = 4743134, upload-time = "2026-07-22T14:27:02.444Z" }, + { url = "https://files.pythonhosted.org/packages/e3/4f/a9f2e1525655c93e9b72cec3792a41f7d732222dad1d329fa04c227cbe28/pillow_heif-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d7c9dd6f490c433267ab9b66b63961e59a4c04c9d0e38ba66e4b6664d5e7d8", size = 4264310, upload-time = "2026-07-22T14:27:04.13Z" }, + { url = "https://files.pythonhosted.org/packages/7f/27/5f901b1d69d641b3b4e99aae6b3cbebe0cfa53188517ca7c79f86f86d4eb/pillow_heif-1.5.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdada8d4cd5886c9ec5454b907290c4cbd420809afbf2c251120f73ba2ee1d12", size = 6346291, upload-time = "2026-07-22T14:27:05.547Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7e/72c9fd99a74c82b08df3690077141bd16f1c9c7a3b3cd1c30e6fec4eb4a5/pillow_heif-1.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e87c9a51ee973b5c1896beba74ddf9b58417f5b8a52d021b493f3ef6257c5f90", size = 5601975, upload-time = "2026-07-22T14:27:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/82/ff/be2b42f3aa4ec040fca5a2bab8bac6b611d39b7a3c69c185c85ae8537a6d/pillow_heif-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ff580e1453bcf3d4965dbbfa00bfabef7784a51214413a344ce2eb652a81a987", size = 7374392, upload-time = "2026-07-22T14:27:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/b7/34/047aba6e860dbe8e32dc948d5ed707c921168769d34379b24956322fb4c3/pillow_heif-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cfa3761cf4fe43ee4b909963f87f05bac925d7bcff7016802f02a26574fb11e", size = 6633487, upload-time = "2026-07-22T14:27:10.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/0e/4b463d7183ee45624fc822f22501feb4b772e5667377d2f981a31c08310a/pillow_heif-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:139f95d1052954804b349089deb91c55ecfe344a7a21ef46c961778a45a17589", size = 6520219, upload-time = "2026-07-22T14:27:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/e69cd5b9aea6e75e361d3d3a98201bb7eab31455b07a5b1fc8ec570ab2c1/pillow_heif-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:c6156bee6f69797a782d8ca373536dac4a7e0d4f793c9842f6bd09669fe4da0f", size = 3811013, upload-time = "2026-07-22T14:27:14.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/51/9b5a9352b86b7d82ad62969a84e3b4f998f6b5dc0320cec3e2d54d74d754/pillow_heif-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8d96783ca1648ba8384e66b888128ca3a48d769c0e3593acf84c40ce0c791629", size = 4743156, upload-time = "2026-07-22T14:27:15.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/10/dc89bec0edf37df5d7609d76ce125a2f8d1e901b2d34944d64d05f497bef/pillow_heif-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5c50626e77510f38cb11aacb3b4bba56eff17ce39a9d85b7ee210bd81387a083", size = 4264368, upload-time = "2026-07-22T14:27:17.198Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/837e9077784df4a0ba69d3e76e9f29a4e52929c84f0e1b0d29a13437a5cc/pillow_heif-1.5.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fb15135733626ee3fd4d7104bde1e54d4370f9f8b697a5d239b8b552b73fc7b", size = 6346432, upload-time = "2026-07-22T14:27:18.55Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/42f392c0513bf351ba2bca1350c457b0d7bd94f12d0e5fa74d7a9deae336/pillow_heif-1.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:646ddad98c8f9c5e637dbb84b6b7db905e565994e9fd6a496a36adff36ae28b3", size = 5602088, upload-time = "2026-07-22T14:27:20.112Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4d/8070974a2e695f83fb10883b7d83c3eda635561f99993623bf111a55a984/pillow_heif-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9803c1291c7c598134f16016a071ec8d9dce268ab1806c8541450c4e3972893", size = 7374543, upload-time = "2026-07-22T14:27:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/9a5abfb163b0fb44a4f624146de142ba4887e5011f965442af2c997db6bc/pillow_heif-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ad925299422d4592e4b5344c03e25b27dc7a5130447603a89404d7d138b746c", size = 6633558, upload-time = "2026-07-22T14:27:23.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bc/7bae1c1efcec25dfdca950fe1a10cd723c544269db73203019e965394015/pillow_heif-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:cfd5f8a8a7b65f2ded217c5bde4dd9365b6b67187657a121b05224841a1b92c0", size = 6703549, upload-time = "2026-07-22T14:27:25.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/8d/fff074683f96eb004d7159228d2fc9e2e9fe8afd899d53aa695c622487d8/pillow_heif-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:6759febf63ea31fd00756e4a828cd643d0b9f98c93f5814b0ce5baa036da50e4", size = 4017234, upload-time = "2026-07-22T14:27:27.443Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/fa5727a62b2fc37b611c7a172fc10c6719530d27bc9126de8456fac69346/pillow_heif-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e2c3f47eac33d8368ea5779eb7bd8c736e7048b5897474d7e1799c073c9505e3", size = 4744030, upload-time = "2026-07-22T14:27:29.047Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/afaccf55774c1084e5beba83f0ac201d315096f6a95d003696376184a7a3/pillow_heif-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d0881c82752d676fbe054cb0258100790ead94946fbb075ffa041bb9ae579b11", size = 4265061, upload-time = "2026-07-22T14:27:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/c90f9fffd5cb56e213753f22a9a955525d5ad99816481ae2392ab6c7ccd8/pillow_heif-1.5.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d5810606f70d3432b55987a5e44aa26fb7598fea565d8645b017f7ed935f5e99", size = 6351671, upload-time = "2026-07-22T14:27:32.231Z" }, + { url = "https://files.pythonhosted.org/packages/74/b3/e15933817e71b9fcd8865e3b64171995ed26f5ca329bf4d03f3571213c52/pillow_heif-1.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e737d326410e733868566c045f651b5371086a4ec294c40d39e129481d024bef", size = 5606028, upload-time = "2026-07-22T14:27:34.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/2665f6c781b90bb5a25595c58ede1955125f0ad2c7d81bf3c153c9e9c9d8/pillow_heif-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:522719e5dd56eae6f9f48828c08eefe0e5c02dd92f5a07780e6f3c04bdbcf880", size = 7379667, upload-time = "2026-07-22T14:27:35.883Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/0f0487b9f6cb1f5dff629b42ad08a7ae1997886048c4c2671e17079ad5d6/pillow_heif-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f1d281b2efee954ef78c0a473a41498d0548a32290d7250b4879ddd2480dffb8", size = 6637581, upload-time = "2026-07-22T14:27:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b7/c65adbcab1b1c99c886c55b97b144b3092c432e7b0fedb91501c479cebe4/pillow_heif-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:65b3d619cea2d1f758cd039cddb331cb44d63768c355606ea9100cfee4421eb4", size = 6704174, upload-time = "2026-07-22T14:27:39.32Z" }, + { url = "https://files.pythonhosted.org/packages/17/53/2a52f64f399717adae560068e9b0ae12ae3e43be2c64991e246af5aedc3f/pillow_heif-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:508fc9dd8fb4df933b666c30b60b0930270d9e072fcd90b75990166050e44656", size = 4017728, upload-time = "2026-07-22T14:27:41.102Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2616,6 +2671,7 @@ dependencies = [ { name = "mcp" }, { name = "packaging" }, { name = "pillow" }, + { name = "pillow-heif" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -2658,6 +2714,7 @@ requires-dist = [ { name = "mcp", specifier = ">=2.0.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "pillow", specifier = ">=11.0" }, + { name = "pillow-heif", specifier = ">=1.5.0" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "python-multipart", specifier = ">=0.0.9" },