Files
275a087a07 feat: audio/video AI/C2PA metadata stripping (MP4/MOV, WAV, MP3) (#139)
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>
2026-08-18 07:47:43 -07:00

198 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Unified clean: text Layer A, raster metadata, and document containers."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from av_meta import clean_av
from common import (
MAX_INPUT_BYTES,
ROUTER_ADVICE,
backup_path,
cleaned_path,
eprint,
guard_binary,
safe_write_text,
)
from container_meta import clean_container, detect_container_format
from format_dispatch import classify
from image_meta import clean_image
from text_unicode import clean_text
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("path", type=Path)
p.add_argument("-o", "--output", type=Path)
p.add_argument("--in-place", action="store_true")
p.add_argument("--json", action="store_true")
p.add_argument("--nfkc", action="store_true", help="Text: NFKC normalize")
p.add_argument("--aggressive-homoglyphs", action="store_true")
p.add_argument(
"--keep-non-ai-metadata",
action="store_true",
help="Images/audio/video: only drop C2PA/AI-looking segments",
)
p.add_argument(
"--as",
dest="force_type",
choices=("auto", "text", "image", "container", "av"),
default="auto",
)
p.add_argument(
"--force-text",
action="store_true",
help="Clean as text even when the bytes look like a binary container",
)
args = p.parse_args()
if not args.path.is_file():
eprint(f"not a file: {args.path}")
return 2
if args.path.stat().st_size > MAX_INPUT_BYTES:
eprint(f"refusing input larger than {MAX_INPUT_BYTES} bytes: {args.path}")
return 2
kind = args.force_type if args.force_type != "auto" else classify(args.path)
# classify() reports "unknown" for bytes that match no supported format.
# Never mutate those in auto mode: a binary with valid UTF-8 runs would
# be decoded and written back mangled. --as text / --force-text are the
# explicit opt-ins and both route through the text pipeline below.
if kind == "unknown":
if args.force_type == "text" or args.force_text:
kind = "text"
else:
eprint(f"refusing to classify {args.path}: unrecognized format")
for line in ROUTER_ADVICE:
eprint(line)
return 2
# In-place cleaning reads from a .bak copy whose suffix would make
# markdown/HTML (detected by extension, not magic bytes) classify as
# "unknown". Pin the format from the original path so --in-place and -o
# route identically.
container_fmt = None
if kind == "container":
container_fmt = detect_container_format(args.path, args.path.read_bytes())
# classify() falls back to "text" for unrecognised bytes, so an unknown
# binary would otherwise be decoded, scrubbed and written back mangled.
# Sniff before --in-place takes a backup: refusing afterwards would leave a
# .bak sidecar behind for a file this run never touches.
raw = args.path.read_bytes() if kind == "text" else None
if raw is not None:
guard_binary(
raw,
str(args.path),
allow_binary=args.force_text,
advice=ROUTER_ADVICE,
)
if args.in_place:
bak = backup_path(args.path)
dest = args.path
src = bak
else:
src = args.path
dest = args.output or cleaned_path(args.path)
if kind == "text":
text = raw.decode("utf-8", errors="surrogateescape")
cleaned, stats = clean_text(
text,
nfkc=args.nfkc,
aggressive_homoglyphs=args.aggressive_homoglyphs,
)
dest.parent.mkdir(parents=True, exist_ok=True)
safe_write_text(dest, cleaned)
result = {
"kind": "text",
"input": str(args.path),
"output": str(dest),
"stats": stats,
}
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
eprint(
f"wrote {dest} removed={stats['removed_count']} replaced={stats['replaced_count']}"
)
return 0
if kind == "image":
try:
result = clean_image(
src,
dest,
strip_all_metadata=not args.keep_non_ai_metadata,
)
except Exception as e:
eprint(f"error: {e}")
return 1
result = {"kind": "image", **result}
residual = result["still_has_c2pa"] or result["still_has_ai_metadata"]
if args.json:
print(json.dumps(result, indent=2))
else:
eprint(f"wrote {result['output']} ({result['bytes_in']} -> {result['bytes_out']})")
for a in result["actions"]:
eprint(f" - {a}")
if residual:
eprint("warning: residual C2PA/AI signals may remain")
return 1 if residual else 0
if kind == "av":
try:
result = clean_av(
src,
dest,
strip_all_metadata=not args.keep_non_ai_metadata,
)
except Exception as e:
eprint(f"error: {e}")
return 1
result = {"kind": "av", **result}
residual = result["still_has_c2pa"] or result["still_has_ai_metadata"]
if args.json:
print(json.dumps(result, indent=2))
else:
eprint(f"wrote {result['output']} ({result['bytes_in']} -> {result['bytes_out']})")
for a in result["actions"]:
eprint(f" - {a}")
if residual:
eprint("warning: residual C2PA/AI signals may remain")
return 1 if residual else 0
try:
result = clean_container(src, dest, fmt=container_fmt)
except Exception as e:
eprint(f"error: {e}")
return 1
result = {"kind": "container", **result}
residual = result["still_has_c2pa"] or result["still_has_ai_metadata"]
degraded = bool(result.get("meta", {}).get("degraded"))
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
eprint(f"wrote {result['output']} format={result['format']}")
for a in result["actions"]:
eprint(f" - {a}")
if residual:
eprint("warning: residual C2PA/AI signals may remain")
for f in result.get("post_findings") or []:
eprint(f" ! {f}")
# A degraded (best-effort) PDF copy warns but is not a hard failure.
return 1 if (residual and not degraded) else 0
if __name__ == "__main__":
raise SystemExit(main())