Add EXIF Editor, consolidate channel key resolution

EXIF Editor (Library → CLI → API → WebUI):
- src/stegasoo/utils.py: read_image_exif(), write_image_exif()
- CLI: stegasoo tools exif [--clear|--set Field=Value]
- API: /api/tools/exif, /api/tools/exif/update, /api/tools/exif/clear
- WebUI: EXIF Editor tab with inline editing, clear all, save/download

Architectural consolidation:
- Moved resolve_channel_key() to src/stegasoo/channel.py (was duplicated in 3 frontends)
- Added get_channel_response_info() for consistent API/WebUI responses
- Frontends now use thin wrappers that translate exceptions

DCT improvements:
- Added will_fit_by_mode() pre-check to WebUI encode (fail fast)
- Suggests LSB mode when DCT capacity exceeded

Dependencies:
- Added piexif>=1.1.0 for EXIF editing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-04 01:16:33 -05:00
parent 5df9b9dac8
commit 01f0173dd4
9 changed files with 807 additions and 97 deletions

View File

@@ -168,37 +168,25 @@ def resolve_channel_key_option(
"""
Resolve channel key from CLI options.
Wrapper around library's resolve_channel_key with Click exception handling.
Returns:
None: Use server-configured key (auto mode)
"": Public mode (no channel key)
str: Explicit channel key
"""
if no_channel:
return "" # Public mode
from stegasoo.channel import resolve_channel_key
if channel_file:
# Load from file
path = Path(channel_file)
if not path.exists():
raise click.ClickException(f"Channel key file not found: {channel_file}")
key = path.read_text().strip()
if not validate_channel_key(key):
raise click.ClickException(f"Invalid channel key format in file: {channel_file}")
return key
if channel:
if channel.lower() == "auto":
return None # Use server config
# Explicit key provided
if not validate_channel_key(channel):
raise click.ClickException(
"Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
"Generate a new key with: stegasoo channel generate"
)
return channel
# Default: use server-configured key (auto mode)
return None
try:
return resolve_channel_key(
value=channel,
file_path=channel_file,
no_channel=no_channel,
)
except FileNotFoundError as e:
raise click.ClickException(str(e))
except ValueError as e:
raise click.ClickException(str(e))
def format_channel_status_line(quiet: bool = False) -> str | None: