diff --git a/Cargo.lock b/Cargo.lock index 148528e..3d6215c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,9 +51,11 @@ dependencies = [ "kamadak-exif", "predicates", "quick-xml 0.36.2", + "rust-i18n", "rustfft", "serde", "serde_json", + "sys-locale", "tempfile", "walkdir", ] @@ -123,6 +125,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + [[package]] name = "asn1-rs" version = "0.7.1" @@ -229,6 +240,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base62" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111" + [[package]] name = "base64" version = "0.22.1" @@ -697,6 +714,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -1185,6 +1227,36 @@ dependencies = [ "weezl", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "group" version = "0.13.0" @@ -1541,6 +1613,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.24.9" @@ -1653,6 +1741,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1872,6 +1969,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2564,7 +2670,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96745587fc6ec8da0cc94946ae0220131927ea46bf68150fc688718c46a355d8" dependencies = [ "either", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2729,6 +2835,60 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.117", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml 0.8.23", + "triomphe", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3016,6 +3176,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3070,6 +3243,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -3226,6 +3405,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -3581,6 +3769,17 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -3617,6 +3816,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 3947b55..2f163b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ walkdir = "2" id3 = "1" rustfft = "6" quick-xml = "0.36" +rust-i18n = "3" +sys-locale = "0.3" [dev-dependencies] assert_cmd = "2" diff --git a/locales/de.yml b/locales/de.yml new file mode 100644 index 0000000..6b2db48 --- /dev/null +++ b/locales/de.yml @@ -0,0 +1,66 @@ +# Urteile +verdict_ai_generated: "KI-generiert" +verdict_likely_ai: "Wahrscheinlich KI-generiert" +verdict_possibly_ai: "Möglicherweise KI-generiert" +verdict_unknown: "Unbekannt" +verdict_not_detected: "Nicht als KI-generiert erkannt" + +# Konfidenzstufen +confidence_none: "KEINE" +confidence_low: "NIEDRIG" +confidence_medium: "MITTEL" +confidence_high: "HOCH" + +# Ausgabe +output_no_signals: "Keine KI-Generierungssignale erkannt." +output_summary: "Ergebnisse: %{detected}/%{total} Dateien mit KI-Signalen (%{high} HOCH, %{medium} MITTEL, %{low} NIEDRIG)" +output_type_label: "Typ: %{mime}" + +# Info-Überschriften +info_c2pa_header: "=== C2PA-Manifest ===" +info_xmp_header: "=== XMP-Metadaten ===" +info_exif_header: "=== EXIF-Daten ===" +info_mp4_header: "=== MP4-Metadaten ===" +info_id3_header: "=== ID3-Tags ===" +info_wav_header: "=== WAV-Metadaten ===" +info_watermark_header: "=== Wasserzeichen-Analyse ===" +info_no_metadata: "Keine Herkunftsmetadaten gefunden." + +# Fehlermeldungen +error_generic: "Fehler: %{msg}" +error_not_found: "Fehler: %{path} nicht gefunden" +error_no_files: "Keine unterstützten Dateien gefunden." +error_json_serialize: "Fehler: JSON-Serialisierung fehlgeschlagen: %{err}" +scanner_not_found: "Warnung: %{path} nicht gefunden, wird übersprungen" + +# Signalbeschreibungen +signal_c2pa_claim_generator: "claim_generator stimmt mit KI-Tool überein: %{value}" +signal_c2pa_claim_generator_info: "claim_generator_info verweist auf KI-Tool" +signal_c2pa_digital_source_type: "digitalSourceType = %{value}" +signal_xmp_digital_source_type: "DigitalSourceType = %{value}" +signal_xmp_ai_system_used: "AISystemUsed = %{value}" +signal_xmp_ai_prompt: "AIPromptInformation vorhanden" +signal_xmp_creator_tool: "CreatorTool stimmt mit KI-Tool überein: %{value}" +signal_exif_software: "Software = \"%{value}\"" +signal_exif_tag_value: "%{tag} = \"%{value}\"" +signal_exif_tag_references_ai: "%{tag} verweist auf KI-Tool" +signal_exif_artist_hash: "Artist enthält Hash-Wert (%{value}...)" +signal_exif_no_camera: "Keine Kamerametadaten (Hersteller, Modell, Objektiv, Belichtung) gefunden" +signal_filename_pattern: "Dateiname stimmt mit KI-Tool-Muster überein: %{pattern}" +signal_filename_elevenlabs: "Dateiname entspricht ElevenLabs-Namenskonvention" +signal_id3_comment: "ID3-Kommentar verweist auf KI-Tool: %{text}" +signal_id3_url: "ID3-URL verweist auf KI-Plattform: %{url}" +signal_id3_text_frame: "ID3 %{frame} stimmt mit KI-Tool überein: %{text}" +signal_id3_txxx: "ID3 TXXX (%{desc}) stimmt mit KI-Tool überein: %{value}" +signal_mp4_tool_match: "%{label} stimmt mit KI-Tool überein: %{value}" +signal_mp4_aigc_label: "AIGC-Label weist auf KI-generierten Inhalt hin" +signal_mp4_aigc_label_id: "AIGC-Label weist auf KI-generierten Inhalt hin (ProduceID: %{id})" +signal_mp4_sei_watermark: "H.264 SEI-Wasserzeichen: %{marker}" +signal_png_text_chunk: "PNG %{keyword} verweist auf KI-Tool" +signal_watermark_detected: "Unsichtbare Wasserzeichen-Indikatoren erkannt (%{strength} Evidenz): %{indicators}" +signal_watermark_strong: "starke" +signal_watermark_moderate: "mäßige" +signal_audio_cutoff: "Harte Frequenzabschaltung bei %{freq}Hz (%{pct}%% von %{nyquist}Hz Nyquist) — typisch für KI/TTS-Synthese" +signal_audio_flatness: "Spektrale Flachheit %{value} deutet auf synthetisches Audio hin (natürliche Sprache typischerweise > 0,05)" +signal_wav_info_tool: "WAV INFO %{key} stimmt mit KI-Tool überein: %{value}" +signal_wav_tts_heuristic: "Audioeigenschaften deuten auf TTS hin: Mono %{rate}Hz %{bits}bit" diff --git a/locales/en.yml b/locales/en.yml new file mode 100644 index 0000000..253f33f --- /dev/null +++ b/locales/en.yml @@ -0,0 +1,66 @@ +# Verdicts +verdict_ai_generated: "AI-generated" +verdict_likely_ai: "Likely AI-generated" +verdict_possibly_ai: "Possibly AI-generated" +verdict_unknown: "Unknown" +verdict_not_detected: "Not detected as AI-generated" + +# Confidence +confidence_none: "NONE" +confidence_low: "LOW" +confidence_medium: "MEDIUM" +confidence_high: "HIGH" + +# Output UI +output_no_signals: "No AI-generation signals detected." +output_summary: "Results: %{detected}/%{total} files with AI signals (%{high} HIGH, %{medium} MEDIUM, %{low} LOW)" +output_type_label: "Type: %{mime}" + +# Info headers +info_c2pa_header: "=== C2PA Manifest ===" +info_xmp_header: "=== XMP Metadata ===" +info_exif_header: "=== EXIF Data ===" +info_mp4_header: "=== MP4 Metadata ===" +info_id3_header: "=== ID3 Tags ===" +info_wav_header: "=== WAV Metadata ===" +info_watermark_header: "=== Watermark Analysis ===" +info_no_metadata: "No provenance metadata found." + +# Errors +error_generic: "Error: %{msg}" +error_not_found: "Error: %{path} not found" +error_no_files: "No supported files found." +error_json_serialize: "Error: failed to serialize JSON: %{err}" +scanner_not_found: "Warning: %{path} not found, skipping" + +# Signal descriptions +signal_c2pa_claim_generator: "claim_generator matches AI tool: %{value}" +signal_c2pa_claim_generator_info: "claim_generator_info references AI tool" +signal_c2pa_digital_source_type: "digitalSourceType = %{value}" +signal_xmp_digital_source_type: "DigitalSourceType = %{value}" +signal_xmp_ai_system_used: "AISystemUsed = %{value}" +signal_xmp_ai_prompt: "AIPromptInformation present" +signal_xmp_creator_tool: "CreatorTool matches AI tool: %{value}" +signal_exif_software: "Software = \"%{value}\"" +signal_exif_tag_value: "%{tag} = \"%{value}\"" +signal_exif_tag_references_ai: "%{tag} references AI tool" +signal_exif_artist_hash: "Artist contains hash-like value (%{value}...)" +signal_exif_no_camera: "No camera metadata (Make, Model, lens, exposure) found" +signal_filename_pattern: "Filename matches AI tool pattern: %{pattern}" +signal_filename_elevenlabs: "Filename matches ElevenLabs naming convention" +signal_id3_comment: "ID3 comment references AI tool: %{text}" +signal_id3_url: "ID3 URL points to AI platform: %{url}" +signal_id3_text_frame: "ID3 %{frame} matches AI tool: %{text}" +signal_id3_txxx: "ID3 TXXX (%{desc}) matches AI tool: %{value}" +signal_mp4_tool_match: "%{label} matches AI tool: %{value}" +signal_mp4_aigc_label: "AIGC label indicates AI-generated content" +signal_mp4_aigc_label_id: "AIGC label indicates AI-generated content (ProduceID: %{id})" +signal_mp4_sei_watermark: "H.264 SEI watermark: %{marker}" +signal_png_text_chunk: "PNG %{keyword} references AI tool" +signal_watermark_detected: "Invisible watermark indicators detected (%{strength} evidence): %{indicators}" +signal_watermark_strong: "strong" +signal_watermark_moderate: "moderate" +signal_audio_cutoff: "Hard frequency cutoff at %{freq}Hz (%{pct}%% of %{nyquist}Hz Nyquist) — typical of AI/TTS synthesis" +signal_audio_flatness: "Spectral flatness %{value} suggests synthetic audio (natural speech typically > 0.05)" +signal_wav_info_tool: "WAV INFO %{key} matches AI tool: %{value}" +signal_wav_tts_heuristic: "Audio characteristics suggest TTS: mono %{rate}Hz %{bits}bit" diff --git a/locales/es.yml b/locales/es.yml new file mode 100644 index 0000000..46cfaae --- /dev/null +++ b/locales/es.yml @@ -0,0 +1,66 @@ +# Veredictos +verdict_ai_generated: "Generado por IA" +verdict_likely_ai: "Probablemente generado por IA" +verdict_possibly_ai: "Posiblemente generado por IA" +verdict_unknown: "Desconocido" +verdict_not_detected: "No detectado como generado por IA" + +# Niveles de confianza +confidence_none: "NINGUNO" +confidence_low: "BAJO" +confidence_medium: "MEDIO" +confidence_high: "ALTO" + +# Interfaz de salida +output_no_signals: "No se detectaron señales de generación por IA." +output_summary: "Resultados: %{detected}/%{total} archivos con señales de IA (%{high} ALTO, %{medium} MEDIO, %{low} BAJO)" +output_type_label: "Tipo: %{mime}" + +# Encabezados de secciones +info_c2pa_header: "=== Manifiesto C2PA ===" +info_xmp_header: "=== Metadatos XMP ===" +info_exif_header: "=== Datos EXIF ===" +info_mp4_header: "=== Metadatos MP4 ===" +info_id3_header: "=== Etiquetas ID3 ===" +info_wav_header: "=== Metadatos WAV ===" +info_watermark_header: "=== Análisis de marca de agua ===" +info_no_metadata: "No se encontraron metadatos de procedencia." + +# Mensajes de error +error_generic: "Error: %{msg}" +error_not_found: "Error: %{path} no encontrado" +error_no_files: "No se encontraron archivos compatibles." +error_json_serialize: "Error: falló la serialización JSON: %{err}" +scanner_not_found: "Advertencia: %{path} no encontrado, omitiendo" + +# Descripciones de señales +signal_c2pa_claim_generator: "claim_generator coincide con herramienta de IA: %{value}" +signal_c2pa_claim_generator_info: "claim_generator_info hace referencia a herramienta de IA" +signal_c2pa_digital_source_type: "digitalSourceType = %{value}" +signal_xmp_digital_source_type: "DigitalSourceType = %{value}" +signal_xmp_ai_system_used: "AISystemUsed = %{value}" +signal_xmp_ai_prompt: "AIPromptInformation presente" +signal_xmp_creator_tool: "CreatorTool coincide con herramienta de IA: %{value}" +signal_exif_software: "Software = \"%{value}\"" +signal_exif_tag_value: "%{tag} = \"%{value}\"" +signal_exif_tag_references_ai: "%{tag} hace referencia a herramienta de IA" +signal_exif_artist_hash: "Artist contiene valor tipo hash (%{value}...)" +signal_exif_no_camera: "No se encontraron metadatos de cámara (fabricante, modelo, lente, exposición)" +signal_filename_pattern: "El nombre de archivo coincide con patrón de herramienta de IA: %{pattern}" +signal_filename_elevenlabs: "El nombre de archivo coincide con la convención de ElevenLabs" +signal_id3_comment: "Comentario ID3 hace referencia a herramienta de IA: %{text}" +signal_id3_url: "URL ID3 apunta a plataforma de IA: %{url}" +signal_id3_text_frame: "ID3 %{frame} coincide con herramienta de IA: %{text}" +signal_id3_txxx: "ID3 TXXX (%{desc}) coincide con herramienta de IA: %{value}" +signal_mp4_tool_match: "%{label} coincide con herramienta de IA: %{value}" +signal_mp4_aigc_label: "Etiqueta AIGC indica contenido generado por IA" +signal_mp4_aigc_label_id: "Etiqueta AIGC indica contenido generado por IA (ProduceID: %{id})" +signal_mp4_sei_watermark: "Marca de agua H.264 SEI: %{marker}" +signal_png_text_chunk: "PNG %{keyword} hace referencia a herramienta de IA" +signal_watermark_detected: "Indicadores de marca de agua invisible detectados (evidencia %{strength}): %{indicators}" +signal_watermark_strong: "fuerte" +signal_watermark_moderate: "moderada" +signal_audio_cutoff: "Corte de frecuencia abrupto en %{freq}Hz (%{pct}%% de %{nyquist}Hz Nyquist) — típico de síntesis IA/TTS" +signal_audio_flatness: "Planitud espectral %{value} sugiere audio sintético (el habla natural típicamente > 0,05)" +signal_wav_info_tool: "WAV INFO %{key} coincide con herramienta de IA: %{value}" +signal_wav_tts_heuristic: "Características de audio sugieren TTS: mono %{rate}Hz %{bits}bit" diff --git a/locales/hi.yml b/locales/hi.yml new file mode 100644 index 0000000..b044d07 --- /dev/null +++ b/locales/hi.yml @@ -0,0 +1,66 @@ +# निर्णय +verdict_ai_generated: "AI-जनित" +verdict_likely_ai: "संभवतः AI-जनित" +verdict_possibly_ai: "AI-जनित हो सकता है" +verdict_unknown: "अज्ञात" +verdict_not_detected: "AI-जनित के रूप में नहीं पहचाना गया" + +# विश्वसनीयता स्तर +confidence_none: "कोई नहीं" +confidence_low: "कम" +confidence_medium: "मध्यम" +confidence_high: "उच्च" + +# आउटपुट UI +output_no_signals: "कोई AI-जनन संकेत नहीं पाया गया।" +output_summary: "परिणाम: %{detected}/%{total} फ़ाइलों में AI संकेत (%{high} उच्च, %{medium} मध्यम, %{low} कम)" +output_type_label: "प्रकार: %{mime}" + +# जानकारी अनुभाग शीर्षक +info_c2pa_header: "=== C2PA मैनिफ़ेस्ट ===" +info_xmp_header: "=== XMP मेटाडेटा ===" +info_exif_header: "=== EXIF डेटा ===" +info_mp4_header: "=== MP4 मेटाडेटा ===" +info_id3_header: "=== ID3 टैग ===" +info_wav_header: "=== WAV मेटाडेटा ===" +info_watermark_header: "=== वॉटरमार्क विश्लेषण ===" +info_no_metadata: "कोई उत्पत्ति मेटाडेटा नहीं मिला।" + +# त्रुटि संदेश +error_generic: "त्रुटि: %{msg}" +error_not_found: "त्रुटि: %{path} नहीं मिला" +error_no_files: "कोई समर्थित फ़ाइल नहीं मिली।" +error_json_serialize: "त्रुटि: JSON क्रमांकन विफल: %{err}" +scanner_not_found: "चेतावनी: %{path} नहीं मिला, छोड़ा जा रहा है" + +# संकेत विवरण +signal_c2pa_claim_generator: "claim_generator AI टूल से मेल खाता है: %{value}" +signal_c2pa_claim_generator_info: "claim_generator_info AI टूल को संदर्भित करता है" +signal_c2pa_digital_source_type: "digitalSourceType = %{value}" +signal_xmp_digital_source_type: "DigitalSourceType = %{value}" +signal_xmp_ai_system_used: "AISystemUsed = %{value}" +signal_xmp_ai_prompt: "AIPromptInformation मौजूद" +signal_xmp_creator_tool: "CreatorTool AI टूल से मेल खाता है: %{value}" +signal_exif_software: "Software = \"%{value}\"" +signal_exif_tag_value: "%{tag} = \"%{value}\"" +signal_exif_tag_references_ai: "%{tag} AI टूल को संदर्भित करता है" +signal_exif_artist_hash: "Artist में हैश-जैसा मान है (%{value}...)" +signal_exif_no_camera: "कोई कैमरा मेटाडेटा (निर्माता, मॉडल, लेंस, एक्सपोज़र) नहीं मिला" +signal_filename_pattern: "फ़ाइलनाम AI टूल पैटर्न से मेल खाता है: %{pattern}" +signal_filename_elevenlabs: "फ़ाइलनाम ElevenLabs नामकरण परंपरा से मेल खाता है" +signal_id3_comment: "ID3 टिप्पणी AI टूल को संदर्भित करती है: %{text}" +signal_id3_url: "ID3 URL AI प्लेटफ़ॉर्म की ओर इंगित करता है: %{url}" +signal_id3_text_frame: "ID3 %{frame} AI टूल से मेल खाता है: %{text}" +signal_id3_txxx: "ID3 TXXX (%{desc}) AI टूल से मेल खाता है: %{value}" +signal_mp4_tool_match: "%{label} AI टूल से मेल खाता है: %{value}" +signal_mp4_aigc_label: "AIGC लेबल AI-जनित सामग्री दर्शाता है" +signal_mp4_aigc_label_id: "AIGC लेबल AI-जनित सामग्री दर्शाता है (ProduceID: %{id})" +signal_mp4_sei_watermark: "H.264 SEI वॉटरमार्क: %{marker}" +signal_png_text_chunk: "PNG %{keyword} AI टूल को संदर्भित करता है" +signal_watermark_detected: "अदृश्य वॉटरमार्क संकेतक पाए गए (%{strength} साक्ष्य): %{indicators}" +signal_watermark_strong: "मजबूत" +signal_watermark_moderate: "मध्यम" +signal_audio_cutoff: "%{freq}Hz पर आवृत्ति कटऑफ़ पाया गया (%{nyquist}Hz नाइक्विस्ट का %{pct}%%) — AI/TTS संश्लेषण की विशेषता" +signal_audio_flatness: "स्पेक्ट्रल समतलता %{value} सिंथेटिक ऑडियो का संकेत देती है (प्राकृतिक वाणी आमतौर पर > 0.05)" +signal_wav_info_tool: "WAV INFO %{key} AI टूल से मेल खाता है: %{value}" +signal_wav_tts_heuristic: "ऑडियो विशेषताएँ TTS का संकेत देती हैं: मोनो %{rate}Hz %{bits}bit" diff --git a/locales/ja.yml b/locales/ja.yml new file mode 100644 index 0000000..1056e4a --- /dev/null +++ b/locales/ja.yml @@ -0,0 +1,66 @@ +# 判定結果 +verdict_ai_generated: "AI生成" +verdict_likely_ai: "AI生成の可能性が高い" +verdict_possibly_ai: "AI生成の可能性あり" +verdict_unknown: "不明" +verdict_not_detected: "AI生成として検出されず" + +# 信頼度 +confidence_none: "なし" +confidence_low: "低" +confidence_medium: "中" +confidence_high: "高" + +# 出力UI +output_no_signals: "AI生成シグナルは検出されませんでした。" +output_summary: "結果:%{detected}/%{total} ファイルにAIシグナルあり(%{high} 高、%{medium} 中、%{low} 低)" +output_type_label: "タイプ:%{mime}" + +# 情報セクションヘッダー +info_c2pa_header: "=== C2PAマニフェスト ===" +info_xmp_header: "=== XMPメタデータ ===" +info_exif_header: "=== EXIFデータ ===" +info_mp4_header: "=== MP4メタデータ ===" +info_id3_header: "=== ID3タグ ===" +info_wav_header: "=== WAVメタデータ ===" +info_watermark_header: "=== 電子透かし分析 ===" +info_no_metadata: "来歴メタデータが見つかりません。" + +# エラーメッセージ +error_generic: "エラー:%{msg}" +error_not_found: "エラー:%{path} が見つかりません" +error_no_files: "対応ファイルが見つかりません。" +error_json_serialize: "エラー:JSONシリアライズに失敗:%{err}" +scanner_not_found: "警告:%{path} が見つかりません。スキップします" + +# シグナル説明 +signal_c2pa_claim_generator: "claim_generatorがAIツールに一致:%{value}" +signal_c2pa_claim_generator_info: "claim_generator_infoがAIツールを参照" +signal_c2pa_digital_source_type: "digitalSourceType = %{value}" +signal_xmp_digital_source_type: "DigitalSourceType = %{value}" +signal_xmp_ai_system_used: "AISystemUsed = %{value}" +signal_xmp_ai_prompt: "AIPromptInformation あり" +signal_xmp_creator_tool: "CreatorToolがAIツールに一致:%{value}" +signal_exif_software: "Software = \"%{value}\"" +signal_exif_tag_value: "%{tag} = \"%{value}\"" +signal_exif_tag_references_ai: "%{tag} がAIツールを参照" +signal_exif_artist_hash: "Artistにハッシュ値を含む(%{value}...)" +signal_exif_no_camera: "カメラメタデータ(メーカー、モデル、レンズ、露出)が見つかりません" +signal_filename_pattern: "ファイル名がAIツールパターンに一致:%{pattern}" +signal_filename_elevenlabs: "ファイル名がElevenLabsの命名規則に一致" +signal_id3_comment: "ID3コメントがAIツールを参照:%{text}" +signal_id3_url: "ID3 URLがAIプラットフォームを指す:%{url}" +signal_id3_text_frame: "ID3 %{frame} がAIツールに一致:%{text}" +signal_id3_txxx: "ID3 TXXX(%{desc})がAIツールに一致:%{value}" +signal_mp4_tool_match: "%{label} がAIツールに一致:%{value}" +signal_mp4_aigc_label: "AIGCラベルがAI生成コンテンツを示す" +signal_mp4_aigc_label_id: "AIGCラベルがAI生成コンテンツを示す(ProduceID:%{id})" +signal_mp4_sei_watermark: "H.264 SEI電子透かし:%{marker}" +signal_png_text_chunk: "PNG %{keyword} がAIツールを参照" +signal_watermark_detected: "不可視電子透かし指標を検出(%{strength}証拠):%{indicators}" +signal_watermark_strong: "強い" +signal_watermark_moderate: "中程度の" +signal_audio_cutoff: "%{freq}Hzで周波数の急激な遮断を検出(ナイキスト周波数%{nyquist}Hzの%{pct}%%)— AI/TTS合成の典型的特徴" +signal_audio_flatness: "スペクトル平坦度 %{value} は合成音声を示唆(自然音声は通常 > 0.05)" +signal_wav_info_tool: "WAV INFO %{key} がAIツールに一致:%{value}" +signal_wav_tts_heuristic: "音声特性がTTSを示唆:モノラル %{rate}Hz %{bits}bit" diff --git a/locales/ko.yml b/locales/ko.yml new file mode 100644 index 0000000..14adcb9 --- /dev/null +++ b/locales/ko.yml @@ -0,0 +1,66 @@ +# 판정 결과 +verdict_ai_generated: "AI 생성" +verdict_likely_ai: "AI 생성 가능성 높음" +verdict_possibly_ai: "AI 생성 가능성 있음" +verdict_unknown: "알 수 없음" +verdict_not_detected: "AI 생성으로 감지되지 않음" + +# 신뢰도 +confidence_none: "없음" +confidence_low: "낮음" +confidence_medium: "중간" +confidence_high: "높음" + +# 출력 UI +output_no_signals: "AI 생성 신호가 감지되지 않았습니다." +output_summary: "결과: %{detected}/%{total} 파일에서 AI 신호 감지 (%{high} 높음, %{medium} 중간, %{low} 낮음)" +output_type_label: "유형: %{mime}" + +# 정보 섹션 헤더 +info_c2pa_header: "=== C2PA 매니페스트 ===" +info_xmp_header: "=== XMP 메타데이터 ===" +info_exif_header: "=== EXIF 데이터 ===" +info_mp4_header: "=== MP4 메타데이터 ===" +info_id3_header: "=== ID3 태그 ===" +info_wav_header: "=== WAV 메타데이터 ===" +info_watermark_header: "=== 워터마크 분석 ===" +info_no_metadata: "출처 메타데이터를 찾을 수 없습니다." + +# 오류 메시지 +error_generic: "오류: %{msg}" +error_not_found: "오류: %{path} 을(를) 찾을 수 없습니다" +error_no_files: "지원되는 파일을 찾을 수 없습니다." +error_json_serialize: "오류: JSON 직렬화 실패: %{err}" +scanner_not_found: "경고: %{path} 을(를) 찾을 수 없습니다. 건너뜁니다" + +# 신호 설명 +signal_c2pa_claim_generator: "claim_generator가 AI 도구와 일치: %{value}" +signal_c2pa_claim_generator_info: "claim_generator_info가 AI 도구를 참조" +signal_c2pa_digital_source_type: "digitalSourceType = %{value}" +signal_xmp_digital_source_type: "DigitalSourceType = %{value}" +signal_xmp_ai_system_used: "AISystemUsed = %{value}" +signal_xmp_ai_prompt: "AIPromptInformation 존재" +signal_xmp_creator_tool: "CreatorTool이 AI 도구와 일치: %{value}" +signal_exif_software: "Software = \"%{value}\"" +signal_exif_tag_value: "%{tag} = \"%{value}\"" +signal_exif_tag_references_ai: "%{tag}이(가) AI 도구를 참조" +signal_exif_artist_hash: "Artist에 해시 값 포함 (%{value}...)" +signal_exif_no_camera: "카메라 메타데이터(제조사, 모델, 렌즈, 노출)를 찾을 수 없음" +signal_filename_pattern: "파일명이 AI 도구 패턴과 일치: %{pattern}" +signal_filename_elevenlabs: "파일명이 ElevenLabs 명명 규칙과 일치" +signal_id3_comment: "ID3 댓글이 AI 도구를 참조: %{text}" +signal_id3_url: "ID3 URL이 AI 플랫폼을 가리킴: %{url}" +signal_id3_text_frame: "ID3 %{frame}이(가) AI 도구와 일치: %{text}" +signal_id3_txxx: "ID3 TXXX (%{desc})이(가) AI 도구와 일치: %{value}" +signal_mp4_tool_match: "%{label}이(가) AI 도구와 일치: %{value}" +signal_mp4_aigc_label: "AIGC 라벨이 AI 생성 콘텐츠를 나타냄" +signal_mp4_aigc_label_id: "AIGC 라벨이 AI 생성 콘텐츠를 나타냄 (ProduceID: %{id})" +signal_mp4_sei_watermark: "H.264 SEI 워터마크: %{marker}" +signal_png_text_chunk: "PNG %{keyword}이(가) AI 도구를 참조" +signal_watermark_detected: "비가시 워터마크 지표 감지 (%{strength} 증거): %{indicators}" +signal_watermark_strong: "강한" +signal_watermark_moderate: "중간" +signal_audio_cutoff: "%{freq}Hz에서 주파수 급격한 차단 감지 (나이퀴스트 주파수 %{nyquist}Hz의 %{pct}%%) — AI/TTS 합성의 전형적 특성" +signal_audio_flatness: "스펙트럼 평탄도 %{value}는 합성 오디오를 시사 (자연 음성은 일반적으로 > 0.05)" +signal_wav_info_tool: "WAV INFO %{key}이(가) AI 도구와 일치: %{value}" +signal_wav_tts_heuristic: "오디오 특성이 TTS를 시사: 모노 %{rate}Hz %{bits}bit" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml new file mode 100644 index 0000000..8b476da --- /dev/null +++ b/locales/zh-CN.yml @@ -0,0 +1,66 @@ +# 判定结果 +verdict_ai_generated: "AI 生成" +verdict_likely_ai: "可能为 AI 生成" +verdict_possibly_ai: "疑似 AI 生成" +verdict_unknown: "未知" +verdict_not_detected: "未检测到 AI 生成痕迹" + +# 置信度 +confidence_none: "无" +confidence_low: "低" +confidence_medium: "中" +confidence_high: "高" + +# 输出界面 +output_no_signals: "未检测到 AI 生成信号。" +output_summary: "结果:%{detected}/%{total} 个文件检测到 AI 信号(%{high} 高、%{medium} 中、%{low} 低)" +output_type_label: "类型:%{mime}" + +# 信息区段标题 +info_c2pa_header: "=== C2PA 清单 ===" +info_xmp_header: "=== XMP 元数据 ===" +info_exif_header: "=== EXIF 数据 ===" +info_mp4_header: "=== MP4 元数据 ===" +info_id3_header: "=== ID3 标签 ===" +info_wav_header: "=== WAV 元数据 ===" +info_watermark_header: "=== 水印分析 ===" +info_no_metadata: "未找到来源元数据。" + +# 错误信息 +error_generic: "错误:%{msg}" +error_not_found: "错误:%{path} 未找到" +error_no_files: "未找到支持的文件。" +error_json_serialize: "错误:JSON 序列化失败:%{err}" +scanner_not_found: "警告:%{path} 未找到,已跳过" + +# 信号描述 +signal_c2pa_claim_generator: "claim_generator 匹配 AI 工具:%{value}" +signal_c2pa_claim_generator_info: "claim_generator_info 引用了 AI 工具" +signal_c2pa_digital_source_type: "digitalSourceType = %{value}" +signal_xmp_digital_source_type: "DigitalSourceType = %{value}" +signal_xmp_ai_system_used: "AISystemUsed = %{value}" +signal_xmp_ai_prompt: "存在 AIPromptInformation" +signal_xmp_creator_tool: "CreatorTool 匹配 AI 工具:%{value}" +signal_exif_software: "Software = \"%{value}\"" +signal_exif_tag_value: "%{tag} = \"%{value}\"" +signal_exif_tag_references_ai: "%{tag} 引用了 AI 工具" +signal_exif_artist_hash: "Artist 包含哈希值(%{value}...)" +signal_exif_no_camera: "未找到相机元数据(制造商、型号、镜头、曝光)" +signal_filename_pattern: "文件名匹配 AI 工具模式:%{pattern}" +signal_filename_elevenlabs: "文件名匹配 ElevenLabs 命名规则" +signal_id3_comment: "ID3 注释引用了 AI 工具:%{text}" +signal_id3_url: "ID3 URL 指向 AI 平台:%{url}" +signal_id3_text_frame: "ID3 %{frame} 匹配 AI 工具:%{text}" +signal_id3_txxx: "ID3 TXXX(%{desc})匹配 AI 工具:%{value}" +signal_mp4_tool_match: "%{label} 匹配 AI 工具:%{value}" +signal_mp4_aigc_label: "AIGC 标签表明内容为 AI 生成" +signal_mp4_aigc_label_id: "AIGC 标签表明内容为 AI 生成(ProduceID:%{id})" +signal_mp4_sei_watermark: "H.264 SEI 水印:%{marker}" +signal_png_text_chunk: "PNG %{keyword} 引用了 AI 工具" +signal_watermark_detected: "检测到隐形水印指标(%{strength}证据):%{indicators}" +signal_watermark_strong: "强" +signal_watermark_moderate: "中等" +signal_audio_cutoff: "在 %{freq}Hz 处检测到频率硬截断(奈奎斯特频率 %{nyquist}Hz 的 %{pct}%%)— 为 AI/TTS 合成的典型特征" +signal_audio_flatness: "频谱平坦度 %{value} 表明为合成音频(自然语音通常 > 0.05)" +signal_wav_info_tool: "WAV INFO %{key} 匹配 AI 工具:%{value}" +signal_wav_tts_heuristic: "音频特征表明为 TTS:单声道 %{rate}Hz %{bits}bit" diff --git a/src/cli.rs b/src/cli.rs index 92d4fb1..7a696a5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,6 +22,10 @@ pub struct Cli { /// Disable colored output #[arg(long, global = true)] pub no_color: bool, + + /// Override display language (e.g., en, zh-CN, de, ja, ko, hi, es) + #[arg(long, global = true)] + pub lang: Option, } #[derive(Subcommand)] diff --git a/src/detector/audio_spectral.rs b/src/detector/audio_spectral.rs index 5c08308..09823ab 100644 --- a/src/detector/audio_spectral.rs +++ b/src/detector/audio_spectral.rs @@ -4,254 +4,130 @@ use std::fs; use std::path::Path; use super::wav_metadata; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; -/// FFT window size (number of samples per frame). const FFT_SIZE: usize = 2048; - -/// Number of FFT frames to average over for stable results. -/// Analyze ~2 seconds of audio from the middle of the file. const MAX_FRAMES: usize = 64; - -/// Fraction of Nyquist bandwidth that must contain energy for "full bandwidth" audio. -/// If energy is concentrated below this fraction, the audio may be AI-generated -/// (many TTS/music models output at lower effective bandwidths). const BANDWIDTH_THRESHOLD: f64 = 0.7; - -/// Minimum energy ratio between used and unused bands to flag bandwidth cutoff. -/// If the top portion of the spectrum has < this fraction of the lower portion's energy, -/// it's a hard cutoff. const CUTOFF_ENERGY_RATIO: f64 = 0.02; -// --------------------------------------------------------------------------- -// PCM decoding -// --------------------------------------------------------------------------- - -/// Decode 16-bit little-endian PCM bytes to f64 samples normalized to [-1, 1]. fn decode_pcm_16le(data: &[u8], channels: u16) -> Vec { let bytes_per_sample = 2usize; let block_align = bytes_per_sample * channels as usize; let num_blocks = data.len() / block_align; - let mut samples = Vec::with_capacity(num_blocks); for i in 0..num_blocks { - // Take first channel only (mono mix) let offset = i * block_align; - if offset + 2 > data.len() { - break; - } + if offset + 2 > data.len() { break; } let raw = i16::from_le_bytes([data[offset], data[offset + 1]]); samples.push(raw as f64 / 32768.0); } samples } -// --------------------------------------------------------------------------- -// Spectral analysis -// --------------------------------------------------------------------------- - -/// Compute average power spectrum from the middle of the audio. fn compute_avg_spectrum(samples: &[f64], fft_size: usize) -> Vec { - if samples.len() < fft_size { - return vec![]; - } - + if samples.len() < fft_size { return vec![]; } let mut planner = FftPlanner::::new(); let fft = planner.plan_fft_forward(fft_size); - - // Start from the middle of the audio (skip silence at start/end) let mid = samples.len() / 2; let half_window = (MAX_FRAMES * fft_size) / 2; let start = mid.saturating_sub(half_window); let available = &samples[start..]; - let num_bins = fft_size / 2; let mut avg_power = vec![0.0f64; num_bins]; let mut frame_count = 0usize; - - let hop = fft_size / 2; // 50% overlap + let hop = fft_size / 2; let mut pos = 0; - while pos + fft_size <= available.len() && frame_count < MAX_FRAMES { - let mut buffer: Vec> = available[pos..pos + fft_size] - .iter() - .enumerate() + let mut buffer: Vec> = available[pos..pos + fft_size].iter().enumerate() .map(|(i, &s)| { - // Apply Hann window let w = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / (fft_size - 1) as f64).cos()); Complex::new(s * w, 0.0) - }) - .collect(); - + }).collect(); fft.process(&mut buffer); - - for (bin, power) in avg_power.iter_mut().enumerate() { - *power += buffer[bin].norm_sqr(); - } - + for (bin, power) in avg_power.iter_mut().enumerate() { *power += buffer[bin].norm_sqr(); } frame_count += 1; pos += hop; } - - if frame_count == 0 { - return vec![]; - } - - for power in avg_power.iter_mut() { - *power /= frame_count as f64; - } - + if frame_count == 0 { return vec![]; } + for power in avg_power.iter_mut() { *power /= frame_count as f64; } avg_power } -/// Find the effective bandwidth cutoff frequency. -/// Returns the frequency (Hz) where energy drops sharply, or None if full bandwidth. fn find_bandwidth_cutoff(spectrum: &[f64], sample_rate: u32) -> Option<(f64, f64)> { - if spectrum.is_empty() { - return None; - } - + if spectrum.is_empty() { return None; } let num_bins = spectrum.len(); let nyquist = sample_rate as f64 / 2.0; let bin_hz = nyquist / num_bins as f64; - - // Find the bin where cumulative energy reaches 99% of total let total_energy: f64 = spectrum.iter().sum(); - if total_energy == 0.0 { - return None; - } - + if total_energy == 0.0 { return None; } let mut cumulative = 0.0; let mut cutoff_bin = num_bins; for (i, &power) in spectrum.iter().enumerate() { cumulative += power; - if cumulative >= total_energy * 0.99 { - cutoff_bin = i + 1; - break; - } + if cumulative >= total_energy * 0.99 { cutoff_bin = i + 1; break; } } - let cutoff_freq = cutoff_bin as f64 * bin_hz; let bandwidth_ratio = cutoff_freq / nyquist; - - // Check for hard cutoff: compare energy above and below cutoff if bandwidth_ratio < BANDWIDTH_THRESHOLD { let below_energy: f64 = spectrum[..cutoff_bin].iter().sum(); let above_energy: f64 = spectrum[cutoff_bin..].iter().sum(); - let ratio = if below_energy > 0.0 { - above_energy / below_energy - } else { - 0.0 - }; - - if ratio < CUTOFF_ENERGY_RATIO { - return Some((cutoff_freq, bandwidth_ratio)); - } + let ratio = if below_energy > 0.0 { above_energy / below_energy } else { 0.0 }; + if ratio < CUTOFF_ENERGY_RATIO { return Some((cutoff_freq, bandwidth_ratio)); } } - None } -/// Compute spectral flatness (Wiener entropy). -/// Values close to 1.0 = noise-like, close to 0.0 = tonal. -/// AI-generated audio often has different flatness than natural audio. fn spectral_flatness(spectrum: &[f64]) -> f64 { let n = spectrum.len() as f64; - if n == 0.0 { - return 0.0; - } - - // Filter out zero/near-zero bins to avoid log(0) + if n == 0.0 { return 0.0; } let filtered: Vec = spectrum.iter().copied().filter(|&x| x > 1e-20).collect(); - if filtered.is_empty() { - return 0.0; - } - + if filtered.is_empty() { return 0.0; } let n = filtered.len() as f64; let log_mean = filtered.iter().map(|x| x.ln()).sum::() / n; let geometric_mean = log_mean.exp(); let arithmetic_mean = filtered.iter().sum::() / n; - - if arithmetic_mean > 0.0 { - geometric_mean / arithmetic_mean - } else { - 0.0 - } + if arithmetic_mean > 0.0 { geometric_mean / arithmetic_mean } else { 0.0 } } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Detect AI signals from audio spectral analysis on WAV files. pub fn detect(path: &Path) -> Result> { let data = fs::read(path)?; let wav = match wav_metadata::parse_wav_full(&data) { Some(w) => w, None => return Ok(vec![]), }; - - // Only support 16-bit PCM for now - if wav.fmt.bits_per_sample != 16 || wav.pcm_start >= wav.pcm_end { - return Ok(vec![]); - } - + if wav.fmt.bits_per_sample != 16 || wav.pcm_start >= wav.pcm_end { return Ok(vec![]); } let pcm_data = &data[wav.pcm_start..wav.pcm_end]; let samples = decode_pcm_16le(pcm_data, wav.fmt.channels); - - if samples.len() < FFT_SIZE { - return Ok(vec![]); - } - + if samples.len() < FFT_SIZE { return Ok(vec![]); } let spectrum = compute_avg_spectrum(&samples, FFT_SIZE); - if spectrum.is_empty() { - return Ok(vec![]); - } + if spectrum.is_empty() { return Ok(vec![]); } let mut signals = Vec::new(); - // Check for hard frequency cutoff if let Some((cutoff_freq, bandwidth_ratio)) = find_bandwidth_cutoff(&spectrum, wav.fmt.sample_rate) { let nyquist = wav.fmt.sample_rate as f64 / 2.0; - signals.push(Signal { - source: SignalSource::AudioSpectral, - confidence: Confidence::Low, - description: format!( - "Hard frequency cutoff at {:.0}Hz ({:.0}% of {:.0}Hz Nyquist) — typical of AI/TTS synthesis", - cutoff_freq, - bandwidth_ratio * 100.0, - nyquist, - ), - tool: None, - details: vec![ - ("cutoff_frequency".to_string(), format!("{:.0}Hz", cutoff_freq)), - ("nyquist".to_string(), format!("{:.0}Hz", nyquist)), - ("bandwidth_used".to_string(), format!("{:.1}%", bandwidth_ratio * 100.0)), - ], - }); + signals.push( + SignalBuilder::new(SignalSource::AudioSpectral, Confidence::Low, "signal_audio_cutoff") + .param("freq", format!("{:.0}", cutoff_freq)) + .param("pct", format!("{:.0}", bandwidth_ratio * 100.0)) + .param("nyquist", format!("{:.0}", nyquist)) + .detail("cutoff_frequency", format!("{:.0}Hz", cutoff_freq)) + .detail("nyquist", format!("{:.0}Hz", nyquist)) + .detail("bandwidth_used", format!("{:.1}%", bandwidth_ratio * 100.0)) + .build(), + ); } - // Check spectral flatness - // Very low flatness in speech range can indicate synthetic voice - // (natural speech has more spectral variation across frames) let flatness = spectral_flatness(&spectrum); let nyquist = wav.fmt.sample_rate as f64 / 2.0; - - // For speech-range audio (Nyquist <= 12kHz), unusually low flatness - // combined with mono suggests TTS if nyquist <= 12000.0 && wav.fmt.channels == 1 && flatness < 0.05 { - signals.push(Signal { - source: SignalSource::AudioSpectral, - confidence: Confidence::Low, - description: format!( - "Spectral flatness {:.4} suggests synthetic audio (natural speech typically > 0.05)", - flatness, - ), - tool: None, - details: vec![ - ("spectral_flatness".to_string(), format!("{:.4}", flatness)), - ], - }); + signals.push( + SignalBuilder::new(SignalSource::AudioSpectral, Confidence::Low, "signal_audio_flatness") + .param("value", format!("{:.4}", flatness)) + .detail("spectral_flatness", format!("{:.4}", flatness)) + .build(), + ); } Ok(signals) @@ -263,7 +139,6 @@ mod tests { #[test] fn test_decode_pcm_16le() { - // Silence: all zeros let data = vec![0u8; 200]; let samples = decode_pcm_16le(&data, 1); assert_eq!(samples.len(), 100); @@ -272,26 +147,21 @@ mod tests { #[test] fn test_decode_pcm_16le_stereo() { - // Stereo: takes first channel - let data = vec![0u8; 400]; // 100 stereo samples + let data = vec![0u8; 400]; let samples = decode_pcm_16le(&data, 2); assert_eq!(samples.len(), 100); } #[test] fn test_spectral_flatness_pure_tone() { - // Pure tone at one frequency should have very low flatness let mut spectrum = vec![0.0; 1024]; - spectrum[100] = 1.0; // Single peak + spectrum[100] = 1.0; let flatness = spectral_flatness(&spectrum); - // With only one non-zero bin, flatness should be 1.0 (single value) - // but with more peaks it decreases assert!(flatness <= 1.0); } #[test] fn test_spectral_flatness_white_noise() { - // Uniform spectrum should have high flatness (close to 1.0) let spectrum = vec![1.0; 1024]; let flatness = spectral_flatness(&spectrum); assert!((flatness - 1.0).abs() < 0.01); @@ -299,7 +169,6 @@ mod tests { #[test] fn test_find_bandwidth_cutoff_full() { - // Flat spectrum = full bandwidth, no cutoff let spectrum = vec![1.0; 1024]; let result = find_bandwidth_cutoff(&spectrum, 48000); assert!(result.is_none()); @@ -307,15 +176,12 @@ mod tests { #[test] fn test_find_bandwidth_cutoff_half() { - // Energy only in lower third = sharp cutoff detected let mut spectrum = vec![0.0; 1024]; - for i in 0..300 { - spectrum[i] = 1.0; - } + for i in 0..300 { spectrum[i] = 1.0; } let result = find_bandwidth_cutoff(&spectrum, 48000); assert!(result.is_some()); let (freq, ratio) = result.unwrap(); - assert!(freq < 12000.0); // Should be below Nyquist (24kHz) + assert!(freq < 12000.0); assert!(ratio < BANDWIDTH_THRESHOLD); } @@ -324,13 +190,12 @@ mod tests { let samples = vec![0.0; FFT_SIZE * 4]; let spectrum = compute_avg_spectrum(&samples, FFT_SIZE); assert!(!spectrum.is_empty()); - // Silence should have near-zero energy everywhere assert!(spectrum.iter().all(|&x| x < 1e-10)); } #[test] fn test_compute_avg_spectrum_too_short() { - let samples = vec![0.0; 100]; // Too short for FFT + let samples = vec![0.0; 100]; let spectrum = compute_avg_spectrum(&samples, FFT_SIZE); assert!(spectrum.is_empty()); } diff --git a/src/detector/c2pa_detector.rs b/src/detector/c2pa_detector.rs index 15c58b7..924eca8 100644 --- a/src/detector/c2pa_detector.rs +++ b/src/detector/c2pa_detector.rs @@ -3,7 +3,7 @@ use c2pa::assertions::{Actions, DigitalSourceType}; use c2pa::Reader; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; use crate::known_tools; /// AI-related digital source types that indicate AI generation. @@ -46,9 +46,6 @@ pub fn detect(path: &Path) -> Result> { let mut signals = Vec::new(); - // Check all manifests — active manifest may not contain the AI generation action - // (e.g., GPT images have a parent manifest with c2pa.created + digitalSourceType - // and a child manifest with c2pa.opened) for manifest in reader.manifests().values() { check_manifest(manifest, &mut signals); } @@ -61,13 +58,13 @@ fn check_manifest(manifest: &c2pa::Manifest, signals: &mut Vec) { // Check claim_generator for known AI tools if let Some(cg) = manifest.claim_generator() { if let Some(tool_name) = known_tools::match_ai_tool(cg) { - signals.push(Signal { - source: SignalSource::C2pa, - confidence: Confidence::High, - description: format!("claim_generator matches AI tool: {}", cg), - tool: Some(tool_name.to_string()), - details: vec![("claim_generator".into(), cg.to_string())], - }); + signals.push( + SignalBuilder::new(SignalSource::C2pa, Confidence::High, "signal_c2pa_claim_generator") + .param("value", cg) + .tool(tool_name) + .detail("claim_generator", cg) + .build(), + ); } } @@ -76,22 +73,19 @@ fn check_manifest(manifest: &c2pa::Manifest, signals: &mut Vec) { for info in info_list { let info_json = serde_json::to_string(info).unwrap_or_default(); if let Some(tool_name) = known_tools::match_ai_tool(&info_json) { - signals.push(Signal { - source: SignalSource::C2pa, - confidence: Confidence::High, - description: "claim_generator_info references AI tool".to_string(), - tool: Some(tool_name.to_string()), - details: vec![("claim_generator_info".into(), info_json)], - }); + signals.push( + SignalBuilder::new(SignalSource::C2pa, Confidence::High, "signal_c2pa_claim_generator_info") + .tool(tool_name) + .detail("claim_generator_info", &info_json) + .build(), + ); } } } // Check actions assertions for digitalSourceType - // Use the actual assertion labels present in the manifest to avoid duplicates let mut checked_labels = Vec::new(); for label in &[Actions::LABEL, "c2pa.actions.v2"] { - // Skip if we already found signals from this assertion (v1/v2 can overlap) if checked_labels.iter().any(|l: &&str| l == label) { continue; } @@ -109,20 +103,19 @@ fn check_manifest(manifest: &c2pa::Manifest, signals: &mut Vec) { details.push(("softwareAgent".into(), sw_str)); } - signals.push(Signal { - source: SignalSource::C2pa, - confidence, - description: format!("digitalSourceType = {}", desc), - tool: action.software_agent().and_then(|sw| { - let sw_str = serde_json::to_string(sw).unwrap_or_default(); - known_tools::match_ai_tool(&sw_str).map(|s| s.to_string()) - }), - details, - }); + signals.push( + SignalBuilder::new(SignalSource::C2pa, confidence, "signal_c2pa_digital_source_type") + .param("value", desc) + .tool_opt(action.software_agent().and_then(|sw| { + let sw_str = serde_json::to_string(sw).unwrap_or_default(); + known_tools::match_ai_tool(&sw_str).map(|s| s.to_string()) + })) + .details(details) + .build(), + ); } } } - // If we found actions with v1 label, skip v2 (they return same data) break; } } diff --git a/src/detector/exif.rs b/src/detector/exif.rs index 2ddb38c..e6ec481 100644 --- a/src/detector/exif.rs +++ b/src/detector/exif.rs @@ -4,7 +4,7 @@ use std::fs::File; use std::io::BufReader; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; use crate::known_tools; /// Camera-specific EXIF tags that real photos typically have. @@ -26,7 +26,7 @@ pub fn detect(path: &Path) -> Result> { let file = File::open(path)?; let exif = match Reader::new().read_from_container(&mut BufReader::new(file)) { Ok(e) => e, - Err(_) => return Ok(vec![]), // No EXIF — not an error + Err(_) => return Ok(vec![]), }; let mut signals = Vec::new(); @@ -36,29 +36,30 @@ pub fn detect(path: &Path) -> Result> { if let Some(field) = exif.get_field(Tag::Software, In::PRIMARY) { let sw = field.display_value().to_string().replace('"', ""); if let Some(tool_name) = known_tools::match_ai_tool(&sw) { - signals.push(Signal { - source: SignalSource::Exif, - confidence: Confidence::Low, - description: format!("Software = \"{}\"", sw), - tool: Some(tool_name.to_string()), - details: vec![("Software".into(), sw)], - }); + signals.push( + SignalBuilder::new(SignalSource::Exif, Confidence::Low, "signal_exif_software") + .param("value", &sw) + .tool(tool_name) + .detail("Software", &sw) + .build(), + ); software_matched = true; } } - // Check Make / Model tags for known AI tools (e.g. Ideogram sets Make="Ideogram AI") + // Check Make / Model tags for known AI tools for tag in &[Tag::Make, Tag::Model] { if let Some(field) = exif.get_field(*tag, In::PRIMARY) { let val = field.display_value().to_string().replace('"', ""); if let Some(tool_name) = known_tools::match_ai_tool(&val) { - signals.push(Signal { - source: SignalSource::Exif, - confidence: Confidence::Low, - description: format!("{} = \"{}\"", tag, val), - tool: Some(tool_name.to_string()), - details: vec![(tag.to_string(), val)], - }); + signals.push( + SignalBuilder::new(SignalSource::Exif, Confidence::Low, "signal_exif_tag_value") + .param("tag", tag.to_string()) + .param("value", &val) + .tool(tool_name) + .detail(tag.to_string(), &val) + .build(), + ); software_matched = true; } } @@ -69,39 +70,36 @@ pub fn detect(path: &Path) -> Result> { if let Some(field) = exif.get_field(*tag, In::PRIMARY) { let val = field.display_value().to_string().replace('"', ""); if let Some(tool_name) = known_tools::match_ai_tool(&val) { - signals.push(Signal { - source: SignalSource::Exif, - confidence: Confidence::Low, - description: format!("{} references AI tool", tag), - tool: Some(tool_name.to_string()), - details: vec![(tag.to_string(), val)], - }); + signals.push( + SignalBuilder::new(SignalSource::Exif, Confidence::Low, "signal_exif_tag_references_ai") + .param("tag", tag.to_string()) + .tool(tool_name) + .detail(tag.to_string(), &val) + .build(), + ); software_matched = true; } } } - // Check Artist tag for suspicious patterns (hex hashes suggest AI pipeline) + // Check Artist tag for suspicious patterns if let Some(field) = exif.get_field(Tag::Artist, In::PRIMARY) { let val = field.display_value().to_string().replace('"', ""); let is_hex_hash = val.len() >= 32 && val.chars().all(|c| c.is_ascii_hexdigit() || c == '-'); if is_hex_hash { - signals.push(Signal { - source: SignalSource::Exif, - confidence: Confidence::Low, - description: format!( - "Artist contains hash-like value ({}...)", - &val[..val.len().min(16)] - ), - tool: None, - details: vec![("Artist".into(), val)], - }); + let prefix = &val[..val.len().min(16)]; + signals.push( + SignalBuilder::new(SignalSource::Exif, Confidence::Low, "signal_exif_artist_hash") + .param("value", prefix) + .detail("Artist", &val) + .build(), + ); software_matched = true; } } - // Camera absence heuristic — only flag if Software also matched + // Camera absence heuristic if software_matched { let camera_tag_count = CAMERA_TAGS .iter() @@ -109,13 +107,11 @@ pub fn detect(path: &Path) -> Result> { .count(); if camera_tag_count == 0 { - signals.push(Signal { - source: SignalSource::Exif, - confidence: Confidence::Low, - description: "No camera metadata (Make, Model, lens, exposure) found".to_string(), - tool: None, - details: vec![("camera_tags_present".into(), "0".into())], - }); + signals.push( + SignalBuilder::new(SignalSource::Exif, Confidence::Low, "signal_exif_no_camera") + .detail("camera_tags_present", "0") + .build(), + ); } } diff --git a/src/detector/filename.rs b/src/detector/filename.rs index ac9cbd9..ce53b8c 100644 --- a/src/detector/filename.rs +++ b/src/detector/filename.rs @@ -1,10 +1,9 @@ use anyhow::Result; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; /// Known filename patterns from AI audio/media generation tools. -/// Format: (pattern prefix or substring, tool name, is_regex) const FILENAME_PATTERNS: &[(&str, &str)] = &[ ("elevenlabs_", "elevenlabs"), ("suno_", "suno"), @@ -13,7 +12,6 @@ const FILENAME_PATTERNS: &[(&str, &str)] = &[ ("mubert_", "mubert"), ("boomy_", "boomy"), ("beatoven_", "beatoven"), - // Image/video tools with distinctive filenames ("dall-e", "dall-e"), ("dalle", "dall-e"), ("midjourney", "midjourney"), @@ -31,46 +29,39 @@ pub fn detect(path: &Path) -> Result> { let lower = filename.to_lowercase(); let mut signals = Vec::new(); - // Check against known filename patterns for &(pattern, tool_name) in FILENAME_PATTERNS { if lower.contains(pattern) { - signals.push(Signal { - source: SignalSource::Filename, - confidence: Confidence::Low, - description: format!("Filename matches AI tool pattern: {}", pattern), - tool: Some(tool_name.to_string()), - details: vec![("filename".to_string(), filename.to_string())], - }); - break; // One match is enough + signals.push( + SignalBuilder::new(SignalSource::Filename, Confidence::Low, "signal_filename_pattern") + .param("pattern", pattern) + .tool(tool_name) + .detail("filename", filename) + .build(), + ); + break; } } - // ElevenLabs specific pattern: ElevenLabs_YYYY-MM-DDTHH_MM_SS_* if signals.is_empty() && detect_elevenlabs_pattern(&lower) { - signals.push(Signal { - source: SignalSource::Filename, - confidence: Confidence::Low, - description: "Filename matches ElevenLabs naming convention".to_string(), - tool: Some("elevenlabs".to_string()), - details: vec![("filename".to_string(), filename.to_string())], - }); + signals.push( + SignalBuilder::new(SignalSource::Filename, Confidence::Low, "signal_filename_elevenlabs") + .tool("elevenlabs") + .detail("filename", filename) + .build(), + ); } Ok(signals) } -/// Check for ElevenLabs timestamp pattern: elevenlabs_YYYY-MM-DDTHH_MM_SS_ fn detect_elevenlabs_pattern(lower: &str) -> bool { if !lower.starts_with("elevenlabs_") { return false; } let rest = &lower["elevenlabs_".len()..]; - // Expect: YYYY-MM-DDTHH_MM_SS_ - // Minimum: 2024-01-01T00_00_00_ = 20 chars if rest.len() < 20 { return false; } - // Check date-time format loosely let bytes = rest.as_bytes(); bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b't' && bytes[13] == b'_' && bytes[16] == b'_' } diff --git a/src/detector/id3_metadata.rs b/src/detector/id3_metadata.rs index 4ab93d0..9deeadf 100644 --- a/src/detector/id3_metadata.rs +++ b/src/detector/id3_metadata.rs @@ -2,7 +2,7 @@ use anyhow::Result; use id3::{Tag, TagLike}; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; use crate::known_tools; /// Known AI audio platform URL domains. @@ -15,39 +15,28 @@ const AI_URL_DOMAINS: &[(&str, &str)] = &[ ("mubert.com", "mubert"), ]; -// --------------------------------------------------------------------------- -// Detection methods -// --------------------------------------------------------------------------- - -/// Detect AI signals from comment frames (COMM). fn detect_comments(tag: &Tag) -> Vec { let mut signals = Vec::new(); - for comment in tag.comments() { let text = &comment.text; if text.is_empty() { continue; } - if let Some(tool_name) = known_tools::match_ai_tool(text) { - signals.push(Signal { - source: SignalSource::Id3Metadata, - confidence: Confidence::Medium, - description: format!("ID3 comment references AI tool: {}", text), - tool: Some(tool_name.to_string()), - details: vec![("COMM".to_string(), text.clone())], - }); + signals.push( + SignalBuilder::new(SignalSource::Id3Metadata, Confidence::Medium, "signal_id3_comment") + .param("text", text.as_str()) + .tool(tool_name) + .detail("COMM", text.as_str()) + .build(), + ); } } - signals } -/// Detect AI signals from URL frames (WOAS, WOAF, WXXX, etc.). fn detect_urls(tag: &Tag) -> Vec { let mut signals = Vec::new(); - - // Collect all URL-like text from known URL frame IDs let url_frame_ids = ["WOAS", "WOAF", "WOAR", "WORS", "WPUB"]; for frame in tag.frames() { let frame_id = frame.id(); @@ -58,13 +47,10 @@ fn detect_urls(tag: &Tag) -> Vec { check_url(&mut signals, frame_id, link); } } - - // Extended URL frames (WXXX) for ext_link in tag.extended_links() { let url = &ext_link.link; check_url(&mut signals, "WXXX", url); } - signals } @@ -72,86 +58,70 @@ fn check_url(signals: &mut Vec, frame_id: &str, url: &str) { let lower = url.to_lowercase(); for &(domain, tool_name) in AI_URL_DOMAINS { if lower.contains(domain) { - signals.push(Signal { - source: SignalSource::Id3Metadata, - confidence: Confidence::Medium, - description: format!("ID3 URL points to AI platform: {}", url), - tool: Some(tool_name.to_string()), - details: vec![(frame_id.to_string(), url.to_string())], - }); + signals.push( + SignalBuilder::new(SignalSource::Id3Metadata, Confidence::Medium, "signal_id3_url") + .param("url", url) + .tool(tool_name) + .detail(frame_id, url) + .build(), + ); break; } } } -/// Detect AI signals from text frames (TENC, TPUB, TXXX). fn detect_text_frames(tag: &Tag) -> Vec { let mut signals = Vec::new(); - - // Standard text frames that may identify encoding software let check_frames = ["TENC", "TPUB", "TSSE"]; for frame_id in &check_frames { if let Some(text) = tag.get(frame_id).and_then(|f| f.content().text()) { if let Some(tool_name) = known_tools::match_ai_tool(text) { - signals.push(Signal { - source: SignalSource::Id3Metadata, - confidence: Confidence::Medium, - description: format!("ID3 {} matches AI tool: {}", frame_id, text), - tool: Some(tool_name.to_string()), - details: vec![(frame_id.to_string(), text.to_string())], - }); + signals.push( + SignalBuilder::new(SignalSource::Id3Metadata, Confidence::Medium, "signal_id3_text_frame") + .param("frame", *frame_id) + .param("text", text) + .tool(tool_name) + .detail(*frame_id, text) + .build(), + ); } } } - - // User-defined text frames (TXXX) for txxx in tag.extended_texts() { let combined = format!("{} {}", txxx.description, txxx.value); if let Some(tool_name) = known_tools::match_ai_tool(&combined) { - signals.push(Signal { - source: SignalSource::Id3Metadata, - confidence: Confidence::Medium, - description: format!("ID3 TXXX ({}) matches AI tool: {}", txxx.description, txxx.value), - tool: Some(tool_name.to_string()), - details: vec![ - ("TXXX description".to_string(), txxx.description.clone()), - ("TXXX value".to_string(), txxx.value.clone()), - ], - }); + signals.push( + SignalBuilder::new(SignalSource::Id3Metadata, Confidence::Medium, "signal_id3_txxx") + .param("desc", &txxx.description) + .param("value", &txxx.value) + .tool(tool_name) + .detail("TXXX description", &txxx.description) + .detail("TXXX value", &txxx.value) + .build(), + ); } } - signals } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Detect AI signals from ID3 tags in audio files. pub fn detect(path: &Path) -> Result> { let tag = match Tag::read_from_path(path) { Ok(t) => t, Err(_) => return Ok(vec![]), }; - let mut signals = Vec::new(); signals.extend(detect_comments(&tag)); signals.extend(detect_urls(&tag)); signals.extend(detect_text_frames(&tag)); - Ok(signals) } -/// Dump all ID3 tags for the `info` subcommand. pub fn dump_info(path: &Path) -> Result> { let tag = match Tag::read_from_path(path) { Ok(t) => t, Err(_) => return Ok(vec![]), }; - let mut props = Vec::new(); - if let Some(title) = tag.title() { props.push(("Title (TIT2)".to_string(), title.to_string())); } @@ -161,16 +131,12 @@ pub fn dump_info(path: &Path) -> Result> { if let Some(album) = tag.album() { props.push(("Album (TALB)".to_string(), album.to_string())); } - - // All text frames let text_frame_ids = ["TENC", "TPUB", "TSSE", "TCON", "TDRC", "TYER"]; for frame_id in &text_frame_ids { if let Some(text) = tag.get(frame_id).and_then(|f| f.content().text()) { props.push((frame_id.to_string(), text.to_string())); } } - - // Comments for comment in tag.comments() { let key = if comment.description.is_empty() { "COMM".to_string() @@ -179,8 +145,6 @@ pub fn dump_info(path: &Path) -> Result> { }; props.push((key, comment.text.clone())); } - - // URL frames let url_frame_ids = ["WOAS", "WOAF", "WOAR", "WORS", "WPUB"]; for frame_id in &url_frame_ids { if let Some(frame) = tag.get(frame_id) { @@ -189,23 +153,12 @@ pub fn dump_info(path: &Path) -> Result> { } } } - - // Extended text (TXXX) for txxx in tag.extended_texts() { - props.push(( - format!("TXXX:{}", txxx.description), - txxx.value.clone(), - )); + props.push((format!("TXXX:{}", txxx.description), txxx.value.clone())); } - - // Extended links (WXXX) for wxxx in tag.extended_links() { - props.push(( - format!("WXXX:{}", wxxx.description), - wxxx.link.clone(), - )); + props.push((format!("WXXX:{}", wxxx.description), wxxx.link.clone())); } - Ok(props) } @@ -309,9 +262,6 @@ mod tests { description: String::new(), text: "made with suno".into(), }); - - // We can't easily test dump_info without a file, but the tag construction - // verifies our code compiles and the test helpers work. let comments = detect_comments(&tag); assert_eq!(comments.len(), 1); } diff --git a/src/detector/mod.rs b/src/detector/mod.rs index 62a8be4..a52bf4f 100644 --- a/src/detector/mod.rs +++ b/src/detector/mod.rs @@ -12,6 +12,8 @@ pub mod xmp; use serde::Serialize; use std::path::{Path, PathBuf}; +use crate::i18n; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "lowercase")] pub enum Confidence { @@ -21,17 +23,24 @@ pub enum Confidence { High, } -impl std::fmt::Display for Confidence { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Confidence { + /// Localized display string for human output. + pub fn localized(&self) -> String { match self { - Confidence::None => write!(f, "NONE"), - Confidence::Low => write!(f, "LOW"), - Confidence::Medium => write!(f, "MEDIUM"), - Confidence::High => write!(f, "HIGH"), + Confidence::None => i18n::t("confidence_none", &[]), + Confidence::Low => i18n::t("confidence_low", &[]), + Confidence::Medium => i18n::t("confidence_medium", &[]), + Confidence::High => i18n::t("confidence_high", &[]), } } } +impl std::fmt::Display for Confidence { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.localized()) + } +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "lowercase")] pub enum SignalSource { @@ -68,11 +77,99 @@ impl std::fmt::Display for SignalSource { pub struct Signal { pub source: SignalSource, pub confidence: Confidence, + /// Always English — used for JSON serialization. pub description: String, #[serde(skip_serializing_if = "Option::is_none")] pub tool: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub details: Vec<(String, String)>, + /// Translation key for localized output. + #[serde(skip)] + pub msg_key: String, + /// Translation parameters for localized output. + #[serde(skip)] + pub msg_params: Vec<(String, String)>, +} + +impl Signal { + /// Render the localized description for human output. + pub fn localized_description(&self) -> String { + if self.msg_key.is_empty() { + return self.description.clone(); + } + let params: Vec<(&str, &str)> = self + .msg_params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + i18n::t(&self.msg_key, ¶ms) + } +} + +/// Builder for creating Signal with both English description and i18n key. +pub struct SignalBuilder { + source: SignalSource, + confidence: Confidence, + msg_key: String, + msg_params: Vec<(String, String)>, + tool: Option, + details: Vec<(String, String)>, +} + +impl SignalBuilder { + pub fn new(source: SignalSource, confidence: Confidence, key: &str) -> Self { + Self { + source, + confidence, + msg_key: key.to_string(), + msg_params: Vec::new(), + tool: None, + details: Vec::new(), + } + } + + pub fn param(mut self, name: &str, value: impl Into) -> Self { + self.msg_params.push((name.to_string(), value.into())); + self + } + + pub fn tool(mut self, tool: impl Into) -> Self { + self.tool = Some(tool.into()); + self + } + + pub fn tool_opt(mut self, tool: Option) -> Self { + self.tool = tool; + self + } + + pub fn details(mut self, details: Vec<(String, String)>) -> Self { + self.details = details; + self + } + + pub fn detail(mut self, key: impl Into, value: impl Into) -> Self { + self.details.push((key.into(), value.into())); + self + } + + pub fn build(self) -> Signal { + let params: Vec<(&str, &str)> = self + .msg_params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let description = i18n::t_en(&self.msg_key, ¶ms); + Signal { + source: self.source, + confidence: self.confidence, + description, + tool: self.tool, + details: self.details, + msg_key: self.msg_key, + msg_params: self.msg_params, + } + } } #[derive(Debug, Clone, Serialize)] @@ -132,7 +229,6 @@ pub fn run_all_detectors(path: &Path, deep: bool) -> FileReport { match c2pa_detector::detect(path) { Ok(sigs) => signals.extend(sigs), Err(e) => { - // C2PA errors are non-fatal (file may just not have a manifest) if std::env::var("AIC_DEBUG").is_ok() { eprintln!(" [debug] C2PA: {}", e); } @@ -212,7 +308,6 @@ pub fn run_all_detectors(path: &Path, deep: bool) -> FileReport { } // Audio spectral analysis (WAV files — frequency cutoff, spectral flatness) - // Run if --deep is set OR as automatic fallback when no metadata signals found if deep || signals.is_empty() { match audio_spectral::detect(path) { Ok(sigs) => signals.extend(sigs), @@ -225,7 +320,6 @@ pub fn run_all_detectors(path: &Path, deep: bool) -> FileReport { } // Watermark detector — pixel-level analysis - // Run if --deep is set OR as automatic fallback when no metadata signals found if deep || signals.is_empty() { match watermark::detect(path) { Ok(sigs) => signals.extend(sigs), diff --git a/src/detector/mp4_metadata.rs b/src/detector/mp4_metadata.rs index 989e235..11bffc1 100644 --- a/src/detector/mp4_metadata.rs +++ b/src/detector/mp4_metadata.rs @@ -2,164 +2,74 @@ use anyhow::Result; use std::fs; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; use crate::known_tools; -/// MP4-specific tool mappings for ambiguous tool names that shouldn't be in the -/// global known_tools list (e.g. "Google" is too broad for general matching but -/// is specifically used by Google Veo in MP4 ©too atoms). const MP4_TOOL_MAPPINGS: &[(&str, &str, Confidence)] = &[ ("google", "google veo", Confidence::Medium), ]; -/// Known H.264 SEI user_data_unregistered markers embedded by AI video tools. -/// Format: (byte pattern to search for in mdat, tool name) const SEI_MARKERS: &[(&[u8], &str)] = &[ - // Kling: SEI type 5 (user_data_unregistered) with UUID 91ca6061-4aee-3854-8614-2d5f73f4ae2e (b"kling-ai", "kling"), ]; -// --------------------------------------------------------------------------- -// MP4 box parsing utilities -// --------------------------------------------------------------------------- - fn read_u32_be(data: &[u8], offset: usize) -> Option { - if offset + 4 > data.len() { - return None; - } - Some(u32::from_be_bytes([ - data[offset], - data[offset + 1], - data[offset + 2], - data[offset + 3], - ])) + if offset + 4 > data.len() { return None; } + Some(u32::from_be_bytes([data[offset], data[offset+1], data[offset+2], data[offset+3]])) } -struct BoxInfo { - box_type: [u8; 4], - content_start: usize, - box_end: usize, -} +struct BoxInfo { box_type: [u8; 4], content_start: usize, box_end: usize } -/// Iterate through sibling boxes in the given byte range. fn find_boxes(data: &[u8], start: usize, end: usize) -> Vec { let mut boxes = Vec::new(); let mut pos = start; while pos + 8 <= end { - let size = match read_u32_be(data, pos) { - Some(s) => s as u64, - None => break, - }; + let size = match read_u32_be(data, pos) { Some(s) => s as u64, None => break }; let mut box_type = [0u8; 4]; box_type.copy_from_slice(&data[pos + 4..pos + 8]); - let (content_start, actual_size) = if size == 1 { - // Extended 64-bit size - if pos + 16 > end { - break; - } - let ext = u64::from_be_bytes([ - data[pos + 8], - data[pos + 9], - data[pos + 10], - data[pos + 11], - data[pos + 12], - data[pos + 13], - data[pos + 14], - data[pos + 15], - ]); + if pos + 16 > end { break; } + let ext = u64::from_be_bytes([data[pos+8],data[pos+9],data[pos+10],data[pos+11],data[pos+12],data[pos+13],data[pos+14],data[pos+15]]); (pos + 16, ext) - } else if size == 0 { - // Box extends to end of data - (pos + 8, (end - pos) as u64) - } else { - (pos + 8, size) - }; - - if actual_size < 8 { - break; - } - + } else if size == 0 { (pos + 8, (end - pos) as u64) } + else { (pos + 8, size) }; + if actual_size < 8 { break; } let box_end = (pos as u64 + actual_size).min(end as u64) as usize; - boxes.push(BoxInfo { - box_type, - content_start, - box_end, - }); + boxes.push(BoxInfo { box_type, content_start, box_end }); pos = box_end; } boxes } -/// Find the first box of a given type within a range. fn get_box(data: &[u8], start: usize, end: usize, box_type: &[u8; 4]) -> Option<(usize, usize)> { - find_boxes(data, start, end) - .into_iter() - .find(|b| &b.box_type == box_type) - .map(|b| (b.content_start, b.box_end)) + find_boxes(data, start, end).into_iter().find(|b| &b.box_type == box_type).map(|b| (b.content_start, b.box_end)) } -// --------------------------------------------------------------------------- -// ilst parsing — standard iTunes format -// --------------------------------------------------------------------------- - -/// Convert a 4-byte box type to a readable string. -/// iTunes atom types like ©too use 0xa9 which is not valid UTF-8, -/// so we decode as Latin-1 (ISO 8859-1) to preserve the © character. -fn box_type_to_string(box_type: &[u8; 4]) -> String { - box_type.iter().map(|&b| b as char).collect() -} +fn box_type_to_string(box_type: &[u8; 4]) -> String { box_type.iter().map(|&b| b as char).collect() } -/// Parse standard iTunes-style ilst where child atom types are the keys (e.g. ©too). fn parse_ilst_standard(data: &[u8], start: usize, end: usize) -> Vec<(String, String)> { let mut results = Vec::new(); for item in find_boxes(data, start, end) { let key = box_type_to_string(&item.box_type); - // Look for 'data' sub-atom inside this item - if let Some((data_cs, data_ce)) = get_box(data, item.content_start, item.box_end, b"data") - { - // data atom content: 4 bytes type/flags + 4 bytes locale + value + if let Some((data_cs, data_ce)) = get_box(data, item.content_start, item.box_end, b"data") { if data_ce - data_cs >= 8 { - let value = - String::from_utf8_lossy(&data[data_cs + 8..data_ce]).trim_matches('\0').to_string(); - if !value.is_empty() { - results.push((key, value)); - } + let value = String::from_utf8_lossy(&data[data_cs + 8..data_ce]).trim_matches('\0').to_string(); + if !value.is_empty() { results.push((key, value)); } } } } results } -// --------------------------------------------------------------------------- -// ilst parsing — keys-based format -// --------------------------------------------------------------------------- - -/// Parse keys box to get key names. fn parse_keys(data: &[u8], start: usize, end: usize) -> Vec { - // keys content: 4 bytes version/flags + 4 bytes entry_count + entries - if end - start < 8 { - return vec![]; - } - let count = match read_u32_be(data, start + 4) { - Some(c) => c as usize, - None => return vec![], - }; - + if end - start < 8 { return vec![]; } + let count = match read_u32_be(data, start + 4) { Some(c) => c as usize, None => return vec![] }; let mut keys = Vec::with_capacity(count); let mut offset = start + 8; for _ in 0..count { - if offset + 8 > end { - break; - } - let key_size = match read_u32_be(data, offset) { - Some(s) => s as usize, - None => break, - }; - if key_size < 8 || offset + key_size > end { - break; - } - // 4 bytes size + 4 bytes namespace + name + if offset + 8 > end { break; } + let key_size = match read_u32_be(data, offset) { Some(s) => s as usize, None => break }; + if key_size < 8 || offset + key_size > end { break; } let name = String::from_utf8_lossy(&data[offset + 8..offset + key_size]).to_string(); keys.push(name); offset += key_size; @@ -167,68 +77,29 @@ fn parse_keys(data: &[u8], start: usize, end: usize) -> Vec { keys } -/// Parse keys-based ilst where items reference keys by 1-based index. -fn parse_ilst_keyed( - data: &[u8], - keys: &[String], - ilst_start: usize, - ilst_end: usize, -) -> Vec<(String, String)> { +fn parse_ilst_keyed(data: &[u8], keys: &[String], ilst_start: usize, ilst_end: usize) -> Vec<(String, String)> { let mut results = Vec::new(); for item in find_boxes(data, ilst_start, ilst_end) { let idx = u32::from_be_bytes(item.box_type) as usize; - let key_name = if idx > 0 && idx <= keys.len() { - keys[idx - 1].clone() - } else { - format!("idx:{}", idx) - }; - - // Look for 'data' sub-atom - if let Some((data_cs, data_ce)) = get_box(data, item.content_start, item.box_end, b"data") - { + let key_name = if idx > 0 && idx <= keys.len() { keys[idx - 1].clone() } else { format!("idx:{}", idx) }; + if let Some((data_cs, data_ce)) = get_box(data, item.content_start, item.box_end, b"data") { if data_ce - data_cs >= 8 { - let value = - String::from_utf8_lossy(&data[data_cs + 8..data_ce]).trim_matches('\0').to_string(); - if !value.is_empty() { - results.push((key_name, value)); - } + let value = String::from_utf8_lossy(&data[data_cs + 8..data_ce]).trim_matches('\0').to_string(); + if !value.is_empty() { results.push((key_name, value)); } } } } results } -// --------------------------------------------------------------------------- -// Extraction: navigate moov > udta > meta > ilst -// --------------------------------------------------------------------------- - fn extract_ilst_entries(data: &[u8]) -> Vec<(String, String)> { - let moov = match get_box(data, 0, data.len(), b"moov") { - Some(m) => m, - None => return vec![], - }; - let udta = match get_box(data, moov.0, moov.1, b"udta") { - Some(u) => u, - None => return vec![], - }; - let meta = match get_box(data, udta.0, udta.1, b"meta") { - Some(m) => m, - None => return vec![], - }; - - // meta is a full box: skip 4 bytes version/flags + let moov = match get_box(data, 0, data.len(), b"moov") { Some(m) => m, None => return vec![] }; + let udta = match get_box(data, moov.0, moov.1, b"udta") { Some(u) => u, None => return vec![] }; + let meta = match get_box(data, udta.0, udta.1, b"meta") { Some(m) => m, None => return vec![] }; let meta_content = meta.0 + 4; - if meta_content >= meta.1 { - return vec![]; - } - - // Check for keys box (keys-based format) + if meta_content >= meta.1 { return vec![]; } let keys_box = get_box(data, meta_content, meta.1, b"keys"); - let ilst = match get_box(data, meta_content, meta.1, b"ilst") { - Some(i) => i, - None => return vec![], - }; - + let ilst = match get_box(data, meta_content, meta.1, b"ilst") { Some(i) => i, None => return vec![] }; if let Some((keys_start, keys_end)) = keys_box { let keys = parse_keys(data, keys_start, keys_end); parse_ilst_keyed(data, &keys, ilst.0, ilst.1) @@ -237,200 +108,120 @@ fn extract_ilst_entries(data: &[u8]) -> Vec<(String, String)> { } } -// --------------------------------------------------------------------------- -// Detection methods -// --------------------------------------------------------------------------- - -/// Method A: Match ilst tool/software values against known AI tools. fn detect_ilst_tools(entries: &[(String, String)]) -> Vec { let mut signals = Vec::new(); - - // Keys to check for tool matching (standard iTunes atom names + keys-based names) let tool_keys: &[&str] = &["\u{a9}too", "\u{a9}swr", "encoder", "tool", "software"]; - for (key, value) in entries { let is_tool_key = tool_keys.iter().any(|tk| key.eq_ignore_ascii_case(tk)); - if !is_tool_key { - continue; - } - + if !is_tool_key { continue; } let label = match key.as_str() { "\u{a9}too" => "Encoding Tool", "\u{a9}swr" => "Software", _ => key.as_str(), }; - - // First try global known_tools match if let Some(tool_name) = known_tools::match_ai_tool(value) { - signals.push(Signal { - source: SignalSource::Mp4Metadata, - confidence: Confidence::Medium, - description: format!("{} matches AI tool: {}", label, value), - tool: Some(tool_name.to_string()), - details: vec![(key.clone(), value.clone())], - }); + signals.push( + SignalBuilder::new(SignalSource::Mp4Metadata, Confidence::Medium, "signal_mp4_tool_match") + .param("label", label).param("value", value.as_str()) + .tool(tool_name).detail(key.as_str(), value.as_str()).build(), + ); continue; } - - // Then try MP4-specific mappings (case-insensitive exact match) let lower = value.to_lowercase(); for &(pattern, mapped_tool, confidence) in MP4_TOOL_MAPPINGS { if lower == pattern { - signals.push(Signal { - source: SignalSource::Mp4Metadata, - confidence, - description: format!("{} matches AI tool: {}", label, value), - tool: Some(mapped_tool.to_string()), - details: vec![(key.clone(), value.clone())], - }); + signals.push( + SignalBuilder::new(SignalSource::Mp4Metadata, confidence, "signal_mp4_tool_match") + .param("label", label).param("value", value.as_str()) + .tool(mapped_tool).detail(key.as_str(), value.as_str()).build(), + ); break; } } } - signals } -/// Method B: Detect China AIGC labeling standard metadata. fn detect_aigc_label(entries: &[(String, String)]) -> Vec { let mut signals = Vec::new(); - for (key, value) in entries { - if !key.eq_ignore_ascii_case("AIGC") { - continue; - } - - // Parse JSON-like content for Label field - // The value is JSON like {"Label":"1", ...} - // We do simple string matching to avoid adding a JSON dependency for this one field. + if !key.eq_ignore_ascii_case("AIGC") { continue; } let has_ai_label = value.contains("\"Label\":\"1\"") || value.contains("\"Label\": \"1\""); - if !has_ai_label { - continue; - } - - // Try to extract ProduceID for tool identification + if !has_ai_label { continue; } let produce_id = extract_json_field(value, "ProduceID"); - let description = if let Some(ref pid) = produce_id { - format!("AIGC label indicates AI-generated content (ProduceID: {})", pid) + let signal = if let Some(ref pid) = produce_id { + SignalBuilder::new(SignalSource::Mp4Metadata, Confidence::Medium, "signal_mp4_aigc_label_id") + .param("id", pid.as_str()) + .detail("AIGC", value.as_str()) + .detail("ProduceID", pid.as_str()) + .build() } else { - "AIGC label indicates AI-generated content".to_string() + SignalBuilder::new(SignalSource::Mp4Metadata, Confidence::Medium, "signal_mp4_aigc_label") + .detail("AIGC", value.as_str()) + .build() }; - - let mut details = vec![("AIGC".to_string(), value.clone())]; - if let Some(pid) = &produce_id { - details.push(("ProduceID".to_string(), pid.clone())); - } - - signals.push(Signal { - source: SignalSource::Mp4Metadata, - confidence: Confidence::Medium, - description, - tool: None, - details, - }); + signals.push(signal); } - signals } -/// Simple JSON field extractor (avoids serde_json dependency for this one use). fn extract_json_field(json: &str, field: &str) -> Option { let pattern = format!("\"{}\"", field); let idx = json.find(&pattern)?; let after = &json[idx + pattern.len()..]; - // Skip whitespace and colon let after = after.trim_start(); let after = after.strip_prefix(':')?; let after = after.trim_start(); - // Extract quoted value let after = after.strip_prefix('"')?; let end = after.find('"')?; Some(after[..end].to_string()) } -/// Method C: Scan mdat for known H.264 SEI watermark markers. fn detect_sei_markers(data: &[u8]) -> Vec { let mut signals = Vec::new(); - - let mdat = match get_box(data, 0, data.len(), b"mdat") { - Some(m) => m, - None => return signals, - }; - - // Scan first 1MB of mdat for performance + let mdat = match get_box(data, 0, data.len(), b"mdat") { Some(m) => m, None => return signals }; let scan_end = mdat.1.min(mdat.0 + 1_048_576); let scan_data = &data[mdat.0..scan_end]; - for &(marker, tool_name) in SEI_MARKERS { if scan_data.windows(marker.len()).any(|w| w == marker) { - signals.push(Signal { - source: SignalSource::Mp4Metadata, - confidence: Confidence::Medium, - description: format!( - "H.264 SEI watermark: {}", - String::from_utf8_lossy(marker) - ), - tool: Some(tool_name.to_string()), - details: vec![( - "SEI marker".to_string(), - String::from_utf8_lossy(marker).to_string(), - )], - }); + let marker_str = String::from_utf8_lossy(marker); + signals.push( + SignalBuilder::new(SignalSource::Mp4Metadata, Confidence::Medium, "signal_mp4_sei_watermark") + .param("marker", &*marker_str) + .tool(tool_name) + .detail("SEI marker", &*marker_str) + .build(), + ); } } - signals } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Detect AI signals from MP4 container metadata. pub fn detect(path: &Path) -> Result> { let data = fs::read(path)?; - - // Quick check: is this an MP4/MOV file? (ftyp box should be near the start) - if get_box(&data, 0, data.len().min(64), b"ftyp").is_none() { - return Ok(vec![]); - } - + if get_box(&data, 0, data.len().min(64), b"ftyp").is_none() { return Ok(vec![]); } let entries = extract_ilst_entries(&data); - let mut signals = Vec::new(); signals.extend(detect_ilst_tools(&entries)); signals.extend(detect_aigc_label(&entries)); signals.extend(detect_sei_markers(&data)); - Ok(signals) } -/// Dump all MP4 metadata for the `info` subcommand. pub fn dump_info(path: &Path) -> Result> { let data = fs::read(path)?; - - if get_box(&data, 0, data.len().min(64), b"ftyp").is_none() { - return Ok(vec![]); - } - + if get_box(&data, 0, data.len().min(64), b"ftyp").is_none() { return Ok(vec![]); } let mut props = extract_ilst_entries(&data); - - // Also report SEI markers found let mdat = get_box(&data, 0, data.len(), b"mdat"); if let Some((mdat_start, mdat_end)) = mdat { let scan_end = mdat_end.min(mdat_start + 1_048_576); let scan_data = &data[mdat_start..scan_end]; - for &(marker, tool_name) in SEI_MARKERS { if scan_data.windows(marker.len()).any(|w| w == marker) { - props.push(( - "SEI watermark".to_string(), - format!("{} ({})", String::from_utf8_lossy(marker), tool_name), - )); + props.push(("SEI watermark".to_string(), format!("{} ({})", String::from_utf8_lossy(marker), tool_name))); } } } - Ok(props) } @@ -442,19 +233,11 @@ mod tests { fn test_extract_json_field() { let json = r#"{"Label":"1","ProduceID":"abc-123","Other":"val"}"#; assert_eq!(extract_json_field(json, "Label"), Some("1".to_string())); - assert_eq!( - extract_json_field(json, "ProduceID"), - Some("abc-123".to_string()) - ); + assert_eq!(extract_json_field(json, "ProduceID"), Some("abc-123".to_string())); assert_eq!(extract_json_field(json, "Missing"), None); - - // With spaces let json2 = r#"{"Label": "1", "ProduceID": "xyz"}"#; assert_eq!(extract_json_field(json2, "Label"), Some("1".to_string())); - assert_eq!( - extract_json_field(json2, "ProduceID"), - Some("xyz".to_string()) - ); + assert_eq!(extract_json_field(json2, "ProduceID"), Some("xyz".to_string())); } #[test] @@ -483,23 +266,17 @@ mod tests { #[test] fn test_detect_aigc_label() { - let entries = vec![( - "AIGC".to_string(), - r#"{"Label":"1","ProduceID":"test-123"}"#.to_string(), - )]; + let entries = vec![("AIGC".to_string(), r#"{"Label":"1","ProduceID":"test-123"}"#.to_string())]; let signals = detect_aigc_label(&entries); assert_eq!(signals.len(), 1); assert_eq!(signals[0].confidence, Confidence::Medium); - assert!(signals[0].description.contains("AIGC")); - assert!(signals[0].description.contains("test-123")); + assert!(signals[0].description.contains("AIGC") || signals[0].msg_key.contains("aigc")); + assert!(signals[0].description.contains("test-123") || signals[0].msg_params.iter().any(|(_, v)| v == "test-123")); } #[test] fn test_detect_aigc_label_not_ai() { - let entries = vec![( - "AIGC".to_string(), - r#"{"Label":"0","ProduceID":"test"}"#.to_string(), - )]; + let entries = vec![("AIGC".to_string(), r#"{"Label":"0","ProduceID":"test"}"#.to_string())]; let signals = detect_aigc_label(&entries); assert!(signals.is_empty()); } @@ -514,28 +291,20 @@ mod tests { #[test] fn test_parse_standard_ilst() { - // Construct a minimal standard ilst: ilst > ©too > data > "Google" let value = b"Google"; - let data_atom_size: u32 = 8 + 8 + value.len() as u32; // header + flags+locale + value + let data_atom_size: u32 = 8 + 8 + value.len() as u32; let item_size: u32 = 8 + data_atom_size; let ilst_size: u32 = 8 + item_size; - let mut buf = Vec::new(); - // ilst header buf.extend_from_slice(&ilst_size.to_be_bytes()); buf.extend_from_slice(b"ilst"); - // ©too item header buf.extend_from_slice(&item_size.to_be_bytes()); buf.extend_from_slice(&[0xa9, b't', b'o', b'o']); - // data atom header buf.extend_from_slice(&data_atom_size.to_be_bytes()); buf.extend_from_slice(b"data"); - // type flags + locale - buf.extend_from_slice(&[0, 0, 0, 1]); // flags: UTF-8 text - buf.extend_from_slice(&[0, 0, 0, 0]); // locale - // value + buf.extend_from_slice(&[0, 0, 0, 1]); + buf.extend_from_slice(&[0, 0, 0, 0]); buf.extend_from_slice(value); - let entries = parse_ilst_standard(&buf, 8, buf.len()); assert_eq!(entries.len(), 1); assert_eq!(entries[0].0, "\u{a9}too"); diff --git a/src/detector/png_text.rs b/src/detector/png_text.rs index 70091d8..d12d1b3 100644 --- a/src/detector/png_text.rs +++ b/src/detector/png_text.rs @@ -2,73 +2,41 @@ use anyhow::Result; use std::fs; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; use crate::known_tools; -/// PNG text chunk keywords that may contain AI tool references. const RELEVANT_KEYWORDS: &[&str] = &[ - "Software", - "Comment", - "Description", - "Source", - "Author", - "parameters", - "prompt", + "Software", "Comment", "Description", "Source", "Author", "parameters", "prompt", ]; -/// Detect AI signals from PNG text chunks (tEXt, iTXt). pub fn detect(path: &Path) -> Result> { let data = fs::read(path)?; - - // Verify PNG signature - if data.len() < 8 || &data[..8] != b"\x89PNG\r\n\x1a\n" { - return Ok(vec![]); - } - + if data.len() < 8 || &data[..8] != b"\x89PNG\r\n\x1a\n" { return Ok(vec![]); } let mut signals = Vec::new(); let mut pos: usize = 8; - while pos + 12 <= data.len() { - let length = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) - as usize; + let length = u32::from_be_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]) as usize; let chunk_type = &data[pos + 4..pos + 8]; let chunk_data_end = pos + 8 + length; - - if chunk_data_end > data.len() { - break; - } - + if chunk_data_end > data.len() { break; } let chunk_data = &data[pos + 8..chunk_data_end]; - match chunk_type { b"tEXt" => { - // Format: keyword\0value if let Some(null_pos) = chunk_data.iter().position(|&b| b == 0) { if let (Ok(keyword), Ok(value)) = ( std::str::from_utf8(&chunk_data[..null_pos]), std::str::from_utf8(&chunk_data[null_pos + 1..]), - ) { - check_text_chunk(keyword, value, &mut signals); - } + ) { check_text_chunk(keyword, value, &mut signals); } } } b"iTXt" => { - // Format: keyword\0compression_flag\0compression_method\0language\0translated_keyword\0text if let Some(null_pos) = chunk_data.iter().position(|&b| b == 0) { if let Ok(keyword) = std::str::from_utf8(&chunk_data[..null_pos]) { - // Skip compression_flag, compression_method, language, translated_keyword let rest = &chunk_data[null_pos + 1..]; - // Find text after 3 more null separators (or parse what we can) let mut nulls_found = 0; let mut text_start = 0; for (i, &b) in rest.iter().enumerate() { - if b == 0 { - nulls_found += 1; - if nulls_found == 3 { - text_start = i + 1; - break; - } - } + if b == 0 { nulls_found += 1; if nulls_found == 3 { text_start = i + 1; break; } } } if text_start > 0 && text_start < rest.len() { if let Ok(value) = std::str::from_utf8(&rest[text_start..]) { @@ -81,38 +49,26 @@ pub fn detect(path: &Path) -> Result> { b"IEND" => break, _ => {} } - - pos = chunk_data_end + 4; // +4 for CRC + pos = chunk_data_end + 4; } - Ok(signals) } fn check_text_chunk(keyword: &str, value: &str, signals: &mut Vec) { let keyword_lower = keyword.to_lowercase(); - let is_relevant = RELEVANT_KEYWORDS - .iter() - .any(|k| keyword_lower == k.to_lowercase()); - - if !is_relevant { - return; - } - + let is_relevant = RELEVANT_KEYWORDS.iter().any(|k| keyword_lower == k.to_lowercase()); + if !is_relevant { return; } if let Some(tool_name) = known_tools::match_ai_tool(value) { - signals.push(Signal { - source: SignalSource::PngText, - confidence: Confidence::Low, - description: format!("PNG {} references AI tool", keyword), - tool: Some(tool_name.to_string()), - details: vec![(keyword.to_string(), truncate(value, 200))], - }); + signals.push( + SignalBuilder::new(SignalSource::PngText, Confidence::Low, "signal_png_text_chunk") + .param("keyword", keyword) + .tool(tool_name) + .detail(keyword, truncate(value, 200)) + .build(), + ); } } fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { - s.to_string() - } else { - format!("{}...", &s[..max]) - } + if s.len() <= max { s.to_string() } else { format!("{}...", &s[..max]) } } diff --git a/src/detector/watermark.rs b/src/detector/watermark.rs index d45ff3e..a5285ec 100644 --- a/src/detector/watermark.rs +++ b/src/detector/watermark.rs @@ -1,282 +1,158 @@ use anyhow::{Context, Result}; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; +use crate::i18n; -/// Maximum dimension for analysis. const MAX_DIM: u32 = 1024; - -/// DWT block size for quantization analysis. const DWT_BLOCK: usize = 4; - -/// Minimum image dimension for analysis. const MIN_DIM: usize = 64; - -/// Known quantization step for invisible-watermark. const QUANT_STEP: f64 = 36.0; - -/// Additional quantization steps to try. const ALT_QUANT_STEPS: &[f64] = &[25.0, 30.0, 40.0, 50.0]; - -/// Coefficient indices in flattened 4x4 block that invisible-watermark modifies. const EMBED_INDICES: &[usize] = &[1, 2, 10, 11]; - -/// Minimum number of indicators to emit a signal. const MIN_INDICATORS: usize = 2; - -/// Channel noise asymmetry threshold. -/// Watermarks that embed per-channel create detectable noise differences. const NOISE_ASYMMETRY_THRESHOLD: f64 = 0.08; - -/// Cross-channel bit agreement threshold. -/// Random agreement is ~50%. Watermarked images show ~65%+ agreement. const BIT_AGREEMENT_THRESHOLD: f64 = 0.62; -/// Detect invisible watermark signals in an image file. pub fn detect(path: &Path) -> Result> { let img = image::open(path).context("Failed to open image for watermark analysis")?; - let img = if img.width() > MAX_DIM || img.height() > MAX_DIM { img.resize(MAX_DIM, MAX_DIM, image::imageops::FilterType::Lanczos3) - } else { - img - }; + } else { img }; let rgba = img.to_rgba8(); let (width, height) = rgba.dimensions(); let (w, h) = (width as usize, height as usize); - - if w < MIN_DIM || h < MIN_DIM { - return Ok(vec![]); - } + if w < MIN_DIM || h < MIN_DIM { return Ok(vec![]); } let debug = std::env::var("AIC_DEBUG").is_ok(); let mut indicators: Vec<&str> = Vec::new(); let mut details = Vec::new(); - // Extract color channels let channels = extract_rgb_channels(&rgba, w, h); - - // Make dimensions even and sufficient for DWT + blocks let cw = w - (w % 2); let ch = h - (h % 2); - if cw < DWT_BLOCK * 4 || ch < DWT_BLOCK * 4 { - return Ok(vec![]); - } + if cw < DWT_BLOCK * 4 || ch < DWT_BLOCK * 4 { return Ok(vec![]); } - // Get channel pixel buffers for DWT - let channel_pixels: Vec> = channels - .iter() - .map(|channel| { - channel - .iter() - .take(ch * w) - .enumerate() - .filter_map(|(i, &v)| if i % w < cw { Some(v) } else { None }) - .collect() - }) - .collect(); + let channel_pixels: Vec> = channels.iter().map(|channel| { + channel.iter().take(ch * w).enumerate() + .filter_map(|(i, &v)| if i % w < cw { Some(v) } else { None }).collect() + }).collect(); - // Apply DWT to each channel - let channel_subbands: Vec = channel_pixels - .iter() - .map(|px| haar_dwt_2d(px, cw, ch)) - .collect(); + let channel_subbands: Vec = channel_pixels.iter().map(|px| haar_dwt_2d(px, cw, ch)).collect(); let sub_w = cw / 2; let sub_h = ch / 2; - // --- Analysis 1: Channel noise asymmetry --- - let channel_noises: Vec = channels - .iter() - .map(|c| estimate_noise_level(c, w, h)) - .collect(); - + // Analysis 1: Channel noise asymmetry + let channel_noises: Vec = channels.iter().map(|c| estimate_noise_level(c, w, h)).collect(); let mean_noise = channel_noises.iter().sum::() / 3.0; if mean_noise > 0.01 { let max_noise = channel_noises.iter().cloned().fold(f64::MIN, f64::max); let min_noise = channel_noises.iter().cloned().fold(f64::MAX, f64::min); let asymmetry = (max_noise - min_noise) / mean_noise; - if debug { - eprintln!( - " [debug] Watermark noise: R={:.3} G={:.3} B={:.3} asymmetry={:.3}", - channel_noises[0], channel_noises[1], channel_noises[2], asymmetry - ); + eprintln!(" [debug] Watermark noise: R={:.3} G={:.3} B={:.3} asymmetry={:.3}", + channel_noises[0], channel_noises[1], channel_noises[2], asymmetry); } - details.push(("noise_asymmetry".to_string(), format!("{:.3}", asymmetry))); - - if asymmetry > NOISE_ASYMMETRY_THRESHOLD { - indicators.push("channel noise asymmetry"); - } + if asymmetry > NOISE_ASYMMETRY_THRESHOLD { indicators.push("channel noise asymmetry"); } } - // --- Analysis 2: Cross-channel bit agreement --- - // invisible-watermark embeds the same watermark in each RGB channel. - // If we extract bits from different channels, they should agree at a rate - // significantly above 50% for watermarked images. - let all_quant_steps: Vec = std::iter::once(QUANT_STEP) - .chain(ALT_QUANT_STEPS.iter().copied()) - .collect(); - + // Analysis 2: Cross-channel bit agreement + let all_quant_steps: Vec = std::iter::once(QUANT_STEP).chain(ALT_QUANT_STEPS.iter().copied()).collect(); let mut best_agreement = 0.0f64; let mut best_q = 0.0f64; - for &q_step in &all_quant_steps { - // Extract bits from each channel's LL subband - let channel_bits: Vec> = channel_subbands - .iter() - .map(|sb| extract_bits(&sb.ll, sub_w, sub_h, q_step, EMBED_INDICES)) - .collect(); - - // Compare all pairs of channels + let channel_bits: Vec> = channel_subbands.iter() + .map(|sb| extract_bits(&sb.ll, sub_w, sub_h, q_step, EMBED_INDICES)).collect(); if channel_bits.iter().all(|b| !b.is_empty()) { let min_len = channel_bits.iter().map(|b| b.len()).min().unwrap_or(0); if min_len > 0 { let mut total_agree = 0usize; let mut total_compared = 0usize; - for i in 0..3 { for j in (i + 1)..3 { for (bi, bj) in channel_bits[i].iter().zip(channel_bits[j].iter()).take(min_len) { - if bi == bj { - total_agree += 1; - } + if bi == bj { total_agree += 1; } total_compared += 1; } } } - if total_compared > 0 { let agreement = total_agree as f64 / total_compared as f64; - if agreement > best_agreement { - best_agreement = agreement; - best_q = q_step; - } + if agreement > best_agreement { best_agreement = agreement; best_q = q_step; } } } } } - - if debug { - eprintln!( - " [debug] Watermark cross-channel bit agreement: {:.3} (q={:.0})", - best_agreement, best_q - ); - } - - details.push(( - "cross_channel_agreement".to_string(), - format!("{:.3}", best_agreement), - )); - + if debug { eprintln!(" [debug] Watermark cross-channel bit agreement: {:.3} (q={:.0})", best_agreement, best_q); } + details.push(("cross_channel_agreement".to_string(), format!("{:.3}", best_agreement))); if best_agreement > BIT_AGREEMENT_THRESHOLD { indicators.push("cross-channel bit consistency"); details.push(("best_quant_step".to_string(), format!("{:.0}", best_q))); } - // --- Analysis 3: DWT residual energy ratio --- - // Compare energy in detail subbands vs LL subband. - // Watermarking modifies LL but not detail subbands, changing the ratio. + // Analysis 3: DWT residual energy ratio let mut energy_ratios = Vec::new(); for (ch_idx, sb) in channel_subbands.iter().enumerate() { let ll_energy: f64 = sb.ll.iter().map(|v| v * v).sum::(); let detail_energy: f64 = sb.lh.iter().map(|v| v * v).sum::() + sb.hl.iter().map(|v| v * v).sum::() + sb.hh.iter().map(|v| v * v).sum::(); - if ll_energy > 0.0 { let ratio = detail_energy / ll_energy; energy_ratios.push(ratio); if debug { let ch_name = ["R", "G", "B"][ch_idx]; - eprintln!( - " [debug] Watermark energy ratio ch={}: {:.6}", - ch_name, ratio - ); + eprintln!(" [debug] Watermark energy ratio ch={}: {:.6}", ch_name, ratio); } } } - - // Check if energy ratios differ between channels (watermark affects channels differently) if energy_ratios.len() >= 2 { let max_ratio = energy_ratios.iter().cloned().fold(f64::MIN, f64::max); let min_ratio = energy_ratios.iter().cloned().fold(f64::MAX, f64::min); let mean_ratio = energy_ratios.iter().sum::() / energy_ratios.len() as f64; - if mean_ratio > 0.0 { let ratio_spread = (max_ratio - min_ratio) / mean_ratio; - details.push(( - "energy_ratio_spread".to_string(), - format!("{:.4}", ratio_spread), - )); - - if debug { - eprintln!( - " [debug] Watermark energy ratio spread: {:.4}", - ratio_spread - ); - } - - if ratio_spread > 0.25 { - indicators.push("asymmetric DWT energy distribution"); - } + details.push(("energy_ratio_spread".to_string(), format!("{:.4}", ratio_spread))); + if debug { eprintln!(" [debug] Watermark energy ratio spread: {:.4}", ratio_spread); } + if ratio_spread > 0.25 { indicators.push("asymmetric DWT energy distribution"); } } } - // --- Emit signal --- + // Emit signal if indicators.len() >= MIN_INDICATORS { - let strength = if indicators.len() >= 3 { - "strong" - } else { - "moderate" - }; - - let description = format!( - "Invisible watermark indicators detected ({} evidence): {}", - strength, - indicators.join("; ") - ); - - Ok(vec![Signal { - source: SignalSource::Watermark, - confidence: Confidence::Low, - description, - tool: None, - details, - }]) + let strength_key = if indicators.len() >= 3 { "signal_watermark_strong" } else { "signal_watermark_moderate" }; + let strength = i18n::t(strength_key, &[]); + let indicators_str = indicators.join("; "); + + Ok(vec![ + SignalBuilder::new(SignalSource::Watermark, Confidence::Low, "signal_watermark_detected") + .param("strength", &strength) + .param("indicators", &indicators_str) + .details(details) + .build(), + ]) } else { Ok(vec![]) } } -// --- Channel Extraction --- - fn extract_rgb_channels(rgba: &image::RgbaImage, w: usize, h: usize) -> [Vec; 3] { let mut r = Vec::with_capacity(w * h); let mut g = Vec::with_capacity(w * h); let mut b = Vec::with_capacity(w * h); - for y in 0..h { for x in 0..w { let pixel = rgba.get_pixel(x as u32, y as u32); - r.push(pixel[0] as f64); - g.push(pixel[1] as f64); - b.push(pixel[2] as f64); + r.push(pixel[0] as f64); g.push(pixel[1] as f64); b.push(pixel[2] as f64); } } - [r, g, b] } -// --- Noise Estimation --- - -/// Estimate noise level using median absolute deviation of Laplacian. fn estimate_noise_level(channel: &[f64], width: usize, height: usize) -> f64 { - if width < 3 || height < 3 { - return 0.0; - } - + if width < 3 || height < 3 { return 0.0; } let mut laplacian_values = Vec::new(); for y in 1..height - 1 { for x in 1..width - 1 { @@ -285,40 +161,21 @@ fn estimate_noise_level(channel: &[f64], width: usize, height: usize) -> f64 { let bottom = channel[(y + 1) * width + x]; let left = channel[y * width + (x - 1)]; let right = channel[y * width + (x + 1)]; - let lap = (4.0 * center - top - bottom - left - right).abs(); laplacian_values.push(lap); } } - - if laplacian_values.is_empty() { - return 0.0; - } - + if laplacian_values.is_empty() { return 0.0; } laplacian_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let median = laplacian_values[laplacian_values.len() / 2]; median / 0.6745 } -// --- Bit Extraction --- - -/// Extract bits from a DWT-LL subband at specific DCT coefficient positions -/// using a given quantization step. Returns empty vec if insufficient data. -fn extract_bits( - ll_subband: &[f64], - width: usize, - height: usize, - quant_step: f64, - coeff_indices: &[usize], -) -> Vec { +fn extract_bits(ll_subband: &[f64], width: usize, height: usize, quant_step: f64, coeff_indices: &[usize]) -> Vec { let blocks_x = width / DWT_BLOCK; let blocks_y = height / DWT_BLOCK; - if blocks_x * blocks_y < 32 { - return vec![]; - } - + if blocks_x * blocks_y < 32 { return vec![]; } let mut bits = Vec::new(); - for by in 0..blocks_y { for bx in 0..blocks_x { let mut block = [0.0f64; 16]; @@ -326,14 +183,10 @@ fn extract_bits( for col in 0..DWT_BLOCK { let y = by * DWT_BLOCK + row; let x = bx * DWT_BLOCK + col; - if y < height && x < width { - block[row * DWT_BLOCK + col] = ll_subband[y * width + x]; - } + if y < height && x < width { block[row * DWT_BLOCK + col] = ll_subband[y * width + x]; } } } - apply_2d_dct_ortho(&mut block, DWT_BLOCK); - for &idx in coeff_indices { if idx < 16 { let coeff = block[idx]; @@ -343,72 +196,44 @@ fn extract_bits( } } } - bits } -/// Apply 2D DCT-II with orthonormal normalization. fn apply_2d_dct_ortho(block: &mut [f64], size: usize) { let n = size as f64; - - // Row-wise DCT for row in 0..size { let start = row * size; let input: Vec = block[start..start + size].to_vec(); for k in 0..size { let mut sum = 0.0; for (i, val) in input.iter().enumerate() { - sum += val - * (std::f64::consts::PI * (2.0 * i as f64 + 1.0) * k as f64 / (2.0 * n)) - .cos(); + sum += val * (std::f64::consts::PI * (2.0 * i as f64 + 1.0) * k as f64 / (2.0 * n)).cos(); } - let scale = if k == 0 { - (1.0 / n).sqrt() - } else { - (2.0 / n).sqrt() - }; + let scale = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() }; block[start + k] = sum * scale; } } - - // Column-wise DCT for col in 0..size { let input: Vec = (0..size).map(|row| block[row * size + col]).collect(); for k in 0..size { let mut sum = 0.0; for (i, val) in input.iter().enumerate() { - sum += val - * (std::f64::consts::PI * (2.0 * i as f64 + 1.0) * k as f64 / (2.0 * n)) - .cos(); + sum += val * (std::f64::consts::PI * (2.0 * i as f64 + 1.0) * k as f64 / (2.0 * n)).cos(); } - let scale = if k == 0 { - (1.0 / n).sqrt() - } else { - (2.0 / n).sqrt() - }; + let scale = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() }; block[k * size + col] = sum * scale; } } } -// --- Haar DWT --- +struct DwtSubbands { ll: Vec, lh: Vec, hl: Vec, hh: Vec } -struct DwtSubbands { - ll: Vec, - lh: Vec, - hl: Vec, - hh: Vec, -} - -/// Single-level 2D Haar Discrete Wavelet Transform. fn haar_dwt_2d(data: &[f64], width: usize, height: usize) -> DwtSubbands { let half_w = width / 2; let half_h = height / 2; let inv_sqrt2 = 1.0 / std::f64::consts::SQRT_2; - let mut row_low = vec![0.0; half_w * height]; let mut row_high = vec![0.0; half_w * height]; - for y in 0..height { for x in 0..half_w { let a = data[y * width + 2 * x]; @@ -417,26 +242,22 @@ fn haar_dwt_2d(data: &[f64], width: usize, height: usize) -> DwtSubbands { row_high[y * half_w + x] = (a - b) * inv_sqrt2; } } - let mut ll = vec![0.0; half_w * half_h]; let mut lh = vec![0.0; half_w * half_h]; let mut hl = vec![0.0; half_w * half_h]; let mut hh = vec![0.0; half_w * half_h]; - for x in 0..half_w { for y in 0..half_h { let a_low = row_low[2 * y * half_w + x]; let b_low = row_low[(2 * y + 1) * half_w + x]; ll[y * half_w + x] = (a_low + b_low) * inv_sqrt2; lh[y * half_w + x] = (a_low - b_low) * inv_sqrt2; - let a_high = row_high[2 * y * half_w + x]; let b_high = row_high[(2 * y + 1) * half_w + x]; hl[y * half_w + x] = (a_high + b_high) * inv_sqrt2; hh[y * half_w + x] = (a_high - b_high) * inv_sqrt2; } } - DwtSubbands { ll, lh, hl, hh } } @@ -448,26 +269,16 @@ mod tests { fn test_haar_dwt_2d_identity() { let data = vec![100.0; 16]; let result = haar_dwt_2d(&data, 4, 4); - for v in &result.lh { - assert!(v.abs() < 1e-10); - } - for v in &result.hl { - assert!(v.abs() < 1e-10); - } - for v in &result.hh { - assert!(v.abs() < 1e-10); - } + for v in &result.lh { assert!(v.abs() < 1e-10); } + for v in &result.hl { assert!(v.abs() < 1e-10); } + for v in &result.hh { assert!(v.abs() < 1e-10); } assert!(result.ll[0] > 0.0); } #[test] fn test_haar_dwt_2d_edge() { let mut data = vec![0.0; 64]; - for y in 0..8 { - for x in (1..8).step_by(2) { - data[y * 8 + x] = 200.0; - } - } + for y in 0..8 { for x in (1..8).step_by(2) { data[y * 8 + x] = 200.0; } } let result = haar_dwt_2d(&data, 8, 8); let hl_energy: f64 = result.hl.iter().map(|v| v * v).sum(); assert!(hl_energy > 0.0); @@ -475,19 +286,11 @@ mod tests { #[test] fn test_dct_ortho_energy_preservation() { - let mut block = [ - 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, - 16.0, - ]; + let mut block = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; let energy_before: f64 = block.iter().map(|x| x * x).sum(); apply_2d_dct_ortho(&mut block, 4); let energy_after: f64 = block.iter().map(|x| x * x).sum(); - assert!( - (energy_before - energy_after).abs() < 0.1, - "before={:.1}, after={:.1}", - energy_before, - energy_after - ); + assert!((energy_before - energy_after).abs() < 0.1, "before={:.1}, after={:.1}", energy_before, energy_after); } #[test] @@ -499,7 +302,6 @@ mod tests { #[test] fn test_extract_bits_deterministic() { - // Same input should produce same bits let data = vec![42.0; 128 * 128]; let bits1 = extract_bits(&data, 128, 128, 36.0, &[1, 2]); let bits2 = extract_bits(&data, 128, 128, 36.0, &[1, 2]); diff --git a/src/detector/wav_metadata.rs b/src/detector/wav_metadata.rs index f14b522..206cb41 100644 --- a/src/detector/wav_metadata.rs +++ b/src/detector/wav_metadata.rs @@ -2,25 +2,18 @@ use anyhow::Result; use std::fs; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; use crate::known_tools; -/// TTS-typical sample rates (not used in professional music/speech recording). -/// 22050 and 24000 Hz are the most common TTS model output rates. +/// TTS-typical sample rates. const TTS_SAMPLE_RATES: &[u32] = &[22050, 24000, 16000]; -// --------------------------------------------------------------------------- -// RIFF/WAV parsing -// --------------------------------------------------------------------------- - -/// Parsed WAV format information. pub(crate) struct WavFmt { pub channels: u16, pub sample_rate: u32, pub bits_per_sample: u16, } -/// Parsed WAV file: format + INFO metadata + raw PCM data range. #[allow(dead_code)] pub(crate) struct WavFile { pub fmt: WavFmt, @@ -29,13 +22,11 @@ pub(crate) struct WavFile { pub pcm_end: usize, } -/// Parse a WAV file and extract format info, LIST/INFO metadata, and data chunk location. pub(crate) fn parse_wav_full(data: &[u8]) -> Option { let (fmt, info_entries, pcm_start, pcm_end) = parse_wav_inner(data)?; Some(WavFile { fmt, info_entries, pcm_start, pcm_end }) } -/// Parse a WAV file and extract format info + LIST/INFO metadata. fn parse_wav(data: &[u8]) -> Option<(WavFmt, Vec<(String, String)>)> { let (fmt, info, _, _) = parse_wav_inner(data)?; Some((fmt, info)) @@ -43,28 +34,18 @@ fn parse_wav(data: &[u8]) -> Option<(WavFmt, Vec<(String, String)>)> { #[allow(clippy::type_complexity)] fn parse_wav_inner(data: &[u8]) -> Option<(WavFmt, Vec<(String, String)>, usize, usize)> { - // Minimum: RIFF(4) + size(4) + WAVE(4) + fmt chunk header(8) + fmt data(16) = 36 - if data.len() < 36 { - return None; - } - if &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" { - return None; - } + if data.len() < 36 { return None; } + if &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" { return None; } let mut fmt = None; let mut info_entries = Vec::new(); let mut data_start = 0usize; let mut data_end = 0usize; - let mut pos = 12; // After "WAVE" + let mut pos = 12; while pos + 8 <= data.len() { let chunk_id = &data[pos..pos + 4]; - let chunk_size = u32::from_le_bytes([ - data[pos + 4], - data[pos + 5], - data[pos + 6], - data[pos + 7], - ]) as usize; + let chunk_size = u32::from_le_bytes([data[pos+4], data[pos+5], data[pos+6], data[pos+7]]) as usize; let chunk_data_start = pos + 8; let chunk_data_end = (chunk_data_start + chunk_size).min(data.len()); @@ -81,48 +62,25 @@ fn parse_wav_inner(data: &[u8]) -> Option<(WavFmt, Vec<(String, String)>, usize, } else if chunk_id == b"LIST" && chunk_size >= 4 { let list_type = &data[chunk_data_start..chunk_data_start + 4]; if list_type == b"INFO" { - // Parse INFO sub-chunks let mut sub_pos = chunk_data_start + 4; while sub_pos + 8 <= chunk_data_end { - let sub_id = std::str::from_utf8(&data[sub_pos..sub_pos + 4]) - .unwrap_or("????") - .to_string(); - let sub_size = u32::from_le_bytes([ - data[sub_pos + 4], - data[sub_pos + 5], - data[sub_pos + 6], - data[sub_pos + 7], - ]) as usize; + let sub_id = std::str::from_utf8(&data[sub_pos..sub_pos + 4]).unwrap_or("????").to_string(); + let sub_size = u32::from_le_bytes([data[sub_pos+4], data[sub_pos+5], data[sub_pos+6], data[sub_pos+7]]) as usize; let sub_data_start = sub_pos + 8; let sub_data_end = (sub_data_start + sub_size).min(chunk_data_end); - if sub_data_start < sub_data_end { - let value = String::from_utf8_lossy(&data[sub_data_start..sub_data_end]) - .trim_matches('\0') - .to_string(); - if !value.is_empty() { - info_entries.push((sub_id, value)); - } + let value = String::from_utf8_lossy(&data[sub_data_start..sub_data_end]).trim_matches('\0').to_string(); + if !value.is_empty() { info_entries.push((sub_id, value)); } } - - // Sub-chunks are word-aligned sub_pos = sub_data_start + ((sub_size + 1) & !1); } } } - - // Chunks are word-aligned pos = chunk_data_start + ((chunk_size + 1) & !1); } - fmt.map(|f| (f, info_entries, data_start, data_end)) } -// --------------------------------------------------------------------------- -// Detection -// --------------------------------------------------------------------------- - -/// Detect AI signals from WAV RIFF metadata and audio characteristics. pub fn detect(path: &Path) -> Result> { let data = fs::read(path)?; let (fmt, info_entries) = match parse_wav(&data) { @@ -132,75 +90,58 @@ pub fn detect(path: &Path) -> Result> { let mut signals = Vec::new(); - // Method A: Check LIST/INFO chunks for AI tool references let tool_keys = ["ISFT", "ICMT", "IART", "IENG", "IPRD", "IGNR"]; for (key, value) in &info_entries { if tool_keys.contains(&key.as_str()) { if let Some(tool_name) = known_tools::match_ai_tool(value) { - signals.push(Signal { - source: SignalSource::WavMetadata, - confidence: Confidence::Medium, - description: format!("WAV INFO {} matches AI tool: {}", key, value), - tool: Some(tool_name.to_string()), - details: vec![(key.clone(), value.clone())], - }); + signals.push( + SignalBuilder::new(SignalSource::WavMetadata, Confidence::Medium, "signal_wav_info_tool") + .param("key", key.as_str()) + .param("value", value.as_str()) + .tool(tool_name) + .detail(key.as_str(), value.as_str()) + .build(), + ); } } } - // Method B: Audio characteristics heuristic for TTS detection - // TTS hallmarks: mono + non-standard sample rate (22050/24000/16000 Hz) let is_tts_rate = TTS_SAMPLE_RATES.contains(&fmt.sample_rate); let is_mono = fmt.channels == 1; if is_mono && is_tts_rate { - signals.push(Signal { - source: SignalSource::WavMetadata, - confidence: Confidence::Low, - description: format!( - "Audio characteristics suggest TTS: mono {}Hz {}bit", - fmt.sample_rate, fmt.bits_per_sample - ), - tool: None, - details: vec![ - ("channels".to_string(), fmt.channels.to_string()), - ("sample_rate".to_string(), format!("{}Hz", fmt.sample_rate)), - ("bits_per_sample".to_string(), fmt.bits_per_sample.to_string()), - ], - }); + signals.push( + SignalBuilder::new(SignalSource::WavMetadata, Confidence::Low, "signal_wav_tts_heuristic") + .param("rate", fmt.sample_rate.to_string()) + .param("bits", fmt.bits_per_sample.to_string()) + .detail("channels", fmt.channels.to_string()) + .detail("sample_rate", format!("{}Hz", fmt.sample_rate)) + .detail("bits_per_sample", fmt.bits_per_sample.to_string()) + .build(), + ); } Ok(signals) } -/// Dump WAV metadata for the `info` subcommand. pub fn dump_info(path: &Path) -> Result> { let data = fs::read(path)?; let (fmt, info_entries) = match parse_wav(&data) { Some(r) => r, None => return Ok(vec![]), }; - let mut props = Vec::new(); props.push(("Sample Rate".to_string(), format!("{}Hz", fmt.sample_rate))); props.push(("Channels".to_string(), fmt.channels.to_string())); props.push(("Bits Per Sample".to_string(), fmt.bits_per_sample.to_string())); - for (key, value) in info_entries { let label = match key.as_str() { - "ISFT" => "Software (ISFT)", - "ICMT" => "Comment (ICMT)", - "IART" => "Artist (IART)", - "IENG" => "Engineer (IENG)", - "IPRD" => "Product (IPRD)", - "IGNR" => "Genre (IGNR)", - "INAM" => "Name (INAM)", - "ICRD" => "Date (ICRD)", - other => other, + "ISFT" => "Software (ISFT)", "ICMT" => "Comment (ICMT)", "IART" => "Artist (IART)", + "IENG" => "Engineer (IENG)", "IPRD" => "Product (IPRD)", "IGNR" => "Genre (IGNR)", + "INAM" => "Name (INAM)", "ICRD" => "Date (ICRD)", other => other, }; props.push((label.to_string(), value)); } - Ok(props) } @@ -208,63 +149,46 @@ pub fn dump_info(path: &Path) -> Result> { mod tests { use super::*; - /// Build a minimal valid WAV file in memory. fn make_wav(channels: u16, sample_rate: u32, bits_per_sample: u16, info_chunks: &[(&str, &str)]) -> Vec { let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8; let block_align = channels * bits_per_sample / 8; - // Tiny data chunk: 100 samples of silence let data_size = 100u32 * block_align as u32; - let mut buf = Vec::new(); - - // Build INFO list if needed let mut info_buf = Vec::new(); if !info_chunks.is_empty() { info_buf.extend_from_slice(b"INFO"); for &(key, value) in info_chunks { let val_bytes = value.as_bytes(); - let padded_len = ((val_bytes.len() + 1 + 1) & !1) as u32; // +1 for null, word-align + let padded_len = ((val_bytes.len() + 1 + 1) & !1) as u32; info_buf.extend_from_slice(key.as_bytes()); info_buf.extend_from_slice(&padded_len.to_le_bytes()); info_buf.extend_from_slice(val_bytes); - info_buf.push(0); // null terminator - if (val_bytes.len() + 1) % 2 != 0 { - info_buf.push(0); // padding - } + info_buf.push(0); + if (val_bytes.len() + 1) % 2 != 0 { info_buf.push(0); } } } - let fmt_size = 16u32; let list_chunk_size = if info_buf.is_empty() { 0 } else { 8 + info_buf.len() as u32 }; let riff_size = 4 + 8 + fmt_size + 8 + data_size + list_chunk_size; - - // RIFF header buf.extend_from_slice(b"RIFF"); buf.extend_from_slice(&riff_size.to_le_bytes()); buf.extend_from_slice(b"WAVE"); - - // fmt chunk buf.extend_from_slice(b"fmt "); buf.extend_from_slice(&fmt_size.to_le_bytes()); - buf.extend_from_slice(&1u16.to_le_bytes()); // PCM + buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&channels.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes()); buf.extend_from_slice(&byte_rate.to_le_bytes()); buf.extend_from_slice(&block_align.to_le_bytes()); buf.extend_from_slice(&bits_per_sample.to_le_bytes()); - - // LIST/INFO chunk if !info_buf.is_empty() { buf.extend_from_slice(b"LIST"); buf.extend_from_slice(&(info_buf.len() as u32).to_le_bytes()); buf.extend_from_slice(&info_buf); } - - // data chunk buf.extend_from_slice(b"data"); buf.extend_from_slice(&data_size.to_le_bytes()); buf.extend_from_slice(&vec![0u8; data_size as usize]); - buf } @@ -297,7 +221,6 @@ mod tests { let signals = detect(tmp.path()).unwrap(); assert_eq!(signals.len(), 1); assert_eq!(signals[0].confidence, Confidence::Low); - assert!(signals[0].description.contains("TTS")); } #[test] diff --git a/src/detector/xmp.rs b/src/detector/xmp.rs index f0c5a76..ee71ea2 100644 --- a/src/detector/xmp.rs +++ b/src/detector/xmp.rs @@ -2,35 +2,17 @@ use anyhow::Result; use std::fs; use std::path::Path; -use super::{Confidence, Signal, SignalSource}; +use super::{Confidence, SignalBuilder, Signal, SignalSource}; use crate::known_tools; /// IPTC DigitalSourceType URIs/names that indicate AI generation. const AI_SOURCE_TYPES: &[(&str, &str)] = &[ - ( - "trainedAlgorithmicMedia", - "trainedAlgorithmicMedia", - ), - ( - "compositeWithTrainedAlgorithmicMedia", - "compositeWithTrainedAlgorithmicMedia", - ), - ( - "algorithmicMedia", - "algorithmicMedia", - ), - ( - "compositeSynthetic", - "compositeSynthetic", - ), - ( - "dataDrivenMedia", - "dataDrivenMedia", - ), - ( - "trainedAlgorithmicData", - "trainedAlgorithmicData", - ), + ("trainedAlgorithmicMedia", "trainedAlgorithmicMedia"), + ("compositeWithTrainedAlgorithmicMedia", "compositeWithTrainedAlgorithmicMedia"), + ("algorithmicMedia", "algorithmicMedia"), + ("compositeSynthetic", "compositeSynthetic"), + ("dataDrivenMedia", "dataDrivenMedia"), + ("trainedAlgorithmicData", "trainedAlgorithmicData"), ]; /// XMP property names we search for in the raw XML. @@ -43,17 +25,11 @@ const XMP_AI_PROPERTIES: &[&str] = &[ ]; /// Extract raw XMP XML from a file's bytes. -/// XMP is embedded as XML between markers in JPEG, PNG, TIFF, PDF, etc. fn extract_xmp_xml(data: &[u8]) -> Option { - // Look for XMP packet markers let begin_marker = b""; - - // Also try without x: prefix let begin_marker2 = b""; - - // Also try rdf:RDF directly (some files embed XMP without xmpmeta wrapper) let begin_marker3 = b""; @@ -78,16 +54,11 @@ fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } -/// Extract a simple property value from XMP XML by looking for tags like -/// value or attributes. fn extract_property(xml: &str, prop_name: &str) -> Option { - // Pattern 1: value - // We search for any namespace prefix for prefix in &["Iptc4xmpExt:", "xmp:", "dc:", "photoshop:", ""] { let open_tag = format!("<{}{}", prefix, prop_name); if let Some(start) = xml.find(&open_tag) { let after_tag = &xml[start + open_tag.len()..]; - // Find the end of the opening tag (could have attributes) if let Some(gt_pos) = after_tag.find('>') { let content_start = gt_pos + 1; let close_tag = format!("", prefix, prop_name); @@ -103,7 +74,6 @@ fn extract_property(xml: &str, prop_name: &str) -> Option { } } - // Pattern 2: ns:PropName="value" (as attribute) for prefix in &["Iptc4xmpExt:", "xmp:", "dc:", "photoshop:", ""] { let attr = format!("{}{}=\"", prefix, prop_name); if let Some(start) = xml.find(&attr) { @@ -122,7 +92,6 @@ fn extract_property(xml: &str, prop_name: &str) -> Option { /// Detect AI signals from XMP/IPTC metadata embedded in the file. pub fn detect(path: &Path) -> Result> { - // Read first 1MB — XMP is typically near the beginning of the file let data = fs::read(path)?; let search_data = if data.len() > 1_048_576 { &data[..1_048_576] @@ -137,55 +106,48 @@ pub fn detect(path: &Path) -> Result> { let mut signals = Vec::new(); - // Check DigitalSourceType if let Some(value) = extract_property(&xml, "DigitalSourceType") { for (name, pattern) in AI_SOURCE_TYPES { if value.contains(pattern) { - signals.push(Signal { - source: SignalSource::Xmp, - confidence: Confidence::Medium, - description: format!("DigitalSourceType = {}", name), - tool: None, - details: vec![("DigitalSourceType".into(), value.clone())], - }); + signals.push( + SignalBuilder::new(SignalSource::Xmp, Confidence::Medium, "signal_xmp_digital_source_type") + .param("value", *name) + .detail("DigitalSourceType", &value) + .build(), + ); break; } } } - // Check AISystemUsed if let Some(value) = extract_property(&xml, "AISystemUsed") { let tool = known_tools::match_ai_tool(&value).map(|s| s.to_string()); - signals.push(Signal { - source: SignalSource::Xmp, - confidence: Confidence::Medium, - description: format!("AISystemUsed = {}", value), - tool, - details: vec![("AISystemUsed".into(), value)], - }); + signals.push( + SignalBuilder::new(SignalSource::Xmp, Confidence::Medium, "signal_xmp_ai_system_used") + .param("value", &value) + .tool_opt(tool) + .detail("AISystemUsed", &value) + .build(), + ); } - // Check AIPromptInformation if let Some(value) = extract_property(&xml, "AIPromptInformation") { - signals.push(Signal { - source: SignalSource::Xmp, - confidence: Confidence::Medium, - description: "AIPromptInformation present".to_string(), - tool: None, - details: vec![("AIPromptInformation".into(), value)], - }); + signals.push( + SignalBuilder::new(SignalSource::Xmp, Confidence::Medium, "signal_xmp_ai_prompt") + .detail("AIPromptInformation", &value) + .build(), + ); } - // Check CreatorTool if let Some(value) = extract_property(&xml, "CreatorTool") { if let Some(tool_name) = known_tools::match_ai_tool(&value) { - signals.push(Signal { - source: SignalSource::Xmp, - confidence: Confidence::Medium, - description: format!("CreatorTool matches AI tool: {}", value), - tool: Some(tool_name.to_string()), - details: vec![("CreatorTool".into(), value)], - }); + signals.push( + SignalBuilder::new(SignalSource::Xmp, Confidence::Medium, "signal_xmp_creator_tool") + .param("value", &value) + .tool(tool_name) + .detail("CreatorTool", &value) + .build(), + ); } } @@ -207,7 +169,6 @@ pub fn dump_info(path: &Path) -> Result> { }; let mut props = Vec::new(); - for prop_name in XMP_AI_PROPERTIES { if let Some(value) = extract_property(&xml, prop_name) { props.push((prop_name.to_string(), value)); diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..bcf8e1c --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,71 @@ +//! Internationalization helpers. +//! +//! Provides locale detection, initialization, and message rendering with +//! named parameter substitution. + +/// Supported locale codes. +const SUPPORTED_LOCALES: &[&str] = &["en", "zh-CN", "de", "ja", "ko", "hi", "es"]; + +/// Map a raw locale string (e.g. "zh_CN.UTF-8", "de_DE") to a supported locale code. +fn normalize_locale(raw: &str) -> &'static str { + let lower = raw.to_lowercase(); + + // Try exact match first + for &loc in SUPPORTED_LOCALES { + if lower == loc.to_lowercase() { + return loc; + } + } + + // Try prefix match (e.g. "zh_cn.utf-8" -> "zh-CN", "de_de.utf-8" -> "de") + let normalized = lower.replace('_', "-").replace(".utf-8", ""); + for &loc in SUPPORTED_LOCALES { + if normalized.starts_with(&loc.to_lowercase()) { + return loc; + } + } + + // Try language-only match (e.g. "zh" -> "zh-CN") + let lang = normalized.split('-').next().unwrap_or(""); + match lang { + "zh" => "zh-CN", + "de" => "de", + "ja" => "ja", + "ko" => "ko", + "hi" => "hi", + "es" => "es", + _ => "en", + } +} + +/// Initialize the locale from --lang flag or system locale. +pub fn init_locale(lang_override: Option<&str>) { + let locale = match lang_override { + Some(lang) => normalize_locale(lang), + None => { + let sys = sys_locale::get_locale().unwrap_or_else(|| "en".to_string()); + normalize_locale(&sys) + } + }; + rust_i18n::set_locale(locale); +} + +/// Render a translated message with named parameter substitution. +/// +/// Parameters in the translation string use the `%{name}` syntax. +pub fn t(key: &str, params: &[(&str, &str)]) -> String { + let mut msg = rust_i18n::t!(key).to_string(); + for &(name, value) in params { + msg = msg.replace(&format!("%{{{}}}", name), value); + } + msg +} + +/// Render a translated message in English (for JSON output / stored descriptions). +pub fn t_en(key: &str, params: &[(&str, &str)]) -> String { + let mut msg = rust_i18n::t!(key, locale = "en").to_string(); + for &(name, value) in params { + msg = msg.replace(&format!("%{{{}}}", name), value); + } + msg +} diff --git a/src/main.rs b/src/main.rs index 426da12..04a7271 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod detector; +mod i18n; mod known_tools; mod output; mod scanner; @@ -9,6 +10,8 @@ use cli::{Cli, Command}; use colored::control::set_override; use std::process::ExitCode; +rust_i18n::i18n!("locales", fallback = "en"); + fn main() -> ExitCode { let args = Cli::parse(); @@ -16,6 +19,8 @@ fn main() -> ExitCode { set_override(false); } + i18n::init_locale(args.lang.as_deref()); + match args.command { Command::Check(ref check_args) => cmd_check(&args, check_args), Command::Info(ref info_args) => cmd_info(info_args), @@ -27,7 +32,7 @@ fn cmd_check(args: &Cli, check_args: &cli::CheckArgs) -> ExitCode { Ok(f) => f, Err(e) => { if !args.quiet { - eprintln!("Error: {}", e); + eprintln!("{}", i18n::t("error_generic", &[("msg", &e.to_string())])); } return ExitCode::from(2); } @@ -35,7 +40,7 @@ fn cmd_check(args: &Cli, check_args: &cli::CheckArgs) -> ExitCode { if files.is_empty() { if !args.quiet { - eprintln!("No supported files found."); + eprintln!("{}", i18n::t("error_no_files", &[])); } return ExitCode::from(2); } @@ -80,7 +85,10 @@ fn cmd_info(info_args: &cli::InfoArgs) -> ExitCode { let path = &info_args.file; if !path.exists() { - eprintln!("Error: {} not found", path.display()); + eprintln!( + "{}", + i18n::t("error_not_found", &[("path", &path.display().to_string())]) + ); return ExitCode::from(2); } diff --git a/src/output.rs b/src/output.rs index 1ac3720..b1c726f 100644 --- a/src/output.rs +++ b/src/output.rs @@ -2,6 +2,7 @@ use colored::Colorize; use serde::Serialize; use crate::detector::{Confidence, FileReport}; +use crate::i18n; #[derive(Serialize)] struct JsonOutput<'a> { @@ -50,12 +51,12 @@ pub fn print_human(reports: &[FileReport]) { } if report.signals.is_empty() { - println!(" {}", "No AI-generation signals detected.".dimmed()); + println!(" {}", i18n::t("output_no_signals", &[]).dimmed()); continue; } for signal in &report.signals { - let conf_str = format!("{:<6}", signal.confidence.to_string()); + let conf_str = format!("{:<6}", signal.confidence.localized()); let colored_conf = match signal.confidence { Confidence::High => conf_str.red().bold(), Confidence::Medium => conf_str.yellow().bold(), @@ -63,7 +64,8 @@ pub fn print_human(reports: &[FileReport]) { Confidence::None => conf_str.dimmed(), }; let source = format!("{}", signal.source); - print!(" {} {}: {}", colored_conf, source.dimmed(), signal.description); + let desc = signal.localized_description(); + print!(" {} {}: {}", colored_conf, source.dimmed(), desc); if let Some(tool) = &signal.tool { print!(" [{}]", tool.green()); } @@ -73,10 +75,10 @@ pub fn print_human(reports: &[FileReport]) { // Verdict let verdict = if report.ai_generated { let label = match report.overall_confidence { - Confidence::High => "AI-generated".red().bold(), - Confidence::Medium => "Likely AI-generated".yellow().bold(), - Confidence::Low => "Possibly AI-generated".blue(), - Confidence::None => "Unknown".dimmed(), + Confidence::High => i18n::t("verdict_ai_generated", &[]).red().bold(), + Confidence::Medium => i18n::t("verdict_likely_ai", &[]).yellow().bold(), + Confidence::Low => i18n::t("verdict_possibly_ai", &[]).blue(), + Confidence::None => i18n::t("verdict_unknown", &[]).dimmed(), }; format!( " Verdict: {} (confidence: {})", @@ -84,7 +86,7 @@ pub fn print_human(reports: &[FileReport]) { report.overall_confidence ) } else { - format!(" Verdict: {}", "Not detected as AI-generated".green()) + format!(" Verdict: {}", i18n::t("verdict_not_detected", &[]).green()) }; println!("{}", verdict); } @@ -95,9 +97,15 @@ pub fn print_human(reports: &[FileReport]) { println!(); println!( "{}", - format!( - "Results: {}/{} files with AI signals ({} HIGH, {} MEDIUM, {} LOW)", - summary.ai_detected, summary.total, summary.high, summary.medium, summary.low + i18n::t( + "output_summary", + &[ + ("detected", &summary.ai_detected.to_string()), + ("total", &summary.total.to_string()), + ("high", &summary.high.to_string()), + ("medium", &summary.medium.to_string()), + ("low", &summary.low.to_string()), + ], ) .bold() ); @@ -111,7 +119,7 @@ pub fn print_json(reports: &[FileReport]) { }; match serde_json::to_string_pretty(&output) { Ok(json) => println!("{}", json), - Err(e) => eprintln!("Error: failed to serialize JSON: {}", e), + Err(e) => eprintln!("{}", i18n::t("error_json_serialize", &[("err", &e.to_string())])), } } @@ -119,7 +127,7 @@ pub fn print_json(reports: &[FileReport]) { pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fields: &[(String, String)], mp4_meta: &[(String, String)], id3_tags: &[(String, String)], wav_meta: &[(String, String)]) { println!("{}", report.path.display().to_string().bold()); if let Some(mime) = &report.mime_type { - println!(" Type: {}", mime); + println!(" {}", i18n::t("output_type_label", &[("mime", mime.as_str())])); } println!(); @@ -130,9 +138,9 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel .filter(|s| matches!(s.source, crate::detector::SignalSource::C2pa)) .collect(); if !c2pa_signals.is_empty() { - println!("{}", "=== C2PA Manifest ===".cyan().bold()); + println!("{}", i18n::t("info_c2pa_header", &[]).cyan().bold()); for signal in &c2pa_signals { - println!(" {}", signal.description); + println!(" {}", signal.localized_description()); for (key, val) in &signal.details { println!(" {}: {}", key.dimmed(), val); } @@ -142,7 +150,7 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel // XMP section if !xmp_props.is_empty() { - println!("{}", "=== XMP Metadata ===".cyan().bold()); + println!("{}", i18n::t("info_xmp_header", &[]).cyan().bold()); for (key, val) in xmp_props { println!(" {}: {}", key, val); } @@ -151,7 +159,7 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel // EXIF section if !exif_fields.is_empty() { - println!("{}", "=== EXIF Data ===".cyan().bold()); + println!("{}", i18n::t("info_exif_header", &[]).cyan().bold()); for (key, val) in exif_fields { println!(" {}: {}", key, val); } @@ -160,7 +168,7 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel // MP4 Metadata section if !mp4_meta.is_empty() { - println!("{}", "=== MP4 Metadata ===".cyan().bold()); + println!("{}", i18n::t("info_mp4_header", &[]).cyan().bold()); for (key, val) in mp4_meta { println!(" {}: {}", key, val); } @@ -169,7 +177,7 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel // ID3 Tags section if !id3_tags.is_empty() { - println!("{}", "=== ID3 Tags ===".cyan().bold()); + println!("{}", i18n::t("info_id3_header", &[]).cyan().bold()); for (key, val) in id3_tags { println!(" {}: {}", key, val); } @@ -178,7 +186,7 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel // WAV Metadata section if !wav_meta.is_empty() { - println!("{}", "=== WAV Metadata ===".cyan().bold()); + println!("{}", i18n::t("info_wav_header", &[]).cyan().bold()); for (key, val) in wav_meta { println!(" {}: {}", key, val); } @@ -192,9 +200,9 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel .filter(|s| matches!(s.source, crate::detector::SignalSource::Watermark)) .collect(); if !wm_signals.is_empty() { - println!("{}", "=== Watermark Analysis ===".cyan().bold()); + println!("{}", i18n::t("info_watermark_header", &[]).cyan().bold()); for signal in &wm_signals { - println!(" {}", signal.description); + println!(" {}", signal.localized_description()); for (key, val) in &signal.details { println!(" {}: {}", key.dimmed(), val); } @@ -203,6 +211,6 @@ pub fn print_info(report: &FileReport, xmp_props: &[(String, String)], exif_fiel } if c2pa_signals.is_empty() && xmp_props.is_empty() && exif_fields.is_empty() && mp4_meta.is_empty() && id3_tags.is_empty() && wav_meta.is_empty() && wm_signals.is_empty() { - println!("{}", "No provenance metadata found.".dimmed()); + println!("{}", i18n::t("info_no_metadata", &[]).dimmed()); } } diff --git a/src/scanner.rs b/src/scanner.rs index 751a9ca..b9b4bb9 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -2,6 +2,8 @@ use anyhow::Result; use std::path::{Path, PathBuf}; use walkdir::WalkDir; +use crate::i18n; + /// Supported MIME types for analysis. const SUPPORTED_TYPES: &[&str] = &[ "image/jpeg", @@ -79,7 +81,10 @@ pub fn discover_files(paths: &[PathBuf], recursive: bool) -> Result } } } else { - eprintln!("Warning: {} not found, skipping", path.display()); + eprintln!( + "{}", + i18n::t("scanner_not_found", &[("path", &path.display().to_string())]) + ); } } diff --git a/tests/audio_id3.rs b/tests/audio_id3.rs index a281c70..b567984 100644 --- a/tests/audio_id3.rs +++ b/tests/audio_id3.rs @@ -24,7 +24,7 @@ fn suno_mp3_json_output() { #[test] fn suno_mp3_info_shows_id3_tags() { cargo_bin_cmd!("aic") - .args(["info", "tests/fixtures/ai_suno.mp3"]) + .args(["--lang", "en", "info", "tests/fixtures/ai_suno.mp3"]) .assert() .success() .stdout(predicate::str::contains("ID3 Tags")) diff --git a/tests/video_c2pa.rs b/tests/video_c2pa.rs index 23011a8..a2e5731 100644 --- a/tests/video_c2pa.rs +++ b/tests/video_c2pa.rs @@ -24,7 +24,7 @@ fn mp4_with_c2pa_json_output() { #[test] fn mp4_without_c2pa_not_detected() { cargo_bin_cmd!("aic") - .args(["check", "tests/fixtures/no_c2pa.mp4"]) + .args(["--lang", "en", "check", "tests/fixtures/no_c2pa.mp4"]) .assert() .code(1) // exit 1 = no AI detected .stdout(predicate::str::contains("No AI-generation signals detected")); diff --git a/tests/watermark_detection.rs b/tests/watermark_detection.rs index 0bc1c05..0b972bb 100644 --- a/tests/watermark_detection.rs +++ b/tests/watermark_detection.rs @@ -4,7 +4,7 @@ use predicates::prelude::*; #[test] fn watermarked_dwtdct_detected_with_deep() { cargo_bin_cmd!("aic") - .args(["check", "--deep", "tests/fixtures/watermarked_dwtdct.png"]) + .args(["--lang", "en", "check", "--deep", "tests/fixtures/watermarked_dwtdct.png"]) .assert() .success() // exit 0 = AI detected .stdout(predicate::str::contains("WATERMARK")) @@ -23,7 +23,7 @@ fn watermarked_dwtdctsvd_detected_with_deep() { #[test] fn clean_image_not_detected_with_deep() { cargo_bin_cmd!("aic") - .args(["check", "--deep", "tests/fixtures/clean_synthetic.png"]) + .args(["--lang", "en", "check", "--deep", "tests/fixtures/clean_synthetic.png"]) .assert() .code(1) // exit 1 = no AI detected .stdout(predicate::str::contains("No AI-generation signals detected")); @@ -68,7 +68,7 @@ fn watermarked_json_output() { #[test] fn watermark_info_command() { cargo_bin_cmd!("aic") - .args(["info", "tests/fixtures/watermarked_dwtdct.png"]) + .args(["--lang", "en", "info", "tests/fixtures/watermarked_dwtdct.png"]) .assert() .success() .stdout(predicate::str::contains("Watermark Analysis"));