Add Hailuo, Kling, AVI, and FLV video coverage

This commit is contained in:
Victor Kuznetsov
2026-07-29 23:30:33 -07:00
parent 63cde7f32a
commit 07b678fa4c
17 changed files with 1017 additions and 189 deletions
+3 -3
View File
@@ -1063,8 +1063,8 @@ def cmd_metadata(
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
untouched.
from WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG. The coded image, audio, and video
data are left untouched.
"""
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify
@@ -1240,7 +1240,7 @@ def cmd_video_invisible(
)
@click.option(
"--mark",
type=click.Choice(["sora", "veo", "seedance", "dola"]),
type=click.Choice(["sora", "veo", "seedance", "dola", "hailuo", "kling"]),
default="sora",
help="Visible AI mark to remove.",
)
+42 -9
View File
@@ -10,10 +10,11 @@ from __future__ import annotations
import contextlib
import functools
import json
import logging
import re
import struct
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from pathlib import Path
@@ -125,7 +126,7 @@ _ISOBMFF_EXTS: frozenset[str] = frozenset({".avif", ".heif", ".heic", ".jxl", ".
# RIFF / Vorbis). remove_ai_metadata strips their container metadata losslessly
# via ffmpeg (`-c copy`), so it needs ffmpeg on PATH for these.
_FFMPEG_STRIP_EXTS: frozenset[str] = frozenset(
{".webm", ".mkv", ".mka", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
{".webm", ".mkv", ".mka", ".avi", ".flv", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
)
# China's mandatory AI-content labeling (TC260, the national cybersecurity
@@ -161,6 +162,22 @@ TC260_AIGC_FIELDS: frozenset[str] = frozenset(
"ServiceUser",
}
)
MAX_TC260_VALUE_BYTES = 1024 * 1024
def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
"""Parse a bounded JSON object carrying at least one normative TC260 field."""
if len(value) > MAX_TC260_VALUE_BYTES:
return None
try:
parsed = json.loads(value.rstrip(b"\x00 ").decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
fields = {str(key): str(item) for key, item in cast("dict[object, object]", parsed).items()}
return fields if TC260_AIGC_FIELDS & fields.keys() else None
# HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this
# ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a
@@ -365,6 +382,8 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
(as written by Doubao / ByteDance), read via PIL;
- a native MP4/MOV ``AIGC`` key in ``moov.udta.meta.keys`` whose matching
``ilst`` item carries the raw JSON object;
- a native AVI ``LIST/INFO/AIGC`` chunk or FLV
``script.onMetaData.AIGC`` string carrying the raw JSON object;
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike); and
- a raw-JSON ``{"AIGC":{...}}`` block with no namespace, as embedded in JPEG
@@ -381,20 +400,17 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
namespaced XMP element is unambiguous, so any JSON object is accepted.
"""
import html
import json
from typing import cast
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
if require_tc260_field:
return parse_tc260_aigc_json(text.encode("utf-8"))
try:
parsed = json.loads(text)
except ValueError:
return None
if not isinstance(parsed, dict):
return None
fields = {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
if require_tc260_field and not (TC260_AIGC_FIELDS & fields.keys()):
return None
return fields
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
# PNG tEXt chunk keyed "AIGC" with raw JSON (Doubao and other China gens).
# The key is generic, so require a TC260 field to avoid a false positive.
@@ -428,6 +444,22 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
return result
# Native AVI and FLV TC260 metadata. Both readers walk their container
# structures and skip media payloads instead of relying on a raw substring
# that could collide inside compressed video.
legacy_payloads: tuple[bytes, ...] = ()
if image_path.suffix.lower() == ".avi":
from remove_ai_watermarks.noai.riff import tc260_aigc_payloads as riff_tc260_aigc_payloads
legacy_payloads = riff_tc260_aigc_payloads(image_path)
elif image_path.suffix.lower() == ".flv":
from remove_ai_watermarks.noai.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
legacy_payloads = flv_tc260_aigc_payloads(image_path)
for payload in legacy_payloads:
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
return result
# XMP TC260:AIGC, namespaced (unambiguous) in either serialization RDF allows:
# an element <TC260:AIGC>{...}</TC260:AIGC> or an attribute TC260:AIGC="{...}"
# (the attribute form is what PicWish writes). Both are HTML-entity encoded.
@@ -1206,7 +1238,8 @@ def remove_ai_metadata(
)
return output_path
# Non-ISOBMFF audio/video (WebM/Matroska EBML, MP3 ID3, WAV/FLAC/OGG): the
# Non-ISOBMFF audio/video (WebM/Matroska EBML, AVI/FLV, MP3 ID3,
# WAV/FLAC/OGG): the
# box walker can't reach these, so strip container metadata losslessly via
# ffmpeg (-c copy -- codec data untouched, only tags/chapters dropped).
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
+4 -17
View File
@@ -8,14 +8,13 @@ their payloads.
from __future__ import annotations
import json
from typing import TYPE_CHECKING, BinaryIO, cast
from typing import TYPE_CHECKING, BinaryIO
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
from remove_ai_watermarks.metadata import TC260_AIGC_FIELDS
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
_EBML_MAGIC = b"\x1aE\xdf\xa3"
_SEGMENT_ID = 0x18538067
@@ -25,7 +24,6 @@ _SIMPLE_TAG_ID = 0x67C8
_TAG_NAME_ID = 0x45A3
_TAG_STRING_ID = 0x4487
_MAX_TAG_NAME_BYTES = 256
_MAX_TC260_VALUE_BYTES = 1024 * 1024
def _vint_length(first: int, *, maximum: int) -> int | None:
@@ -111,17 +109,6 @@ def _read_bounded(
return value if len(value) == size else None
def _is_tc260_aigc_json(value: bytes) -> bool:
try:
parsed = json.loads(value.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return False
if not isinstance(parsed, dict):
return False
fields = cast("dict[object, object]", parsed)
return bool(TC260_AIGC_FIELDS & {str(key) for key in fields})
def _simple_tag_payloads(
stream: BinaryIO,
start: int,
@@ -133,12 +120,12 @@ def _simple_tag_payloads(
if element_id == _TAG_NAME_ID:
name = _read_bounded(stream, payload_start, element_end, _MAX_TAG_NAME_BYTES)
elif element_id == _TAG_STRING_ID:
value = _read_bounded(stream, payload_start, element_end, _MAX_TC260_VALUE_BYTES)
value = _read_bounded(stream, payload_start, element_end, MAX_TC260_VALUE_BYTES)
if value is not None:
values.append(value)
if name != b"AIGC":
return ()
return tuple(value for value in values if _is_tc260_aigc_json(value))
return tuple(value for value in values if parse_tc260_aigc_json(value) is not None)
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
+160
View File
@@ -0,0 +1,160 @@
"""Bounded FLV metadata reader for native TC260 AIGC labels.
TC260-PG-20257A stores the label in the ``onMetaData`` script tag as an AMF0
property named ``AIGC`` whose string value is the normative JSON object. Media
tag payloads are skipped without being loaded.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
_SCRIPT_TAG = 18
_MAX_SCRIPT_BYTES = 4 * 1024 * 1024
def _u24(value: bytes) -> int:
return int.from_bytes(value, "big")
def _amf0_string(data: bytes, position: int, *, long: bool = False) -> tuple[bytes, int] | None:
length_size = 4 if long else 2
if position + length_size > len(data):
return None
length = int.from_bytes(data[position : position + length_size], "big")
start = position + length_size
end = start + length
if end > len(data):
return None
return data[start:end], end
def _skip_amf0(data: bytes, position: int, depth: int = 0) -> int | None:
if position >= len(data) or depth > 8:
return None
value_type = data[position]
position += 1
if value_type == 0:
return position + 8 if position + 8 <= len(data) else None
if value_type == 1:
return position + 1 if position + 1 <= len(data) else None
if value_type == 2:
parsed = _amf0_string(data, position)
return parsed[1] if parsed is not None else None
if value_type in {5, 6}:
return position
if value_type == 7:
return position + 2 if position + 2 <= len(data) else None
if value_type == 11:
return position + 10 if position + 10 <= len(data) else None
if value_type == 12:
parsed = _amf0_string(data, position, long=True)
return parsed[1] if parsed is not None else None
if value_type == 10:
if position + 4 > len(data):
return None
count = int.from_bytes(data[position : position + 4], "big")
position += 4
for _ in range(count):
next_position = _skip_amf0(data, position, depth + 1)
if next_position is None:
return None
position = next_position
return position
if value_type in {3, 8}:
if value_type == 8:
if position + 4 > len(data):
return None
position += 4
while position + 3 <= len(data):
name_length = int.from_bytes(data[position : position + 2], "big")
position += 2
if name_length == 0 and data[position] == 9:
return position + 1
position += name_length
if position > len(data):
return None
next_position = _skip_amf0(data, position, depth + 1)
if next_position is None:
return None
position = next_position
return None
return None
def _script_payloads(data: bytes) -> tuple[bytes, ...]:
first = _amf0_string(data, 1) if data[:1] == b"\x02" else None
if first is None or first[0] != b"onMetaData":
return ()
position = first[1]
if position >= len(data) or data[position] not in {3, 8}:
return ()
if data[position] == 8:
position += 5
else:
position += 1
found: list[bytes] = []
while position + 3 <= len(data):
name_length = int.from_bytes(data[position : position + 2], "big")
position += 2
if name_length == 0 and data[position] == 9:
break
name_end = position + name_length
if name_end > len(data):
break
name = data[position:name_end]
position = name_end
if name == b"AIGC" and position < len(data) and data[position] in {2, 12}:
long = data[position] == 12
parsed = _amf0_string(data, position + 1, long=long)
if parsed is None:
break
value, position = parsed
if len(value) <= MAX_TC260_VALUE_BYTES and parse_tc260_aigc_json(value) is not None:
found.append(value)
continue
next_position = _skip_amf0(data, position)
if next_position is None:
break
position = next_position
return tuple(found)
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
"""Read validated TC260 values from FLV ``script.onMetaData.AIGC``."""
found: list[bytes] = []
try:
with open(path, "rb") as stream:
header = stream.read(9)
if len(header) != 9 or header[:3] != b"FLV":
return ()
data_offset = int.from_bytes(header[5:9], "big")
stream.seek(0, 2)
file_size = stream.tell()
position = data_offset + 4
while position + 11 <= file_size:
stream.seek(position)
tag_header = stream.read(11)
if len(tag_header) != 11:
break
tag_type = tag_header[0] & 0x1F
data_size = _u24(tag_header[1:4])
payload_start = position + 11
payload_end = payload_start + data_size
if payload_end + 4 > file_size:
break
if tag_type == _SCRIPT_TAG and data_size <= _MAX_SCRIPT_BYTES:
payload = stream.read(data_size)
if len(payload) == data_size:
found.extend(_script_payloads(payload))
if found:
return tuple(found)
position = payload_end + 4
except OSError:
return ()
return tuple(found)
+6 -20
View File
@@ -23,11 +23,10 @@ Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
from __future__ import annotations
import io
import json
import logging
import re
import struct
from typing import TYPE_CHECKING, Any, BinaryIO, cast
from typing import TYPE_CHECKING, Any, BinaryIO
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -38,7 +37,8 @@ from remove_ai_watermarks.metadata import (
C2PA_UUID,
IPTC_AI_FIELD_MARKERS,
IPTC_AI_MARKERS,
TC260_AIGC_FIELDS,
MAX_TC260_VALUE_BYTES,
parse_tc260_aigc_json,
)
logger = logging.getLogger(__name__)
@@ -61,14 +61,12 @@ _AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_
# blanked in place (see ``blank_ai_xmp_packets``).
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
# TC260-PG-20257A stores an MP4/MOV label as an ``AIGC`` key in
# ``moov.udta.meta.keys`` and its JSON value in the corresponding
# ``moov.udta.meta.ilst`` item. The value is intentionally bounded before it is
# read: the normative object is tiny, and a corrupt size must not allocate an
# arbitrary amount of memory during an inspection.
_MAX_TC260_VALUE_BYTES = 1024 * 1024
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
@@ -181,18 +179,6 @@ def _tc260_key_indices(
return found
def _is_tc260_aigc_json(value: bytes) -> bool:
"""Require a JSON object carrying at least one normative TC260 field."""
try:
parsed = json.loads(value.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return False
if not isinstance(parsed, dict):
return False
fields = cast("dict[object, object]", parsed)
return bool(TC260_AIGC_FIELDS & {str(key) for key in fields})
def _tc260_aigc_regions(
stream: BinaryIO,
file_size: int,
@@ -249,11 +235,11 @@ def _tc260_aigc_regions(
):
value_start = data_payload + 8
value_size = data_end - value_start
if data_type != b"data" or value_size < 0 or value_size > _MAX_TC260_VALUE_BYTES:
if data_type != b"data" or value_size < 0 or value_size > MAX_TC260_VALUE_BYTES:
continue
stream.seek(value_start)
value = stream.read(value_size)
if len(value) == value_size and _is_tc260_aigc_json(value):
if len(value) == value_size and parse_tc260_aigc_json(value) is not None:
regions.append((*key_span, value_start, data_end, value))
return regions
+74
View File
@@ -0,0 +1,74 @@
"""Bounded AVI metadata reader for native TC260 AIGC labels.
TC260-PG-20257A stores the label in an AVI ``LIST/INFO`` chunk whose child
chunk ID is ``AIGC`` and whose value is the normative JSON object. The walker
seeks over media chunks and reads only bounded metadata values.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, BinaryIO
if TYPE_CHECKING:
from pathlib import Path
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
def _info_payloads(
stream: BinaryIO,
start: int,
end: int,
) -> tuple[bytes, ...]:
found: list[bytes] = []
position = start
while position + 8 <= end:
stream.seek(position)
chunk_id = stream.read(4)
size_raw = stream.read(4)
if len(chunk_id) != 4 or len(size_raw) != 4:
break
size = int.from_bytes(size_raw, "little")
payload_start = position + 8
payload_end = payload_start + size
if payload_end > end:
break
if chunk_id == b"AIGC" and size <= MAX_TC260_VALUE_BYTES:
value = stream.read(size)
if len(value) == size and parse_tc260_aigc_json(value) is not None:
found.append(value.rstrip(b"\x00 "))
position = payload_end + (size & 1)
return tuple(found)
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
"""Read validated TC260 values from an AVI ``LIST/INFO/AIGC`` chunk."""
found: list[bytes] = []
try:
with open(path, "rb") as stream:
header = stream.read(12)
if len(header) != 12 or header[:4] != b"RIFF" or header[8:12] != b"AVI ":
return ()
stream.seek(0, 2)
file_size = stream.tell()
declared_end = min(8 + int.from_bytes(header[4:8], "little"), file_size)
position = 12
while position + 8 <= declared_end:
stream.seek(position)
chunk_id = stream.read(4)
size_raw = stream.read(4)
if len(chunk_id) != 4 or len(size_raw) != 4:
break
size = int.from_bytes(size_raw, "little")
payload_start = position + 8
payload_end = payload_start + size
if payload_end > declared_end:
break
if chunk_id == b"LIST" and size >= 4:
list_type = stream.read(4)
if list_type == b"INFO":
found.extend(_info_payloads(stream, payload_start + 4, payload_end))
position = payload_end + (size & 1)
except OSError:
return ()
return tuple(found)
+34 -14
View File
@@ -1,9 +1,10 @@
"""High-level video processing API.
Supported experimental stages are container-level AI metadata inspection and
removal, temporally stabilized visible Sora, Veo, Seedance, and Dola removal,
and VAE regeneration that produces an externally verifiable SynthID candidate.
The visible pixel path reuses the image package's shared fill backends.
removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo, and
Kling removal, and VAE regeneration that produces an externally verifiable
SynthID candidate. The visible pixel path reuses the image package's shared
fill backends.
"""
from __future__ import annotations
@@ -22,9 +23,11 @@ from remove_ai_watermarks.video_synthid import (
if TYPE_CHECKING:
from remove_ai_watermarks.video_invisible import RegenerationMetrics
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv"})
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"})
_ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"})
_EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
_RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"})
_FLV_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".flv"})
_REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
_EBML_MAGIC = b"\x1aE\xdf\xa3"
@@ -109,8 +112,11 @@ def _video_source(source: str | Path) -> Path:
with path.open("rb") as stream:
head = stream.read(12)
suffix = path.suffix.lower()
matches_container = (suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp") or (
suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC)
matches_container = (
(suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp")
or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC))
or (suffix in _RIFF_VIDEO_EXTENSIONS and len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"AVI ")
or (suffix in _FLV_VIDEO_EXTENSIONS and head.startswith(b"FLV"))
)
if not matches_container:
raise ValueError(f"Video content does not match its {suffix} extension: {path}")
@@ -182,11 +188,11 @@ def remove_video_visible(
) -> VideoVisibleResult:
"""Remove a supported visible AI wordmark from a video.
Supported marks are ``sora``, ``veo``, ``seedance``, and ``dola``. The full
sequence is scanned before pixels change, and only recurring candidates are
accepted. Audio is copied without re-encoding; video is transcoded because
the pixels change. When no stable mark is found, no output is written and
``output`` in the result is ``None``.
Supported marks are ``sora``, ``veo``, ``seedance``, ``dola``, ``hailuo``,
and ``kling``. The full sequence is scanned before pixels change, and only
recurring candidates are accepted. Audio is copied without re-encoding;
video is transcoded because the pixels change. When no stable mark is found,
no output is written and ``output`` in the result is ``None``.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_visible import (
@@ -195,18 +201,22 @@ def remove_video_visible(
has_sora_provenance,
has_veo_provenance,
scan_dola_video,
scan_hailuo_video,
scan_kling_video,
scan_seedance_video,
scan_sora_video,
scan_veo_video,
stabilize_dola_localizations,
stabilize_hailuo_localizations,
stabilize_kling_localizations,
stabilize_seedance_localizations,
stabilize_sora_localizations,
stabilize_veo_localizations,
)
from remove_ai_watermarks.watermark_registry import resolve_backend
if mark not in {"sora", "veo", "seedance", "dola"}:
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, or dola")
if mark not in {"sora", "veo", "seedance", "dola", "hailuo", "kling"}:
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, dola, hailuo, or kling")
if backend not in {"auto", "cv2", "migan", "lama"}:
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
@@ -237,7 +247,7 @@ def remove_video_visible(
)
padding_fraction = 0.0
mask_style = "box"
else:
elif mark == "dola":
scan = scan_dola_video(source_path)
regions = stabilize_dola_localizations(
scan.detections,
@@ -245,6 +255,16 @@ def remove_video_visible(
)
padding_fraction = 0.20
mask_style = "box"
elif mark == "hailuo":
scan = scan_hailuo_video(source_path)
regions = stabilize_hailuo_localizations(scan.detections)
padding_fraction = 0.12
mask_style = "box"
else:
scan = scan_kling_video(source_path)
regions = stabilize_kling_localizations(scan.detections)
padding_fraction = 0.12
mask_style = "box"
detected_frames = sum(region is not None for region in regions)
if detected_frames == 0:
return VideoVisibleResult(
+306 -19
View File
@@ -4,10 +4,11 @@ Supported marks use fully synthetic silhouettes made from geometric primitives,
OpenCV's built-in font, and Pillow's bundled font. Sora detection searches the
full frame because the wordmark moves. Veo detection covers both the current
four-point diamond and legacy ``Veo`` text. Seedance detects the boxed ``AI``
label, while Dola detects its compact text label. A single frame is never enough
to authorize removal: the temporal arbiter requires the candidate to recur at
the same location across adjacent frames. This keeps isolated lookalikes in
clean videos from becoming removal masks.
label, Dola detects its compact text label, Hailuo detects the composite
MINIMAX/Hailuo label, and Kling detects its version-independent wordmark core.
A single frame is never enough to authorize removal: the temporal arbiter
requires the candidate to recur at the same location across adjacent frames.
This keeps isolated lookalikes in clean videos from becoming removal masks.
Video pixels are decoded with OpenCV and encoded with the system ``ffmpeg``.
Audio is stream-copied from the source. The video stream must be transcoded
@@ -64,6 +65,11 @@ _SEEDANCE_STRONG_CONFIDENCE = 0.43
_DOLA_PROVENANCE_WEAK_CONFIDENCE = 0.48
_DOLA_STRICT_WEAK_CONFIDENCE = 0.50
_DOLA_STRONG_CONFIDENCE = 0.52
_HAILUO_WEAK_CONFIDENCE = 0.30
_HAILUO_STRONG_CONFIDENCE = 0.34
_KLING_WEAK_CONFIDENCE = 0.20
_KLING_STRONG_CONFIDENCE = 0.24
_KLING_MIN_WHITE_FRACTION = 0.02
_MIN_STABLE_FRAMES = 5
_MIN_VEO_STABLE_FRAMES = 12
_MIN_FIXED_MARK_STABLE_FRAMES = 12
@@ -76,6 +82,8 @@ _VEO_DIAMOND_PROFILES = (
(44, 29, 40),
)
_DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41))
_HAILUO_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(28, 56, 3))
_KLING_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(24, 49, 3))
@dataclass(frozen=True)
@@ -108,6 +116,14 @@ def _scalable_default_font(size: int) -> ImageFont.ImageFont | ImageFont.FreeTyp
return ImageFont.load_default()
def _crop_nonzero(image: NDArray[Any]) -> NDArray[Any]:
"""Crop a synthetic template to its nonzero footprint."""
ys, xs = np.where(image > 0)
if len(xs) == 0:
return image
return image[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
@lru_cache(maxsize=1)
def _sora_templates() -> tuple[NDArray[Any], NDArray[Any]]:
"""Return synthetic full-wordmark and mascot-only silhouettes.
@@ -162,9 +178,7 @@ def _veo_templates() -> tuple[NDArray[Any], NDArray[Any]]:
text_canvas = Image.new("L", (140, 60), 0)
text_draw = ImageDraw.Draw(text_canvas)
text_draw.text((2, 0), "Veo", font=_scalable_default_font(48), fill=255)
text = np.asarray(text_canvas, dtype=np.uint8)
ys, xs = np.where(text > 0)
text = text[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
text = _crop_nonzero(np.asarray(text_canvas, dtype=np.uint8))
return np.asarray(diamond_canvas, dtype=np.uint8), text
@@ -201,8 +215,75 @@ def _dola_template() -> NDArray[Any]:
3,
cv2.LINE_AA,
)
ys, xs = np.where(canvas > 0)
return canvas[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
return _crop_nonzero(canvas)
@lru_cache(maxsize=1)
def _hailuo_template() -> NDArray[Any]:
"""Return a synthetic MINIMAX/Hailuo composite-label silhouette."""
canvas = Image.new("L", (680, 112), 0)
draw = ImageDraw.Draw(canvas)
# Five symmetric waveform strokes approximate the provider-independent
# geometry at the left edge without copying pixels from an export.
waveform_heights = (42, 70, 96, 70, 42)
center_y = 56
for index, height in enumerate(waveform_heights):
x = 10 + index * 13
draw.rounded_rectangle(
(x, center_y - height // 2, x + 5, center_y + height // 2),
radius=2,
fill=255,
)
font = _scalable_default_font(58)
draw.text((84, 18), "MINIMAX", font=font, fill=255, stroke_width=1, stroke_fill=255)
draw.rectangle((342, 18, 347, 92), fill=255)
# The Hailuo symbol is a ring with a small offset highlight.
draw.ellipse((370, 20, 446, 96), outline=255, width=12)
draw.ellipse((397, 38, 434, 76), fill=255)
draw.ellipse((397, 29, 420, 52), fill=0)
draw.text((456, 18), "hailuo AI", font=font, fill=255, stroke_width=1, stroke_fill=255)
return np.asarray(canvas, dtype=np.uint8)
@lru_cache(maxsize=1)
def _kling_templates() -> tuple[NDArray[Any], ...]:
"""Return synthetic font variants for the Kling wordmark core."""
canvas = Image.new("L", (430, 104), 0)
draw = ImageDraw.Draw(canvas)
draw.text(
(2, 12),
"KLING AI",
font=_scalable_default_font(64),
fill=255,
stroke_width=1,
stroke_fill=255,
)
templates = [_crop_nonzero(np.asarray(canvas, dtype=np.uint8))]
for font in (cv2.FONT_HERSHEY_SIMPLEX, cv2.FONT_HERSHEY_DUPLEX):
cv_template = np.zeros((100, 500), dtype=np.uint8)
cv2.putText(
cv_template,
"KLING AI",
(2, 72),
font,
2.2,
255,
3,
cv2.LINE_AA,
)
templates.append(_crop_nonzero(cv_template))
return tuple(templates)
@lru_cache(maxsize=1)
def _kling_logo_template() -> NDArray[Any]:
"""Return a synthetic ring approximation of the Kling swirl."""
template = np.zeros((100, 100), dtype=np.uint8)
cv2.circle(template, (50, 50), 34, 255, 14, cv2.LINE_AA)
return _crop_nonzero(template)
def _top_hat(gray: NDArray[Any]) -> NDArray[Any]:
@@ -338,21 +419,43 @@ def _restore_region(
return source_x, source_y, source_width, source_height
def _detect_fixed_bottom_right_mark(
def _bounded_region(
x: int,
y: int,
width: int,
height: int,
*,
frame_width: int,
frame_height: int,
) -> Region:
"""Clip an expanded region to the frame without changing its anchor."""
bounded_x = max(0, x)
bounded_y = max(0, y)
return (
bounded_x,
bounded_y,
min(frame_width - bounded_x, width + x - bounded_x),
min(frame_height - bounded_y, height + y - bounded_y),
)
def _detect_fixed_mark(
image_bgr: NDArray[Any],
template: NDArray[Any],
*,
relative_heights: tuple[float, ...],
search_origin: tuple[float, float],
kernel_fraction: float,
prefer_larger_within: float = 0.0,
normalized: tuple[NDArray[Any], float] | None = None,
frame_index: int,
) -> FrameLocalization:
"""Match one fixed bottom-right synthetic mark on a normalized frame."""
"""Match one fixed synthetic mark inside a normalized-frame search region."""
if image_bgr.size == 0:
return FrameLocalization(frame_index, 0.0, None)
frame_height, frame_width = image_bgr.shape[:2]
gray, scale = _normalized_gray(image_bgr)
gray, scale = normalized if normalized is not None else _normalized_gray(image_bgr)
normalized_height, normalized_width = gray.shape[:2]
short_side = min(normalized_height, normalized_width)
search_x = round(normalized_width * search_origin[0])
@@ -363,8 +466,7 @@ def _detect_fixed_bottom_right_mark(
normalized_width - search_x,
normalized_height - search_y,
)
best_confidence = 0.0
best_region: Region | None = None
matches: list[tuple[float, Region]] = []
for relative_height in relative_heights:
template_height = max(6, round(short_side * relative_height))
template_width = max(1, round(template.shape[1] * template_height / template.shape[0]))
@@ -379,9 +481,21 @@ def _detect_fixed_bottom_right_mark(
region=search_region,
kernel_size=max(3, round(template_height * kernel_fraction) | 1),
)
if confidence > best_confidence:
best_confidence = confidence
best_region = candidate
if candidate is not None and confidence > 0:
matches.append((confidence, candidate))
if not matches:
return FrameLocalization(frame_index, 0.0, None)
best_confidence, best_region = max(matches, key=lambda match: match[0])
if prefer_larger_within > 0:
eligible = [
(confidence, candidate)
for confidence, candidate in matches
if confidence >= best_confidence - prefer_larger_within
]
best_confidence, best_region = max(
eligible,
key=lambda match: (match[1][2] * match[1][3], match[0]),
)
return FrameLocalization(
frame_index,
@@ -397,7 +511,7 @@ def _detect_fixed_bottom_right_mark(
def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the strongest fixed Seedance boxed-AI candidate."""
return _detect_fixed_bottom_right_mark(
return _detect_fixed_mark(
image_bgr,
_seedance_template(),
relative_heights=(0.065, 0.075, 0.085, 0.095, 0.105),
@@ -409,7 +523,7 @@ def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> F
def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the strongest fixed Dola AI text candidate."""
return _detect_fixed_bottom_right_mark(
return _detect_fixed_mark(
image_bgr,
_dola_template(),
relative_heights=_DOLA_RELATIVE_HEIGHTS,
@@ -419,6 +533,137 @@ def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Frame
)
def detect_hailuo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the strongest fixed MINIMAX/Hailuo composite-label candidate."""
detection = _detect_fixed_mark(
image_bgr,
_hailuo_template(),
relative_heights=_HAILUO_RELATIVE_HEIGHTS,
search_origin=(0.28, 0.76),
kernel_fraction=0.18,
frame_index=frame_index,
)
if detection.region is None:
return detection
frame_width = image_bgr.shape[1]
x, y, width, height = detection.region
horizontal_padding = round(height * 1.25)
region = _bounded_region(
x - horizontal_padding,
y,
width + horizontal_padding * 2,
height,
frame_width=frame_width,
frame_height=image_bgr.shape[0],
)
return FrameLocalization(
frame_index,
detection.confidence,
region,
)
def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the fixed Kling wordmark core and include its version suffix."""
if image_bgr.size == 0:
return FrameLocalization(frame_index, 0.0, None)
frame_height, frame_width = image_bgr.shape[:2]
normalized = _normalized_gray(image_bgr)
expanded: list[FrameLocalization] = []
for template in _kling_templates():
detection = _detect_fixed_mark(
image_bgr,
template,
relative_heights=_KLING_RELATIVE_HEIGHTS,
search_origin=(0.64, 0.84),
kernel_fraction=0.18,
prefer_larger_within=0.06,
normalized=normalized,
frame_index=frame_index,
)
if detection.region is None:
continue
x, y, width, height = detection.region
left_padding = round(height * 1.8)
right_padding = round(height * 4.0)
vertical_padding = round(height * 0.4)
region = _bounded_region(
x - left_padding,
y - vertical_padding,
width + left_padding + right_padding,
height + vertical_padding * 2,
frame_width=frame_width,
frame_height=frame_height,
)
expanded.append(
FrameLocalization(
frame_index,
detection.confidence,
region,
)
)
if not expanded:
return FrameLocalization(frame_index, 0.0, None)
edge_candidates = [
candidate
for candidate in expanded
if candidate.region is not None
and candidate.region[0] + candidate.region[2] >= frame_width * 0.96
and candidate.region[1] + candidate.region[3] >= frame_height * 0.94
]
font_candidate = None if not edge_candidates else max(edge_candidates, key=lambda candidate: candidate.confidence)
logo = _detect_fixed_mark(
image_bgr,
_kling_logo_template(),
relative_heights=tuple(value / 1000 for value in range(20, 61, 3)),
search_origin=(0.62, 0.90),
kernel_fraction=0.18,
prefer_larger_within=0.03,
normalized=normalized,
frame_index=frame_index,
)
logo_candidate: FrameLocalization | None = None
logo_x: int | None = None
if logo.region is not None and logo.confidence >= 0.44:
logo_x, logo_y, _, logo_height = logo.region
logo_candidate = FrameLocalization(
frame_index,
logo.confidence,
_bounded_region(
logo_x - round(logo_height * 0.2),
logo_y - round(logo_height * 0.25),
round(logo_height * 7.8),
round(logo_height * 1.5),
frame_width=frame_width,
frame_height=frame_height,
),
)
best = font_candidate or logo_candidate
if (
font_candidate is not None
and font_candidate.region is not None
and logo_candidate is not None
and logo_x is not None
):
font_x, _, font_width, _ = font_candidate.region
if logo_x <= font_x + round(font_width * 0.50):
best = logo_candidate
if best is None or best.region is None:
return FrameLocalization(frame_index, 0.0, None)
x, y, width, height = best.region
roi = image_bgr[y : y + height, x : x + width]
if roi.ndim == 2:
white_fraction = float(np.mean(roi >= 180))
else:
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
white_fraction = float(np.mean((hsv[:, :, 1] <= 55) & (hsv[:, :, 2] >= 180)))
if white_fraction < _KLING_MIN_WHITE_FRACTION:
return FrameLocalization(frame_index, 0.0, None)
return best
def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the strongest current-diamond or legacy-text Veo candidate."""
if image_bgr.size == 0:
@@ -599,6 +844,38 @@ def stabilize_dola_localizations(
)
def stabilize_hailuo_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
) -> list[Region | None]:
"""Accept a recurring MINIMAX/Hailuo label at a fixed position."""
return _stabilize_localizations(
detections,
provenance=False,
weak_floor=_HAILUO_WEAK_CONFIDENCE,
strong_floor=_HAILUO_STRONG_CONFIDENCE,
transition_floor=0.28,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
)
def stabilize_kling_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
) -> list[Region | None]:
"""Accept a recurring versioned Kling label at a fixed position."""
return _stabilize_localizations(
detections,
provenance=False,
weak_floor=_KLING_WEAK_CONFIDENCE,
strong_floor=_KLING_STRONG_CONFIDENCE,
transition_floor=0.30,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
)
def _stabilize_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
@@ -734,6 +1011,16 @@ def scan_dola_video(source: Path) -> VideoScan:
return _scan_video(source, detect_dola_frame)
def scan_hailuo_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Hailuo candidate per frame."""
return _scan_video(source, detect_hailuo_frame)
def scan_kling_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Kling candidate per frame."""
return _scan_video(source, detect_kling_frame)
def _mask_for_region(
frame_bgr: NDArray[Any],
region: Region,