Compare commits

..
12 Commits
Author SHA1 Message Date
Victor Kuznetsov f020115ca2 Release 0.39.1 2026-09-09 18:51:48 -07:00
Victor Kuznetsov 62be1ef456 Drop third-party apt sources before the video job's ffmpeg install
The video full-clip job needs ffmpeg from Ubuntu's own repositories, but its
apt-get update refreshed every source on the runner image, so a broken
third-party index failed the job twice on 2026-09-09: google-chrome.list
served a Hash Sum mismatch (bc1428ab... against 233e56de...) and both the run
and its rerun died at Install ffmpeg with ten otherwise-green jobs around
them. Remove every non-Ubuntu source definition from
/etc/apt/sources.list.d before updating, so no vendor's broken index can
fail a step that never needed it. The only apt-get in the workflows.

pre-commit: 1) maintain.sh - exit 2 at the uv-secure gate, the three triaged
no-fix advisories in docs/development.md (rechecked 2026-09-09 against PyPI);
ruff check+format clean, pyright src/ 0 errors, 2368 passed 3 skipped run
separately, same gate run as the preceding commit with no post-gate edit
between them; 2) /simplify - single pass, no fan-out available, no findings;
3) docs sync - grepped apt-get/google-chrome/Install ffmpeg across docs/,
README, .claude/rules/, tests/: no other references, the rationale lives in
the step's comment; 4) CLAUDE.md - audited against the structure rule, no
additions, revise-claude-md: no additions
2026-09-09 18:27:22 -07:00
Victor Kuznetsov 4d7c2b458e Record MIT-0 as ClawHub's platform constant, not a listing error
The listing's MIT-0 next to the repository's Apache-2.0 was documented as a
drift to repair in the ClawHub listing itself. It is not repairable and not
drift: ClawHub's own client pins the platform skill license
(dist/schema/license.js sets PLATFORM_SKILL_LICENSE = "MIT-0", and the
version schema accepts only "MIT-0" or null), so every skill published there
is offered under MIT No Attribution. Publishing to ClawHub means offering
this skill on those terms in addition to Apache-2.0; the field is not
editable and the mismatch must not be read as drift.

pre-commit: 1) maintain.sh - exit 2 at the uv-secure gate, the three triaged
no-fix advisories in docs/development.md (rechecked 2026-09-09 against PyPI);
ruff check+format clean, pyright src/ 0 errors, 2368 passed 3 skipped run
separately; 2) /simplify - single pass, no fan-out available, prose-only
change, no findings; 3) docs sync - MIT-0/ClawHub grepped across docs/,
README, .claude/rules/, skills/: only release-and-distribution.md describes
the publish flow and stays accurate, the audit doc row is a dated snapshot,
no refs to update; 4) CLAUDE.md - audited against the structure rule, no
additions, revise-claude-md: no additions
2026-09-09 18:27:08 -07:00
Victor Kuznetsov 96b8ab9ae2 Let the HEIF container, not the blanked tag, decide the orientation carry
pillow-heif's opener rewrites the EXIF orientation to 1 and stashes the real
value in info["original_orientation"], while libheif turns the raster on decode
exactly when the container declares an effective irot/imir transform. A camera
HEIC (stored raster, EXIF-only rotation) and a re-encoded one (rotation baked
as irot) both decode with Image.size matching the raster and the tag blanked,
so _read_display_tags dropped the turn for both (issue #105): a camera HEIC
went through erase and came out sideways with nothing on it saying so.

For HEIF the caller's orientation_applied claim is now ignored the way it is
for TIFF, and the container decides: isobmff.heif_transform_applied scans
meta/iprp/ipco for an irot with a non-zero angle or any imir, the orientation
comes from original_orientation, and a transform-bearing file drops the tag.
AVIF keeps the generic path: this plugin build neither blanks its tag nor
applies the transform there, and a camera-style AVIF's orientation stays
unreachable from Pillow (recorded in known-limitations).

Tests build the camera fixture without exiftool: a pillow-heif-written file
with its irot angle neutralized to the identity behaves like the reporter's
exiftool-built camera file. Mutation-checked in both directions: forcing the
detector either way fails exactly the tests whose property broke.

pre-commit: 1) maintain.sh - exit 2 at the uv-secure gate, the three triaged
no-fix advisories in docs/development.md (rechecked 2026-09-09: accelerate
1.15.0 and lightning 2.6.5 still carry no fix); ruff check+format clean,
pyright src/ 0 errors, 2368 passed 3 skipped run separately; 2) /simplify -
single pass, no fan-out available, one duplicate test helper inlined, no
other findings; 3) docs sync - heif_transform_applied, original_orientation,
irot, imir live only in module-internals.md and known-limitations.md, both
updated, no other refs; 4) CLAUDE.md - audited against the structure rule,
no additions; revise-claude-md: no CLAUDE.md changes
2026-09-09 18:02:05 -07:00
Victor Kuznetsov 5b30f0169a Merge pull request #107 from DKalimist/fix/heif-and-tiff-orientation
Read a TIFF source's stored size from its IFD, not from Image.size
2026-09-09 17:16:34 -07:00
Victor Kuznetsov 003dabfae4 Let the TIFF geometry override a caller's raw-decode claim
Both readers in the imread stack apply the TIFF orientation tag (cv2's
libtiff path under IMREAD_UNCHANGED too, and Pillow's TIFF plugin,
identically), so the visible path's blanket orientation_applied=False
carried the tag onto an already-turned raster. _read_display_tags now
ignores the explicit claim for TIFF and decides on the IFD's stored size,
which also keeps a hypothetical non-turning build correct: a stored-shape
raster still carries the tag. Guarded at the api seam, not just imwrite.

pre-commit: 1) maintain.sh - exit 2 at the uv-secure gate, the three
triaged no-fix advisories in docs/development.md (rechecked 2026-09-08);
ruff check+format clean, pyright src/ 0 errors, 2363 passed 3 skipped
run separately; 2) /simplify - single pass, no fan-out available, nothing
to fix (api-state plumbing rejected as disproportionate); 3) docs sync -
identifiers live only in docs/module-internals.md, updated, no other
refs; 4) CLAUDE.md - audited against the structure rule, no additions,
revise-claude-md: no additions
2026-09-09 12:56:14 -07:00
Victor Kuznetsov 4c090c5ae2 Merge main into fix/heif-and-tiff-orientation
Resolve image_io.py and test_image_io.py the same way the rebased
adaptation branch does, so the branch carries the author's original
commit 1cfdf19 on top of current main.
2026-09-09 12:56:14 -07:00
Victor KuznetsovandClaude Opus 5 8c025985f7 Correct the ClawHub issue state to closed
The previous commit recorded openclaw/clawhub#3643 as still open. It was
closed as completed at 17:29 UTC, minutes before that commit landed, so the
claim was already false when it was written: the state was read earlier in
the session and never re-read against the tracker.

The note now says the reporter closed it once 1.0.5 published, that nothing
upstream was confirmed fixed, and that this was the fifth report of the same
shape, so a future stalled submission gets a fresh report rather than an
assumption that the pipeline is repaired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrJja6MnncVhyWnmXPN4GM
2026-09-09 10:38:10 -07:00
Victor KuznetsovandClaude Opus 5 5f1dea95f4 Record the ClawHub listing as live and correct its blocker note
docs/agent-skill.md still described the ClawHub surface as blocked: 1.0.3
and 1.0.4 stuck in pending.publication, with an instruction not to bump the
skill version until openclaw/clawhub#3643 resolved. The registry disagrees.
1.0.5, submitted by the clawhub job during the 0.39.0 release, is public:
clawhub search returns the row, and the public API reports tags.latest
1.0.5 with a single published version, which is also how the two earlier
submissions are shown never to have gone public. They were bypassed by the
next submission rather than recovered, and the pipeline issue stays open.

The note now says to read the catalog rather than the job log, since a
green job proves only a submission. It also records that the job fires on a
published release alone, so the 1.0.6 bump that landed after 0.39.0 reaches
ClawHub with the next release and must not be published by hand, and that
the listing carries MIT-0 where SKILL.md declares Apache-2.0, which the
publish command has no flag to set.

The awesome-openclaw entry loses its ClawHub prerequisite and stays
deferred on adoption alone: the listing reports no installs in 60 days.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrJja6MnncVhyWnmXPN4GM
2026-09-09 10:31:56 -07:00
Victor Kuznetsov 00d9771c6a Raise the skill's CLI floor to cover its 0.39.0 references
commands.md names liblib_pill and the auto pipeline routing OpenAI
provenance to qwen-zimage, both of which only exist since CLI 0.39.0,
while probe.py kept MIN_CLI_VERSION at 0.38.0: a 0.38.0 install would
pass the probe's currency check and then reject the very mark key and
routing the skill hands it. The references now carry the established
(since CLI X.Y.Z) annotations and the floor rises to 0.39.0, per
docs/agent-skill.md.

The floor was hand-synced against prose only a person could read, so it
drifted exactly the way its own comment warns about, twice before. A new
guard derives the floor's lower bound from every (since CLI X.Y.Z)
annotation in the skill tree and fails when the floor lags the newest
one; combined with the existing package-version ceiling, the floor is
now pinned from both sides. Skill version 1.0.6 in SKILL.md, plugin.json,
and marketplace.json.

pre-commit: 1) maintain.sh - exit 2 (uv-secure on the documented
lightning/accelerate blocks, unchanged lock; core checks separately:
C2PA sync current, Ruff 1 fix + 1 reformat folded into this commit,
Pyright src/ 0 errors, 2361 passed 3 skipped); 2) /simplify -
single-pass four angles, clean; 3) docs sync - grepped MIN_CLI_VERSION
and since CLI over docs/ and rules/: both mentions stay accurate;
4) CLAUDE.md - improver grade A earlier this session, revise: no
additions
2026-09-08 21:31:25 -07:00
Victor Kuznetsov de03748849 Read the flat single-artifact layout in release verification
download-artifact@v8 extracts the only pattern-matched artifact directly
into the target path, without the artifact-name directory the resolve
step assumed, so every workflow_run verification died at
"No distributed release identity found" before checking a single
surface. The directory-name attempt check now applies only when the
artifact directory exists; a flat identity is still pinned by the
run-id and attempt-eligibility asserts. The workflow_run path of
5c6588b had never executed: v0.38.0's run failed on later checks and
the surfaces were confirmed by a manual dispatch.

The new test drives the real workflow script against the flat layout
and reproduced the production failure before the fix.

pre-commit: 1) maintain.sh - exit 2 (uv-secure on the documented
lightning/accelerate blocks, unchanged lock; core checks separately:
C2PA sync current, Ruff clean with no rewrites, Pyright src/ 0 errors,
2360 passed 3 skipped); 2) /simplify - single-pass four angles, clean;
3) docs sync - grepped distributed-release over docs/: the one mention
stays accurate; 4) CLAUDE.md - improver grade A earlier this session,
revise: no additions
2026-09-08 20:30:59 -07:00
Diego.F 1cfdf192d8 Read a TIFF source's stored size from its IFD, not from Image.size
`_read_display_tags` drops the orientation when the raster no longer has the
source's stored dimensions, which is what stops an upright decode from being
tagged into a second rotation. It read those dimensions off `Image.size`.

That is not the stored size for TIFF: Pillow's TIFF reader applies the EXIF
orientation itself and reports the upright one. For a transposing orientation
the comparison was upright-against-upright, so it matched and the tag rode
along onto pixels that were already turned. `erase` on a 96x64 TIFF tagged 6
returned a 64x96 PNG carrying Orientation 6, and a viewer turned it again.

The TIFF IFD keeps the real geometry in ImageWidth/ImageLength, and no other
Pillow plugin exposes `tag_v2`, so every other container falls back to
`Image.size` and is byte-for-byte unaffected.

Also corrects `_pil_read`'s "No EXIF auto-rotation, matching cv2": Pillow's
TIFF reader rotates and cannot be told not to. Coming back upright is the
useful outcome; carrying the tag on top of it was the defect.
2026-09-08 00:54:22 +02:00
18 changed files with 423 additions and 41 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
"version": "1.0.5"
"version": "1.0.6"
},
"plugins": [
{
+9 -1
View File
@@ -94,7 +94,15 @@ jobs:
with:
python-version: "3.12"
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install --yes ffmpeg
# Third-party apt sources baked into the runner image (google-chrome et
# al.) can publish a broken index and fail `apt-get update`, while this
# job only needs ffmpeg from Ubuntu's own repositories: drop every
# non-Ubuntu source definition before updating.
run: |
sudo find /etc/apt/sources.list.d -mindepth 1 -maxdepth 1 \
! -name 'ubuntu.sources' -delete
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Run full-clip video test
+6 -2
View File
@@ -59,9 +59,13 @@ jobs:
import json, os
from pathlib import Path
identities = []
for path in Path("distribution-identities").glob("*/release-identity.json"):
for path in Path("distribution-identities").rglob("release-identity.json"):
item = json.loads(path.read_text(encoding="utf-8"))
assert path.parent.name == 'distributed-release-' + item['run_attempt'], "Artifact attempt mismatch"
# download-artifact@v8 extracts the only matched artifact directly
# into the path, with no artifact-name directory; several matches
# each get one, so the attempt is name-checked only in that layout.
if path.parent.name.startswith('distributed-release-'):
assert path.parent.name == 'distributed-release-' + item['run_attempt'], "Artifact attempt mismatch"
identities.append(item)
assert identities, "No distributed release identity found"
assert all(item["run_id"] == os.environ["DISTRIBUTION_RUN_ID"] for item in identities), "Distribution run mismatch"
+32 -18
View File
@@ -54,10 +54,11 @@ Do these in order after the skill is on the default branch.
brand-new skills ("give your skill time to mature and gain users before
submitting"). Re-attempt after the skill has real installs and a
release that ships it (0.38.0+).
- `VoltAgent/awesome-openclaw-skills`: DEFERRED. Entries are indexed via
clawskills.sh, so the skill must be published to ClawHub first, and the
list asks contributors not to submit skills "created 3 hours ago".
Re-attempt after the ClawHub publish plus visible adoption.
- `VoltAgent/awesome-openclaw-skills`: DEFERRED on adoption only. Entries
are indexed via clawskills.sh, and the ClawHub prerequisite in step 6 is
now met. The list still asks contributors not to submit skills "created
3 hours ago", and `clawhub search` reports 0 installs over 60 days.
Re-attempt once the listing shows real installs.
5. **Claude community marketplace.** Form at
[platform.claude.com/plugins/submit](https://platform.claude.com/plugins/submit)
(individuals) or the claude.ai directory form (Team/Enterprise). Validate
@@ -68,20 +69,33 @@ Do these in order after the skill is on the default branch.
official Anthropic marketplace is
curated and has no application.
6. **ClawHub.** `clawhub login` then `clawhub skill publish` from
`skills/remove-ai-watermarks/`. PUBLISHED 2026-09-07 (1.0.3) and
republished 2026-09-08 (1.0.4, identical content) — both sit in
`pending.publication` with CLEAN moderation, succeeded scan frameworks,
and null ClawScan / VirusTotal / Skillspector verdicts: a recurring
ClawHub pipeline defect tracked as openclaw/clawhub#3643 (prior reports:
#3624, #3466, #3351, #3284). Release-time publishing is automated through
the `clawhub` job in `distribute.yml` (see
[release-and-distribution.md](release-and-distribution.md)); do not bump
the version again to re-queue until the issue resolves, and re-check the
listing before any listing-dependent step such as the awesome-openclaw
submission. The public catalog already has an add-watermark
skill; this is the removal counterpart, framed as the user's own
content. ClawHub disallows deception, impersonation, and fake-engagement
install loops.
`skills/remove-ai-watermarks/`. LISTED 2026-09-09: version 1.0.5, submitted
by the `clawhub` job in `distribute.yml` during the 0.39.0 release, is
public. A green job proves only a submission, so read the catalog itself:
`clawhub search remove-ai-watermarks` returns the row and
`https://clawhub.ai/api/v1/skills/remove-ai-watermarks` reports
`tags.latest` 1.0.5 with a single published version. The earlier 1.0.3 and
1.0.4 submissions never became public and do not appear in that version
list, so they were bypassed rather than recovered. The issue that tracked
them, openclaw/clawhub#3643, was closed by the reporter on 2026-09-09 once
1.0.5 went through; nothing upstream was confirmed fixed, and it is the
fifth report of this shape (#3624, #3466, #3351, #3284), so treat a stalled
submission as recurring and file a fresh one rather than assuming the
pipeline is repaired. Release-time publishing is automated (see
[release-and-distribution.md](release-and-distribution.md)), and the job
fires only on a published GitHub Release: a skill version bumped after a
release, as 1.0.6 was, reaches ClawHub with the next one. Do not publish by
hand to close that gap. The listing records the license as MIT-0 while
`SKILL.md` declares Apache-2.0, and that is not a listing error to repair:
MIT-0 is a platform-wide constant in ClawHub's own client
(`dist/schema/license.js`, and its version schema accepts only `"MIT-0"`
or null), so every skill published there is offered under MIT No
Attribution. Publishing to ClawHub means offering this skill on those
terms in addition to the repository's Apache-2.0; do not try to edit the
field, and do not read the mismatch as drift. The public catalog
already has an add-watermark skill; this is the removal counterpart, framed
as the user's own content. ClawHub disallows deception, impersonation, and
fake-engagement install loops.
7. **OpenAI ChatGPT / Codex plugin directory.** Skills-only plugins are a
supported submission type at
[platform.openai.com/plugins](https://platform.openai.com/plugins). This is
+5 -1
View File
@@ -388,7 +388,11 @@ OpenCV does not decode these formats in the project. `image_io.imread` falls
back to Pillow with `pillow-heif` when the `heif` extra is installed alongside
a pixel feature. The
default metadata path scans these containers without that plugin. A corrupt or
truncated file may still fail to decode.
truncated file may still fail to decode. A camera-style AVIF's EXIF
orientation is exposed by this plugin neither through `getexif()` nor
`info["original_orientation"]`, so the display-tag carry cannot re-declare it
(HEIC can; see the display-tags contract in
[`module-internals.md`](module-internals.md)).
### Some metadata removal requires ffmpeg
+19 -1
View File
@@ -2148,7 +2148,25 @@ Contracts:
pixels on save. Callers that know the decode state pass
`orientation_applied=False` for raw pixels and `True` for a transposed
raster. Dimensions alone cannot distinguish mirrors or 180-degree rotation.
The omitted value retains the compatibility shape heuristic. Display tags
TIFF and HEIF are the container exceptions on the explicit claim. Every
reader in the stack (cv2's libtiff path under `IMREAD_UNCHANGED` too, and
Pillow's TIFF plugin, identically) applies the TIFF orientation tag on
decode, so `_read_display_tags` ignores the claim for TIFF and decides on
the IFD's stored `ImageWidth`/`ImageLength` against the raster, via
`_stored_size` -- `Image.size` already reports the upright geometry there,
which is what let a turned raster be tagged into a second rotation
(issue #106). HEIF blanks the tag instead: pillow-heif's opener rewrites
the EXIF orientation to 1 (the real value waits in
`info["original_orientation"]`), and libheif turns the raster exactly
when the container declares an effective `irot`/`imir` transform
(`isobmff.heif_transform_applied` scans the `meta`/`iprp`/`ipco`
property store), so `Image.size` matches the raster in both cases and the
shape heuristic has nothing to compare. A camera HEIC (stored raster,
EXIF-only rotation, issue #105) re-declares its turn on the output; a
re-encoded one whose writer baked `irot` decodes upright and is not
tagged again. AVIF keeps the generic path: this plugin build neither
blanks its tag nor applies the transform there. The omitted value retains
the compatibility shape heuristic. Display tags
are captured before encoding, including when source and destination match.
The metadata strip is the other half of that contract: Pillow cannot hold 16-bit
+1 -1
View File
@@ -4,7 +4,7 @@
# on it, including the ComfyUI node package. The console script below carries
# the same weight, since users have it on PATH.
name = "remove-ai-watermarks"
version = "0.39.0"
version = "0.39.1"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remove-ai-watermarks",
"displayName": "Remove AI Watermarks",
"version": "1.0.5",
"version": "1.0.6",
"description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.",
"author": {
"name": "wiltodelta",
+1 -1
View File
@@ -5,7 +5,7 @@ license: Apache-2.0
compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip.
metadata:
author: wiltodelta
version: "1.0.5"
version: "1.0.6"
homepage: https://raiw.cc
repository: https://github.com/wiltodelta/remove-ai-watermarks
---
@@ -26,7 +26,8 @@ Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`,
`--mark auto` removes every selected match. `microsoft` is the top-right AI
badge, separate from the invisible InvisMark watermark on the same vendor's
images. `jimeng_pill` is a weak detector and needs corroboration. The compact
`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark.
`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark
(since CLI 0.39.0).
```bash
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
@@ -88,8 +89,8 @@ run on.
Pipeline profiles: `qwen-zimage` (the default), `sdxl-zimage`, `chroma-zimage`,
`auto`. All are CUDA-only and all install from the same `qwen-zimage` extra.
`auto` picks the engine from the file's own provenance -- chroma-zimage for
Microsoft, qwen-zimage for OpenAI, Google, Meta and unknown -- so prefer it
over naming an engine when the user has no reason to care. `sdxl-zimage` is the
Microsoft, qwen-zimage for OpenAI (since CLI 0.39.0), Google, Meta and unknown
-- so prefer it over naming an engine when the user has no reason to care. `sdxl-zimage` is the
heavier alternative and `chroma-zimage` is the Apache-2.0 global stage.
```bash
+8 -7
View File
@@ -24,13 +24,14 @@ from typing import Any
_GPU_MARKERS = ("GPU ", "gpu:")
# Below this, the CLI predates flags and behavior this skill documents. Raise it in
# the same change as any reference that names a flag the previous release lacked:
# 0.37.0 is here because `classify` arrived with it (`--pipeline auto` needed
# 0.36.0; chroma-zimage and the doubao video mark 0.35.0;
# the `microsoft` mark 0.34.0; `--vendor` 0.32.0), and a stale floor told an agent
# its CLI was current right before it typed a value that build rejects.
# tests/test_agent_skill.py pins it against the package version, so it can never
# advertise a release that does not exist.
MIN_CLI_VERSION = (0, 38, 0)
# 0.39.0 is here because the `liblib_pill` mark and `auto` routing OpenAI
# provenance to qwen-zimage arrived with it; 0.38.0 added the receipt gate;
# `classify` needed 0.37.0 (`--pipeline auto` 0.36.0; chroma-zimage and the
# doubao video mark 0.35.0; the `microsoft` mark 0.34.0; `--vendor` 0.32.0),
# and a stale floor told an agent its CLI was current right before it typed a
# value that build rejects. tests/test_agent_skill.py pins it against the
# package version and against every "(since CLI X.Y.Z)" annotation in the skill.
MIN_CLI_VERSION = (0, 39, 0)
# Markers a build without the pixel stack prints. The first is the current CLI's own
# install hint; the rest are what older builds emit before the guard existed, and the
# Homebrew formula ships exactly such a build.
+1 -1
View File
@@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.39.0"
__version__ = "0.39.1"
__all__ = [
"BatchItemResult",
@@ -344,6 +344,68 @@ def is_isobmff(data: bytes) -> bool:
return len(data) >= 8 and data[4:8] == b"ftyp"
# What a HEIF ``meta`` box's first child can be; used to probe whether the box
# carries the ISO FullBox header (children start 4 bytes into the payload) or
# the QuickTime bare form (children start at 0).
_HEIF_META_CHILD_TYPES = frozenset({b"hdlr", b"pitm", b"iinf", b"iprp", b"iloc", b"iref", b"idat"})
def heif_transform_applied(path: str | Path) -> bool:
"""Whether a HEIF/AVIF file declares an effective container transform.
Scans the ``meta`` box's ``iprp``/``ipco`` property store for an ``irot``
box with a non-zero angle or any ``imir`` box. libheif applies those
transforms on decode, and that is the only signal separating a re-encoded
HEIF -- the writer baked the rotation as ``irot`` and the decode comes back
upright -- from a camera-style one that stores the raw raster and carries
the rotation only in its EXIF payload: pillow-heif blanks that tag on open
for both and stashes the real value in ``info["original_orientation"]"
(issue #105). Association through ``ipma`` is not re-derived: a stray
unassociated ``irot`` reads as "transformed", the direction that drops the
tag and keeps today's tagless output rather than risking a double rotation.
A malformed or missing structure reads as "no transform".
"""
try:
with open(path, "rb") as f:
sniff = f.read(8)
if len(sniff) < 8 or sniff[4:8] != b"ftyp":
return False
f.seek(0, 2)
file_size = f.tell()
for _start, end, box_type, payload_off in iter_file_boxes(f, 0, file_size):
if box_type != b"meta":
continue
# ISO serializes ``meta`` as a FullBox, so children start 4 bytes
# into the payload; the QuickTime bare form starts at 0. The form
# whose first child has a known type is the real one.
children = None
for offset in (4, 0):
first = _read_box_header(f, payload_off + offset, end)
if first is not None and first[1] in _HEIF_META_CHILD_TYPES:
children = iter_file_boxes(f, payload_off + offset, end)
break
if children is None:
return False
for _s, iprp_end, iprp_type, iprp_payload in children:
if iprp_type != b"iprp":
continue
for _s2, ipco_end, ipco_type, ipco_payload in iter_file_boxes(f, iprp_payload, iprp_end):
if ipco_type != b"ipco":
continue
for _s3, _prop_end, prop_type, prop_payload in iter_file_boxes(f, ipco_payload, ipco_end):
if prop_type == b"irot":
f.seek(prop_payload)
angle = f.read(1)
if angle and angle[0] & 0x03:
return True
elif prop_type == b"imir":
return True
return False
except OSError:
return False
return False
def scan_c2pa_region(path: str | Path, *, max_total: int = 4 * 1024 * 1024, strict: bool = False) -> bytes:
"""Concatenated payloads of top-level ``uuid`` / ``jumb`` boxes in an ISOBMFF
file, found by seeking past other boxes (``mdat`` etc.) by size.
+63 -2
View File
@@ -29,6 +29,7 @@ from remove_ai_watermarks._internal.constants import PNG_SIGNATURE
if TYPE_CHECKING:
from numpy.typing import NDArray
from PIL.Image import Image as PILImage
log = logging.getLogger(__name__)
@@ -72,7 +73,10 @@ def _pil_read(path: str | Path, flags: int) -> NDArray[Any] | None:
"""Decode via Pillow (HEIC/AVIF and any other Pillow-readable container) into the
cv2 layout ``flags`` implies: grayscale, 3-channel BGR, or BGRA when the source has
alpha and ``IMREAD_UNCHANGED`` was requested. Returns None if Pillow (with the
optional HEIF plugin) still cannot open it. This fallback does not apply EXIF orientation."""
optional HEIF plugin) still cannot open it. This fallback asks for no EXIF
auto-rotation, but a container reader can apply it regardless: Pillow's TIFF
reader always turns the raster upright. That decode state is reconciled where
it matters, in :func:`_read_display_tags`."""
import cv2
import numpy as np
@@ -152,6 +156,8 @@ def _register_heif() -> None:
# AI-provenance tags the metadata strip exists to drop.
_ORIENT_EXIF_TAG = 0x0112 # EXIF ImageIFD.Orientation
_TIFF_IMAGE_WIDTH = 256 # TIFF ImageWidth, the stored raster's columns
_TIFF_IMAGE_LENGTH = 257 # TIFF ImageLength, the stored raster's rows
# Orientations that transpose width and height (the 90-degree rotations). Only these
# can be detected by comparing the raster's shape with the source's stored size;
# the mirror/180 values (2, 3, 4) keep today's tagless behaviour because no cheap
@@ -176,6 +182,23 @@ def _orientation_exif(orient: int | None, *, prefixed: bool) -> bytes | None:
return dumped[6:] if dumped.startswith(b"Exif\x00\x00") else dumped
def _stored_size(im: PILImage) -> tuple[int, int]:
"""The size the container STORES, which is not always ``Image.size``.
Pillow's TIFF reader applies the EXIF orientation itself: it hands back a turned
raster and reports the upright size. Comparing that against a turned raster says
"still in stored orientation" for pixels that are already upright, and the tag
rides along into a second rotation. The TIFF IFD keeps the real stored geometry in
ImageWidth/ImageLength, and no other Pillow plugin exposes ``tag_v2``.
"""
tags = getattr(im, "tag_v2", None)
if tags is not None:
width, height = tags.get(_TIFF_IMAGE_WIDTH), tags.get(_TIFF_IMAGE_LENGTH)
if isinstance(width, int) and isinstance(height, int):
return width, height
return im.size
def _read_display_tags(
source: Path, raster_shape: tuple[int, int], *, orientation_applied: bool | None = None
) -> tuple[bytes | None, int | None]:
@@ -195,6 +218,26 @@ def _read_display_tags(
tag and keeps today's behaviour rather than risk a double rotation. ICC travels
in every case: it describes colour, not geometry. Failures read as "no tags":
the restore is an enhancement, never a reason to fail a write.
TIFF is the exception on the explicit state: every reader in this module's
stack applies the TIFF orientation tag on decode (cv2's libtiff path under
``IMREAD_UNCHANGED`` too, and Pillow's TIFF plugin, identically), so a caller's
blanket ``False`` -- which encodes cv2's JPEG/PNG/WebP contract -- is not true
for TIFF. For TIFF sources the parameter is ignored and the geometry check
decides against the IFD's stored size: a raster that no longer has the stored
dimensions was turned by the decode and must not be tagged again (issue #106).
HEIF is the other exception: pillow-heif's opener blanks the EXIF orientation
on open (the real value waits in ``info["original_orientation"]``), and
libheif turns the raster exactly when the container declares an effective
``irot``/``imir`` transform, so the geometry check has nothing to compare --
``Image.size`` matches the raster in both cases. For HEIF the parameter is
likewise ignored and the container decides (issue #105). AVIF keeps the
generic path: this plugin build neither blanks its tag nor applies the
transform there.
The stored size cannot be read straight off ``Image``: Pillow's TIFF reader
reports the upright one, so it comes through :func:`_stored_size`.
"""
_register_heif()
try:
@@ -205,11 +248,29 @@ def _read_display_tags(
with Image.open(source) as im:
icc = im.info.get("icc_profile")
orient = im.getexif().get(_ORIENT_EXIF_TAG)
stored_w, stored_h = im.size
fmt = im.format
# pillow-heif's opener rewrites the EXIF orientation to 1 and stashes
# the real value here, so `orient` is blanked for every HEIF.
heif_original = im.info.get("original_orientation")
stored_w, stored_h = _stored_size(im)
except Exception:
return None, None
if not (isinstance(icc, bytes) and icc):
icc = None
if fmt == "TIFF":
orientation_applied = None
if fmt == "HEIF":
# The second container whose decode state a caller cannot claim: libheif
# turns the raster on decode exactly when the file declares an effective
# irot/imir transform, and pillow-heif has blanked the EXIF tag either
# way, so the container decides and `original_orientation` supplies the
# value (issue #105). AVIF keeps the generic path: this plugin build
# neither blanks its tag nor applies the transform there.
from remove_ai_watermarks._internal import isobmff
orientation_applied = isobmff.heif_transform_applied(source)
if orient in (None, 1):
orient = heif_original
if orientation_applied is True:
orient = None
elif orientation_applied is False:
+29
View File
@@ -104,6 +104,35 @@ def test_probe_min_cli_version_never_exceeds_the_package_version() -> None:
sys.modules.pop("probe", None)
def test_probe_floor_covers_every_since_annotation() -> None:
"""MIN_CLI_VERSION must be at least the newest named "(since CLI X.Y.Z)" value.
A reference that names a value the previous release lacked has to raise the
floor in the same change; this reads the annotations back out of the skill
tree so a stale floor fails the suite instead of an agent's CLI.
"""
sys.path.insert(0, str(ROOT / "skills" / "remove-ai-watermarks" / "scripts"))
try:
import probe
annotated = []
for path in (ROOT / "skills" / "remove-ai-watermarks").rglob("*"):
if path.suffix not in {".md", ".py"} or not path.is_file():
continue
for match in re.finditer(r"\(since CLI (\d+)\.(\d+)\.(\d+)\)", path.read_text(encoding="utf-8")):
annotated.append(tuple(int(part) for part in match.groups()))
assert annotated, "no (since CLI X.Y.Z) annotations found; the floor guard lost its source"
highest = max(annotated)
assert highest <= probe.MIN_CLI_VERSION, (
f"probe.py MIN_CLI_VERSION {probe.MIN_CLI_VERSION} is below {highest}, the newest "
"(since CLI X.Y.Z) annotation in the skill; a build that old rejects a named value"
)
finally:
sys.path.remove(str(ROOT / "skills" / "remove-ai-watermarks" / "scripts"))
sys.modules.pop("probe", None)
@pytest.mark.parametrize(
"relative",
[
+151
View File
@@ -409,3 +409,154 @@ class TestRemoverWriteCarriesDisplayTags:
with Image.open(out) as im:
assert im.info.get("icc_profile") == icc
assert im.getexif().get(0x0112) is None
class TestTiffOrientation:
"""Pillow's TIFF reader applies the EXIF orientation itself, and reports the
upright size while doing it. A stored-size comparison against ``Image.size``
therefore matches for a raster that is already upright, and the tag is carried
onto it -- a viewer then turns the picture a second time."""
ORIENT = 6 # Rotate 90 CW.
@staticmethod
def _tagged(path: Path, orient: int) -> Path:
"""A 96x64 stored raster carrying ``orient``, written by the container itself."""
import piexif
from PIL import Image
exif = piexif.dump({"0th": {piexif.ImageIFD.Orientation: orient}})
Image.new("RGB", (96, 64), "red").save(path, exif=exif)
return path
@staticmethod
def _out_orientation(path: Path) -> object:
from PIL import Image
with Image.open(path) as im:
return im.getexif().get(0x0112)
def test_a_turned_tiff_decode_is_not_tagged_into_a_second_rotation(self, tmp_path: Path) -> None:
src = self._tagged(tmp_path / "src.tif", self.ORIENT)
raster = image_io.imread(src, cv2.IMREAD_UNCHANGED)
assert raster is not None
# Pillow already applied the rotation: the raster is upright, 64x96.
assert (raster.shape[1], raster.shape[0]) == (64, 96)
out = tmp_path / "out.png"
assert image_io.imwrite(out, raster, display_tags_from=src) is True
assert self._out_orientation(out) is None
def test_the_visible_api_path_does_not_tag_a_turned_tiff(self, tmp_path: Path) -> None:
# The visible write passes orientation_applied=False ("raw decode"), which is
# a lie for TIFF: both readers turn the raster. _read_display_tags must ignore
# that claim for TIFF and let the IFD geometry decide, or a CLI/library user
# gets a turned raster TAGGED for the same turn again (issue #106).
from PIL import Image
from remove_ai_watermarks import api
src = self._tagged(tmp_path / "src.tif", self.ORIENT)
out = tmp_path / "out.png"
_, removed = api.remove_visible(src, out, strip_metadata=False)
assert removed == [] # plain red: no mark, the passthrough write is exercised
with Image.open(out) as im:
assert im.size == (64, 96) # the upright raster, not the stored landscape
assert im.getexif().get(0x0112) is None # and no tag asking for another turn
class TestHeifOrientation:
"""A camera HEIC stores its raster and carries the rotation in the EXIF
payload; a pillow-heif-written one bakes the rotation as an ``irot`` transform
that the decode applies. pillow-heif blanks the EXIF tag on open for both and
stashes the real value in ``info["original_orientation"]``, and ``Image.size``
matches the raster either way, so the container's transform boxes are the only
signal telling the two apart (issue #105)."""
ORIENT = 6 # Rotate 90 CW.
@staticmethod
def _baked(path: Path) -> Path:
"""A 96x64 stored raster whose rotation is baked as an irot transform."""
import piexif
from PIL import Image
exif = piexif.dump({"0th": {piexif.ImageIFD.Orientation: TestHeifOrientation.ORIENT}})
Image.new("RGB", (96, 64), "red").save(path, exif=exif)
return path
@staticmethod
def _camera_style(path: Path) -> Path:
"""The camera counterpart: same EXIF payload, identity transform, so the
decode returns the stored raster and only the tag says to turn it."""
data = bytearray(TestHeifOrientation._baked(path).read_bytes())
meta_end = data.find(b"mdat")
irot = data.find(b"irot", 0, meta_end if meta_end != -1 else len(data))
assert irot != -1, "fixture lost its irot box"
data[irot + 4] = 0 # angle 0 is the identity transform
path.write_bytes(data)
return path
def test_malformed_input_reads_as_no_transform(self, tmp_path: Path) -> None:
from remove_ai_watermarks._internal import isobmff
not_bmff = tmp_path / "garbage.bin"
not_bmff.write_bytes(b"\x00\x00\x00\x0cJUNKgarbage")
assert isobmff.heif_transform_applied(not_bmff) is False
empty = tmp_path / "empty.heic"
empty.write_bytes(b"")
assert isobmff.heif_transform_applied(empty) is False
assert isobmff.heif_transform_applied(tmp_path / "missing.heic") is False
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
def test_the_transform_box_alone_reports_the_decode_state(self, tmp_path: Path) -> None:
from remove_ai_watermarks._internal import isobmff
assert isobmff.heif_transform_applied(self._baked(tmp_path / "baked.heic")) is True
assert isobmff.heif_transform_applied(self._camera_style(tmp_path / "cam.heic")) is False
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
def test_a_camera_heic_carries_its_orientation_tag(self, tmp_path: Path) -> None:
# Issue #105: the blanked tag used to be dropped, so the output went out
# sideways with nothing on it saying so.
from PIL import Image
src = self._camera_style(tmp_path / "cam.heic")
raster = image_io.imread(src, cv2.IMREAD_UNCHANGED)
assert raster is not None
assert (raster.shape[1], raster.shape[0]) == (96, 64) # the stored raster
out = tmp_path / "out.png"
assert image_io.imwrite(out, raster, display_tags_from=src) is True
with Image.open(out) as im:
assert im.size == (96, 64)
assert im.getexif().get(0x0112) == self.ORIENT # the turn re-declared
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
def test_a_baked_heif_is_not_tagged_into_a_second_rotation(self, tmp_path: Path) -> None:
from PIL import Image
src = self._baked(tmp_path / "baked.heic")
raster = image_io.imread(src, cv2.IMREAD_UNCHANGED)
assert raster is not None
assert (raster.shape[1], raster.shape[0]) == (64, 96) # libheif turned it upright
out = tmp_path / "out.png"
assert image_io.imwrite(out, raster, display_tags_from=src) is True
with Image.open(out) as im:
assert im.getexif().get(0x0112) is None
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
def test_the_visible_api_path_carries_a_camera_heic_tag(self, tmp_path: Path) -> None:
# The visible write passes orientation_applied=False ("raw decode"), a
# claim it cannot make for HEIF: whether libheif turned the raster is a
# per-file fact of the container. _read_display_tags must override the
# claim the way it does for TIFF (issue #105).
from PIL import Image
from remove_ai_watermarks import api
src = self._camera_style(tmp_path / "cam.heic")
out = tmp_path / "out.png"
_, removed = api.remove_visible(src, out, strip_metadata=False)
assert removed == [] # plain red: no mark, the passthrough write is exercised
with Image.open(out) as im:
assert im.size == (96, 64) # the stored raster...
assert im.getexif().get(0x0112) == self.ORIENT # ...with its turn re-declared
+29
View File
@@ -135,6 +135,35 @@ def test_distributed_version_survives_a_newer_pypi_release(tmp_path: Path) -> No
)
@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow")
def test_distributed_version_reads_the_flat_single_artifact_layout(tmp_path: Path) -> None:
producer = _step("distribute.yml", "resolve", "Record distributed release")
consumer = _step("verify-release.yml", "verify", "Resolve target version")
env = {
**os.environ,
"VERSION": "0.37.0",
"GITHUB_RUN_ID": "123",
"GITHUB_RUN_ATTEMPT": "2",
"GITHUB_OUTPUT": str(tmp_path / "output"),
"EVENT_NAME": "workflow_run",
"INPUT_VERSION": "",
"DISTRIBUTION_RUN_ID": "123",
"DISTRIBUTION_RUN_ATTEMPT": "2",
}
subprocess.run(["bash", "-eu", "-c", producer["run"]], cwd=tmp_path, env=env, check=True)
# download-artifact@v8 extracts the only pattern-matched artifact directly
# into the target path, with no artifact-name directory.
artifact = tmp_path / "distribution-identities"
artifact.mkdir()
(artifact / "release-identity.json").write_bytes((tmp_path / "release-identity.json").read_bytes())
result = subprocess.run(
["bash", "-eu", "-c", consumer["run"]], cwd=tmp_path, env=env, capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert "version=0.37.0" in (tmp_path / "output").read_text()
@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow")
def test_clawhub_uses_local_install(tmp_path: Path) -> None:
script = _step("distribute.yml", "clawhub", "Publish the agent skill when its version moved")["run"]
Generated
+1 -1
View File
@@ -3449,7 +3449,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.39.0"
version = "0.39.1"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },