Pinned the container, some other resiliancy stuff.
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
# Stegasoo Docker Image
|
||||
# Multi-stage build for smaller image size
|
||||
|
||||
FROM python:3.11-slim as base
|
||||
# Pin the base image digest for reproducibility
|
||||
# To update: docker manifest inspect python:3.11-slim -v | jq -r '.[0].Descriptor.digest'
|
||||
FROM python:3.11-slim@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 as base
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
# Suppress pip "running as root" warnings during build
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -6,6 +6,7 @@ Usage:
|
||||
stegasoo generate [OPTIONS]
|
||||
stegasoo encode [OPTIONS]
|
||||
stegasoo decode [OPTIONS]
|
||||
stegasoo verify [OPTIONS]
|
||||
stegasoo info [OPTIONS]
|
||||
"""
|
||||
|
||||
@@ -28,6 +29,9 @@ from stegasoo import (
|
||||
DAY_NAMES, __version__,
|
||||
StegasooError, DecryptionError, ExtractionError,
|
||||
FilePayload,
|
||||
# New in 2.2.1
|
||||
will_fit,
|
||||
strip_image_metadata,
|
||||
)
|
||||
|
||||
# QR Code utilities
|
||||
@@ -273,6 +277,16 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
|
||||
ref_photo = Path(ref).read_bytes()
|
||||
carrier_image = Path(carrier).read_bytes()
|
||||
|
||||
# Pre-check capacity
|
||||
fit_check = will_fit(payload, carrier_image)
|
||||
if not fit_check['fits']:
|
||||
raise click.ClickException(
|
||||
f"Payload too large for carrier image.\n"
|
||||
f" Payload: {fit_check['payload_size']:,} bytes\n"
|
||||
f" Capacity: {fit_check['capacity']:,} bytes\n"
|
||||
f" Shortfall: {-fit_check['headroom']:,} bytes"
|
||||
)
|
||||
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
@@ -302,6 +316,8 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
|
||||
|
||||
except StegasooError as e:
|
||||
raise click.ClickException(str(e))
|
||||
except click.ClickException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Error: {e}")
|
||||
|
||||
@@ -431,6 +447,129 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet
|
||||
raise click.ClickException(f"Error: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VERIFY COMMAND
|
||||
# ============================================================================
|
||||
|
||||
@cli.command()
|
||||
@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo')
|
||||
@click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image')
|
||||
@click.option('--phrase', '-p', required=True, help='Day phrase')
|
||||
@click.option('--pin', help='Static PIN')
|
||||
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
|
||||
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
|
||||
@click.option('--key-password', help='RSA key password (for encrypted .pem files)')
|
||||
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
||||
def verify(ref, stego, phrase, pin, key, key_qr, key_password, as_json):
|
||||
"""
|
||||
Verify that a stego image can be decoded without extracting the message.
|
||||
|
||||
Quick check to validate credentials are correct and data is intact.
|
||||
Does NOT output the actual message content.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
stegasoo verify -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456
|
||||
|
||||
stegasoo verify -r photo.jpg -s stego.png -p "words" -k mykey.pem --json
|
||||
"""
|
||||
# Load key if provided
|
||||
rsa_key_data = None
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if key and key_qr:
|
||||
raise click.UsageError("Cannot use both --key and --key-qr. Choose one.")
|
||||
|
||||
if key:
|
||||
rsa_key_data = Path(key).read_bytes()
|
||||
elif key_qr:
|
||||
if not HAS_QR or not has_qr_read():
|
||||
raise click.ClickException(
|
||||
"QR code reading not available. Install: pip install pyzbar\n"
|
||||
"Also requires system library: sudo apt-get install libzbar0"
|
||||
)
|
||||
key_pem = extract_key_from_qr_file(key_qr)
|
||||
if not key_pem:
|
||||
raise click.ClickException(f"Could not extract RSA key from QR code: {key_qr}")
|
||||
rsa_key_data = key_pem.encode('utf-8')
|
||||
rsa_key_from_qr = True
|
||||
|
||||
effective_key_password = None if rsa_key_from_qr else key_password
|
||||
|
||||
if not pin and not rsa_key_data:
|
||||
raise click.UsageError("Must provide --pin or --key/--key-qr (or both)")
|
||||
|
||||
try:
|
||||
ref_photo = Path(ref).read_bytes()
|
||||
stego_image = Path(stego).read_bytes()
|
||||
|
||||
# Attempt to decode
|
||||
result = decode(
|
||||
stego_image=stego_image,
|
||||
reference_photo=ref_photo,
|
||||
day_phrase=phrase,
|
||||
pin=pin or "",
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_key_password,
|
||||
)
|
||||
|
||||
# Calculate payload size
|
||||
if result.is_file:
|
||||
payload_size = len(result.file_data) if result.file_data else 0
|
||||
payload_type = "file"
|
||||
payload_desc = result.filename or "unnamed file"
|
||||
if result.mime_type:
|
||||
payload_desc += f" ({result.mime_type})"
|
||||
else:
|
||||
payload_size = len(result.message.encode('utf-8')) if result.message else 0
|
||||
payload_type = "text"
|
||||
payload_desc = f"{payload_size} bytes"
|
||||
|
||||
# Get date info
|
||||
date_encoded = result.date_encoded
|
||||
day_name = get_day_from_date(date_encoded) if date_encoded else None
|
||||
|
||||
if as_json:
|
||||
import json
|
||||
output = {
|
||||
"valid": True,
|
||||
"stego_file": stego,
|
||||
"payload_type": payload_type,
|
||||
"payload_size": payload_size,
|
||||
"date_encoded": date_encoded,
|
||||
"day_encoded": day_name,
|
||||
}
|
||||
if result.is_file:
|
||||
output["filename"] = result.filename
|
||||
output["mime_type"] = result.mime_type
|
||||
click.echo(json.dumps(output, indent=2))
|
||||
else:
|
||||
click.secho("✓ Valid stego image", fg='green', bold=True)
|
||||
click.echo(f" Payload: {payload_type} ({payload_desc})")
|
||||
click.echo(f" Size: {payload_size:,} bytes")
|
||||
if date_encoded:
|
||||
click.echo(f" Encoded: {date_encoded} ({day_name})")
|
||||
|
||||
except (DecryptionError, ExtractionError) as e:
|
||||
if as_json:
|
||||
import json
|
||||
output = {
|
||||
"valid": False,
|
||||
"stego_file": stego,
|
||||
"error": str(e),
|
||||
}
|
||||
click.echo(json.dumps(output, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.secho("✗ Verification failed", fg='red', bold=True)
|
||||
click.echo(f" Error: {e}")
|
||||
sys.exit(1)
|
||||
except StegasooError as e:
|
||||
raise click.ClickException(str(e))
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Error: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# INFO COMMAND
|
||||
# ============================================================================
|
||||
@@ -473,6 +612,50 @@ def info(image):
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STRIP-METADATA COMMAND
|
||||
# ============================================================================
|
||||
|
||||
@cli.command('strip-metadata')
|
||||
@click.argument('image', type=click.Path(exists=True))
|
||||
@click.option('--output', '-o', type=click.Path(), help='Output file (default: overwrites input)')
|
||||
@click.option('--format', '-f', 'output_format', type=click.Choice(['PNG', 'BMP']), default='PNG', help='Output format')
|
||||
@click.option('--quiet', '-q', is_flag=True, help='Suppress output')
|
||||
def strip_metadata_cmd(image, output, output_format, quiet):
|
||||
"""
|
||||
Remove all metadata (EXIF, GPS, etc.) from an image.
|
||||
|
||||
Creates a clean image with only pixel data - no camera info,
|
||||
location data, timestamps, or other potentially sensitive metadata.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
stegasoo strip-metadata photo.jpg -o clean.png
|
||||
stegasoo strip-metadata photo.jpg # Overwrites as PNG
|
||||
"""
|
||||
try:
|
||||
image_data = Path(image).read_bytes()
|
||||
original_size = len(image_data)
|
||||
|
||||
clean_data = strip_image_metadata(image_data, output_format)
|
||||
|
||||
if output:
|
||||
out_path = Path(output)
|
||||
else:
|
||||
# Replace extension with output format
|
||||
out_path = Path(image).with_suffix(f'.{output_format.lower()}')
|
||||
|
||||
out_path.write_bytes(clean_data)
|
||||
|
||||
if not quiet:
|
||||
click.secho("✓ Metadata stripped", fg='green')
|
||||
click.echo(f" Input: {image} ({original_size:,} bytes)")
|
||||
click.echo(f" Output: {out_path} ({len(clean_data):,} bytes)")
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
3
quick_web.sh
Executable file
3
quick_web.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
cd ./frontends/web/
|
||||
python app.py
|
||||
4
rbld_containers.sh
Executable file
4
rbld_containers.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
sudo docker-compose down
|
||||
sudo docker-compose build
|
||||
sudo docker-compose up -d
|
||||
@@ -58,6 +58,16 @@ File Embedding:
|
||||
else:
|
||||
print(decoded.message)
|
||||
|
||||
Capacity Pre-check (v2.2.1):
|
||||
from stegasoo import will_fit
|
||||
|
||||
# Check if payload will fit before encoding
|
||||
result = will_fit("My secret message", carrier_image)
|
||||
if result['fits']:
|
||||
print(f"Will use {result['usage_percent']:.1f}% capacity")
|
||||
else:
|
||||
print(f"Need {-result['headroom']} more bytes")
|
||||
|
||||
Debugging:
|
||||
from stegasoo.debug import debug
|
||||
debug.enable(True) # Enable debug output
|
||||
@@ -142,6 +152,8 @@ from .steganography import (
|
||||
get_image_format,
|
||||
is_lossless_format,
|
||||
LOSSLESS_FORMATS,
|
||||
# NEW in v2.2.1
|
||||
will_fit,
|
||||
)
|
||||
from .utils import (
|
||||
generate_filename,
|
||||
@@ -152,6 +164,8 @@ from .utils import (
|
||||
secure_delete,
|
||||
SecureDeleter,
|
||||
format_file_size,
|
||||
# NEW in v2.2.1
|
||||
strip_image_metadata,
|
||||
)
|
||||
from .debug import debug # Import debug utilities
|
||||
|
||||
@@ -177,6 +191,8 @@ from .batch import (
|
||||
BatchItem,
|
||||
BatchStatus,
|
||||
batch_capacity_check,
|
||||
# NEW in v2.2.1
|
||||
BatchCredentials,
|
||||
)
|
||||
|
||||
# QR Code utilities (optional, depends on qrcode and pyzbar)
|
||||
@@ -630,6 +646,7 @@ __all__ = [
|
||||
'get_image_dimensions',
|
||||
'get_image_format',
|
||||
'is_lossless_format',
|
||||
'will_fit', # NEW in v2.2.1
|
||||
|
||||
# Utilities
|
||||
'generate_filename',
|
||||
@@ -640,6 +657,7 @@ __all__ = [
|
||||
'secure_delete',
|
||||
'SecureDeleter',
|
||||
'format_file_size',
|
||||
'strip_image_metadata', # NEW in v2.2.1
|
||||
|
||||
# Debugging
|
||||
'debug',
|
||||
@@ -659,4 +677,5 @@ __all__ = [
|
||||
'BatchItem',
|
||||
'BatchStatus',
|
||||
'batch_capacity_check',
|
||||
'BatchCredentials', # NEW in v2.2.1
|
||||
]
|
||||
|
||||
@@ -61,6 +61,41 @@ class BatchItem:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchCredentials:
|
||||
"""
|
||||
Credentials for batch encode/decode operations.
|
||||
|
||||
Provides a structured way to pass authentication factors
|
||||
for batch processing instead of using plain dicts.
|
||||
|
||||
Example:
|
||||
creds = BatchCredentials(
|
||||
reference_photo=ref_bytes,
|
||||
day_phrase="apple forest thunder",
|
||||
pin="123456"
|
||||
)
|
||||
result = processor.batch_encode(images, creds, message="secret")
|
||||
"""
|
||||
reference_photo: bytes
|
||||
day_phrase: str
|
||||
pin: str = ""
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
rsa_password: Optional[str] = None
|
||||
date_str: Optional[str] = None # YYYY-MM-DD, defaults to today
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for legacy API compatibility."""
|
||||
return {
|
||||
"reference_photo": self.reference_photo,
|
||||
"day_phrase": self.day_phrase,
|
||||
"pin": self.pin,
|
||||
"rsa_key_data": self.rsa_key_data,
|
||||
"rsa_password": self.rsa_password,
|
||||
"date_str": self.date_str,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchResult:
|
||||
"""Summary of a batch operation."""
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "2.2.0"
|
||||
__version__ = "2.2.1"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
|
||||
@@ -6,13 +6,13 @@ LSB embedding and extraction with pseudo-random pixel selection.
|
||||
|
||||
import io
|
||||
import struct
|
||||
from typing import Optional, Tuple, List
|
||||
from typing import Optional, Tuple, List, Union
|
||||
|
||||
from PIL import Image
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
from .models import EmbedStats
|
||||
from .models import EmbedStats, FilePayload
|
||||
from .exceptions import CapacityError, ExtractionError, EmbeddingError
|
||||
from .debug import debug
|
||||
|
||||
@@ -35,6 +35,11 @@ EXT_TO_FORMAT = {
|
||||
'tif': 'TIFF',
|
||||
}
|
||||
|
||||
# Overhead constants for capacity estimation
|
||||
HEADER_OVERHEAD = 104 # Magic + version + date + salt + iv + tag
|
||||
LENGTH_PREFIX = 4 # 4 bytes for payload length
|
||||
ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX
|
||||
|
||||
|
||||
def get_output_format(input_format: Optional[str]) -> Tuple[str, str]:
|
||||
"""
|
||||
@@ -67,6 +72,106 @@ def get_output_format(input_format: Optional[str]) -> Tuple[str, str]:
|
||||
return 'PNG', 'png'
|
||||
|
||||
|
||||
def will_fit(
|
||||
payload: Union[str, bytes, FilePayload, int],
|
||||
carrier_image: bytes,
|
||||
bits_per_channel: int = 1,
|
||||
include_compression_estimate: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Check if a payload will fit in a carrier image without performing encryption.
|
||||
|
||||
This is a lightweight pre-check to avoid wasted work on payloads that
|
||||
are too large. For accurate results with compression, the actual compressed
|
||||
size may vary.
|
||||
|
||||
Args:
|
||||
payload: Message string, raw bytes, FilePayload, or size in bytes
|
||||
carrier_image: Carrier image bytes
|
||||
bits_per_channel: Bits to use per color channel (1-2)
|
||||
include_compression_estimate: Estimate compressed size (requires payload data)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- fits: bool - Whether payload will fit
|
||||
- payload_size: int - Raw payload size in bytes
|
||||
- estimated_encrypted_size: int - Estimated size after encryption + overhead
|
||||
- capacity: int - Available capacity in bytes
|
||||
- usage_percent: float - Estimated capacity usage (0-100)
|
||||
- headroom: int - Bytes remaining (negative if won't fit)
|
||||
- compressed_estimate: int | None - Estimated compressed size (if applicable)
|
||||
|
||||
Example:
|
||||
>>> result = will_fit("Hello world", carrier_bytes)
|
||||
>>> result['fits']
|
||||
True
|
||||
>>> result['usage_percent']
|
||||
0.5
|
||||
|
||||
>>> result = will_fit(50000, carrier_bytes) # Check if 50KB would fit
|
||||
>>> result['fits']
|
||||
False
|
||||
"""
|
||||
# Determine payload size
|
||||
if isinstance(payload, int):
|
||||
payload_size = payload
|
||||
payload_data = None
|
||||
elif isinstance(payload, str):
|
||||
payload_data = payload.encode('utf-8')
|
||||
payload_size = len(payload_data)
|
||||
elif isinstance(payload, FilePayload):
|
||||
payload_data = payload.data
|
||||
# Account for filename/mime metadata
|
||||
filename_overhead = len(payload.filename.encode('utf-8')) if payload.filename else 0
|
||||
mime_overhead = len(payload.mime_type.encode('utf-8')) if payload.mime_type else 0
|
||||
payload_size = len(payload.data) + filename_overhead + mime_overhead + 5 # +5 for length prefixes + type byte
|
||||
else:
|
||||
payload_data = payload
|
||||
payload_size = len(payload)
|
||||
|
||||
# Calculate capacity
|
||||
capacity = calculate_capacity(carrier_image, bits_per_channel)
|
||||
|
||||
# Estimate encrypted size (payload + random padding + overhead)
|
||||
# Padding adds 64-319 bytes, averaging ~190
|
||||
estimated_padding = 190
|
||||
estimated_encrypted_size = payload_size + estimated_padding + ENCRYPTION_OVERHEAD
|
||||
|
||||
# Compression estimate
|
||||
compressed_estimate = None
|
||||
if include_compression_estimate and payload_data is not None and len(payload_data) >= 64:
|
||||
try:
|
||||
import zlib
|
||||
compressed = zlib.compress(payload_data, level=6)
|
||||
# Add compression header overhead (9 bytes)
|
||||
compressed_size = len(compressed) + 9
|
||||
if compressed_size < payload_size:
|
||||
compressed_estimate = compressed_size
|
||||
# Use compressed size for fit calculation
|
||||
estimated_encrypted_size = compressed_size + estimated_padding + ENCRYPTION_OVERHEAD
|
||||
except Exception:
|
||||
pass # Ignore compression errors
|
||||
|
||||
headroom = capacity - estimated_encrypted_size
|
||||
fits = headroom >= 0
|
||||
usage_percent = (estimated_encrypted_size / capacity * 100) if capacity > 0 else 100.0
|
||||
|
||||
result = {
|
||||
'fits': fits,
|
||||
'payload_size': payload_size,
|
||||
'estimated_encrypted_size': estimated_encrypted_size,
|
||||
'capacity': capacity,
|
||||
'usage_percent': min(usage_percent, 100.0),
|
||||
'headroom': headroom,
|
||||
'compressed_estimate': compressed_estimate,
|
||||
}
|
||||
|
||||
debug.print(f"will_fit: payload={payload_size}, encrypted~={estimated_encrypted_size}, "
|
||||
f"capacity={capacity}, fits={fits}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@debug.time
|
||||
def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List[int]:
|
||||
"""
|
||||
@@ -172,6 +277,8 @@ def embed_in_image(
|
||||
Uses pseudo-random pixel selection based on pixel_key to scatter
|
||||
the data across the image, defeating statistical analysis.
|
||||
|
||||
Note: Output images have all metadata (EXIF, etc.) stripped automatically.
|
||||
|
||||
Args:
|
||||
carrier_data: Carrier image bytes
|
||||
encrypted_data: Data to embed
|
||||
@@ -274,7 +381,7 @@ def embed_in_image(
|
||||
|
||||
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
|
||||
|
||||
# Create output image
|
||||
# Create output image (fresh image = no metadata/EXIF carried over)
|
||||
stego_img = Image.new('RGB', img.size)
|
||||
stego_img.putdata(new_pixels)
|
||||
|
||||
@@ -447,7 +554,7 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int:
|
||||
max_bytes = (num_pixels * bits_per_pixel) // 8
|
||||
|
||||
# Subtract overhead: 4 bytes length + ~100 bytes header
|
||||
capacity = max(0, max_bytes - 104)
|
||||
capacity = max(0, max_bytes - ENCRYPTION_OVERHEAD)
|
||||
debug.print(f"Image capacity: {capacity} bytes at {bits_per_channel} bit(s)/channel")
|
||||
return capacity
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Stegasoo Utilities
|
||||
Secure deletion, filename generation, and other helpers.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
import secrets
|
||||
@@ -12,10 +13,50 @@ from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .constants import DAY_NAMES
|
||||
from .debug import debug
|
||||
|
||||
|
||||
def strip_image_metadata(image_data: bytes, output_format: str = 'PNG') -> bytes:
|
||||
"""
|
||||
Remove all metadata (EXIF, ICC profiles, etc.) from an image.
|
||||
|
||||
Creates a fresh image with only pixel data - no EXIF, GPS coordinates,
|
||||
camera info, timestamps, or other potentially sensitive metadata.
|
||||
|
||||
Args:
|
||||
image_data: Raw image bytes
|
||||
output_format: Output format ('PNG', 'BMP', 'TIFF')
|
||||
|
||||
Returns:
|
||||
Clean image bytes with no metadata
|
||||
|
||||
Example:
|
||||
>>> clean = strip_image_metadata(photo_bytes)
|
||||
>>> # EXIF data is now removed
|
||||
"""
|
||||
debug.print(f"Stripping metadata, output format: {output_format}")
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if needed (handles RGBA, P, L, etc.)
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Create fresh image - this discards all metadata
|
||||
clean = Image.new(img.mode, img.size)
|
||||
clean.putdata(list(img.getdata()))
|
||||
|
||||
output = io.BytesIO()
|
||||
clean.save(output, output_format.upper())
|
||||
output.seek(0)
|
||||
|
||||
debug.print(f"Metadata stripped: {len(image_data)} -> {len(output.getvalue())} bytes")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def generate_filename(
|
||||
date_str: Optional[str] = None,
|
||||
prefix: str = "",
|
||||
|
||||
Reference in New Issue
Block a user