mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
The file-cleaners layer covered 15 formats -- all image, document, or text -- and zero audio/video. That gap gets more expensive every month: Sora, Veo, ElevenLabs, and Suno all embed provenance through the same mechanisms image generators do, just in different containers. New av_meta.py adds inspect/clean for: - MP4/MOV/M4A/M4V: top-level C2PA (jumb/c2pa box) and XMP (uuid box) detection/stripping reuse inspect_isobmff()/strip_isobmff() from image_meta.py unchanged -- that's exactly the mechanism the C2PA spec defines for ISOBMFF-family containers, already proven for AVIF/HEIC. moov/udta (where generator/tool tags live) is handled separately since it's MP4-specific. - WAV: RIFF LIST INFO chunk + embedded id3 chunk. - MP3: ID3v2 frames, per-frame for v2.3/v2.4, whole-tag fallback for v2.2 (3-byte frame IDs are detected but not decomposed, so a partial rewrite is never attempted there). Every box/chunk/frame is either kept byte-identical or dropped whole -- nothing does a partial in-place rewrite of a payload, so a container can never come out semantically mangled. Default strip_all_metadata=True matches this project's existing default (privacy-first: drop everything, --keep-non-ai-metadata narrows to only AI-flagged content), same as the image cleaners. Wired through the full dispatch stack so the feature isn't a half integration: format_dispatch.py (new "av" Kind), inspect_file.py / clean_file.py (--as av), audit_lib.py (so audit_dir.py's CI/SARIF path and the pre-commit hooks from #135 both cover audio/video too), and server.py (HTTP /inspect and /clean). Closes #134 Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
130 lines
3.8 KiB
Python
130 lines
3.8 KiB
Python
"""Route a file or byte stream to the text, image or container pipeline.
|
|
|
|
The routers (inspect_file, clean_file) and the audits (audit_lib) all need the
|
|
same answer: given a path or bytes, which pipeline owns it? That decision used
|
|
to live in three copies with subtly different extension tables and sniffing.
|
|
This module is the single interface for it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from av_meta import AV_EXTS, detect_av_format
|
|
from container_meta import detect_container_format
|
|
from image_meta import detect_format as detect_image_format
|
|
|
|
Kind = Literal["text", "image", "container", "av", "unknown"]
|
|
|
|
#: Bytes read for header-only sniffing. Every supported image/container
|
|
#: magic lives in the prefix; zip-based containers (docx/odt/...) need the
|
|
#: full central directory, which sits at the end of the archive, so only a
|
|
#: PK header triggers a whole-file read.
|
|
CLASSIFY_HEADER_BYTES = 4096
|
|
|
|
IMAGE_EXTS = {
|
|
".png",
|
|
".jpg",
|
|
".jpeg",
|
|
".webp",
|
|
".avif",
|
|
".heic",
|
|
".heif",
|
|
".bmp",
|
|
".gif",
|
|
".tiff",
|
|
".tif",
|
|
}
|
|
CONTAINER_EXTS = {
|
|
".svg",
|
|
".pdf",
|
|
".docx",
|
|
".xlsx",
|
|
".pptx",
|
|
".odt",
|
|
".epub",
|
|
".html",
|
|
".htm",
|
|
".md",
|
|
".markdown",
|
|
".mdx",
|
|
}
|
|
TEXT_EXTS = {
|
|
".txt",
|
|
".text",
|
|
".css",
|
|
".js",
|
|
".py",
|
|
".rs",
|
|
".go",
|
|
".json",
|
|
".yaml",
|
|
".yml",
|
|
".toml",
|
|
".csv",
|
|
}
|
|
|
|
|
|
def classify_bytes(data: bytes, suffix: str | None = None) -> Kind:
|
|
"""Classify *data* by extension first, then by magic bytes.
|
|
|
|
The extension wins when it names a known format; otherwise the bytes are
|
|
sniffed for image/container signatures. Unrecognized bytes classify as
|
|
"unknown" — callers that must not mangle unknown binaries refuse unless
|
|
the user explicitly forces a kind (--as / --force-text).
|
|
|
|
*data* must cover the whole file: zip-based containers (docx/odt) are
|
|
detected from their central directory, which sits at the end of the bytes.
|
|
"""
|
|
ext = (suffix or "").lower()
|
|
if ext in IMAGE_EXTS:
|
|
return "image"
|
|
if ext in CONTAINER_EXTS:
|
|
return "container"
|
|
if ext in TEXT_EXTS:
|
|
return "text"
|
|
if ext in AV_EXTS:
|
|
return "av"
|
|
if detect_image_format(data) in ("png", "jpeg", "webp", "avif", "heic", "bmp", "gif", "tiff"):
|
|
return "image"
|
|
if detect_av_format(data) != "unknown":
|
|
return "av"
|
|
if data:
|
|
sniff_path = Path("input") if not ext else Path(f"input{ext}")
|
|
if detect_container_format(sniff_path, data) != "unknown":
|
|
return "container"
|
|
return "unknown"
|
|
|
|
|
|
def classify(path: Path) -> Kind:
|
|
"""Classify a file on disk by extension, then by its bytes.
|
|
|
|
Known extensions are routed without reading the file. For unknown
|
|
extensions a 4096-byte header is sniffed once; only when the header is a
|
|
zip local header (PK) is the whole file read, because the container
|
|
signature (docx/xlsx/pptx/odt/epub) lives in the central directory at
|
|
the end of the archive.
|
|
"""
|
|
ext = path.suffix.lower()
|
|
if ext in IMAGE_EXTS:
|
|
return "image"
|
|
if ext in CONTAINER_EXTS:
|
|
return "container"
|
|
if ext in TEXT_EXTS:
|
|
return "text"
|
|
if ext in AV_EXTS:
|
|
return "av"
|
|
with path.open("rb") as fh:
|
|
head = fh.read(CLASSIFY_HEADER_BYTES)
|
|
if detect_image_format(head) in ("png", "jpeg", "webp", "avif", "heic", "bmp", "gif", "tiff"):
|
|
return "image"
|
|
if detect_av_format(head) != "unknown":
|
|
return "av"
|
|
if head:
|
|
data = path.read_bytes() if head[:4] == b"PK\x03\x04" else head
|
|
sniff_path = Path("input") if not ext else Path(f"input{ext}")
|
|
if detect_container_format(sniff_path, data) != "unknown":
|
|
return "container"
|
|
return "unknown"
|