Files
Agrici Daniel d973b6a7e8 fix(release): complete evidence and ecosystem hardening
Harden the candidate against stale tracker state, malformed provider
output, schema drift, unsupported platform claims, and supply-chain
gaps.

- add exact-head live issue and pull-request reconciliation to CI
- validate bounded complete PNG outputs and matching extensions
- version breaking control schemas and retain v1 compatibility
- refresh source and claim evidence with current primary sources
- isolate pip-audit behind a hash-locked dependency closure
- expand sensitive artifact and binary secret scanning

The change does not enable account mutations, merge the candidate, or
satisfy external model, signed-review, and remote-CI gates. Those
remain intentionally fail closed.

Verified:
- pytest: 558 passed, 24 skipped
- ruff, compileall, shell syntax, pip check, and diff checks: passed
- release audit: 352 files passed
- dependency audit: 16 not_affected, 0 unhandled
- live tracker audit: 21 issues, 35 public PRs, 13 canonical PRs
- CPython 3.12 Linux hash lock resolution: passed
- added diff scan: 1,375 lines, 0 em dashes or private paths
- remaining: canonical model evidence, signed review, and remote CI

Co-Authored-By: GPT-5 <noreply@openai.com>
2026-08-26 14:41:06 +03:00

1523 lines
57 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate ad creative images through an explicitly selected API adapter.
This local CLI is a fallback for environments whose approved image capability is
not exposed through a host-native tool. It has no default provider or model.
Select both from current capability evidence for every run.
Implemented adapters are Gemini, OpenAI, Stability AI, and Replicate. Adapter
availability does not imply operator approval, account access, model availability,
or fitness for a placement.
Usage:
python generate_image.py "prompt text" --provider "$ADS_IMAGE_PROVIDER" --model "$ADS_IMAGE_MODEL" --ratio 9:16 --output ad.png
python generate_image.py --batch prompts.json --provider "$ADS_IMAGE_PROVIDER" --model "$ADS_IMAGE_MODEL" --output-dir ./ad-assets/
Environment variables:
ADS_IMAGE_PROVIDER Required unless --provider is supplied
ADS_IMAGE_MODEL Required unless --model is supplied
GOOGLE_API_KEY Required for gemini provider
OPENAI_API_KEY Required for openai provider
STABILITY_API_KEY Required for stability provider
REPLICATE_API_TOKEN Required for replicate provider
See ads/references/image-providers.md for pricing and capability details.
"""
import argparse
import base64
from contextlib import contextmanager
import hashlib
import json
import os
import re
import secrets
import stat
import struct
import sys
import tempfile
import time
import zlib
from pathlib import Path
from typing import Any, Mapping
from urllib.parse import urlparse
from claude_ads_core.contracts import ContractError, load_contract, validate_contract
# Single source of truth for credential redaction (see scripts/url_utils.py).
# Re-exported under the local _sanitize_error name so existing call sites do
# not need to change.
from url_utils import artifact_locator, guarded_request, resolve_output_path, sanitize_error as _sanitize_error
# Aspect ratio shorthand → (width, height)
ASPECT_RATIOS = {
"1:1": (1080, 1080),
"9:16": (1080, 1920),
"16:9": (1920, 1080),
"4:5": (1080, 1350),
"4:3": (1200, 900),
"3:4": (900, 1200),
"1.91:1": (1200, 628), # Google PMax / LinkedIn landscape
"4:1": (1200, 300), # Google Logo landscape
"21:9": (2520, 1080), # Ultra-wide
}
# Gemini API ratio strings (closest supported ratio for each alias)
GEMINI_RATIO_MAP = {
"1:1": "1:1",
"9:16": "9:16",
"16:9": "16:9",
"4:5": "4:5",
"4:3": "4:3",
"3:4": "3:4",
"1.91:1": "16:9", # Closest Gemini supports; crop in post if needed
"4:1": "4:1",
"21:9": "21:9",
}
MAX_RETRIES = 4
RETRY_BACKOFF = [1, 2, 4, 8] # seconds
MAX_BATCH_SIZE = 50
MAX_DIMENSION = 8192
MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024
def _windows_acl_api():
"""Return Win32 ACL libraries with pointer-safe ctypes signatures."""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
void_pp = ctypes.POINTER(ctypes.c_void_p)
kernel32.GetCurrentProcess.argtypes = []
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
kernel32.LocalFree.restype = ctypes.c_void_p
advapi32.OpenProcessToken.argtypes = [
wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)
]
advapi32.OpenProcessToken.restype = wintypes.BOOL
advapi32.GetTokenInformation.argtypes = [
wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
]
advapi32.GetTokenInformation.restype = wintypes.BOOL
advapi32.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(wintypes.LPWSTR)]
advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL
advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
wintypes.LPCWSTR, wintypes.DWORD, void_pp, ctypes.POINTER(wintypes.DWORD)
]
advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL
advapi32.GetSecurityDescriptorDacl.argtypes = [
ctypes.c_void_p, ctypes.POINTER(wintypes.BOOL), void_pp,
ctypes.POINTER(wintypes.BOOL),
]
advapi32.GetSecurityDescriptorDacl.restype = wintypes.BOOL
advapi32.SetSecurityInfo.argtypes = [
wintypes.HANDLE, ctypes.c_int, wintypes.DWORD, ctypes.c_void_p,
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
]
advapi32.SetSecurityInfo.restype = wintypes.DWORD
advapi32.GetSecurityInfo.argtypes = [
wintypes.HANDLE, ctypes.c_int, wintypes.DWORD, void_pp, void_pp,
void_pp, void_pp, void_pp,
]
advapi32.GetSecurityInfo.restype = wintypes.DWORD
advapi32.GetSecurityDescriptorControl.argtypes = [
ctypes.c_void_p, ctypes.POINTER(wintypes.WORD), ctypes.POINTER(wintypes.DWORD)
]
advapi32.GetSecurityDescriptorControl.restype = wintypes.BOOL
advapi32.GetAclInformation.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, wintypes.DWORD, ctypes.c_int
]
advapi32.GetAclInformation.restype = wintypes.BOOL
advapi32.GetAce.argtypes = [ctypes.c_void_p, wintypes.DWORD, void_pp]
advapi32.GetAce.restype = wintypes.BOOL
kernel32.ReOpenFile.argtypes = [
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD
]
kernel32.ReOpenFile.restype = wintypes.HANDLE
return ctypes, wintypes, kernel32, advapi32
def _windows_current_user_sid() -> str:
ctypes, wintypes, kernel32, advapi32 = _windows_acl_api()
class _SID_AND_ATTRIBUTES(ctypes.Structure):
_fields_ = [("Sid", ctypes.c_void_p), ("Attributes", wintypes.DWORD)]
class _TOKEN_USER(ctypes.Structure):
_fields_ = [("User", _SID_AND_ATTRIBUTES)]
token = wintypes.HANDLE()
if not advapi32.OpenProcessToken(
kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)
):
raise ctypes.WinError(ctypes.get_last_error())
try:
required = wintypes.DWORD()
advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required))
if required.value == 0:
raise ctypes.WinError(ctypes.get_last_error())
buffer = ctypes.create_string_buffer(required.value)
if not advapi32.GetTokenInformation(
token, 1, buffer, required.value, ctypes.byref(required)
):
raise ctypes.WinError(ctypes.get_last_error())
sid = ctypes.cast(buffer, ctypes.POINTER(_TOKEN_USER)).contents.User.Sid
string_sid = wintypes.LPWSTR()
if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(string_sid)):
raise ctypes.WinError(ctypes.get_last_error())
try:
return string_sid.value
finally:
kernel32.LocalFree(ctypes.cast(string_sid, ctypes.c_void_p))
finally:
kernel32.CloseHandle(token)
def _windows_sid_string(sid: int) -> str:
ctypes, wintypes, kernel32, advapi32 = _windows_acl_api()
string_sid = wintypes.LPWSTR()
if not advapi32.ConvertSidToStringSidW(ctypes.c_void_p(sid), ctypes.byref(string_sid)):
raise ctypes.WinError(ctypes.get_last_error())
try:
return string_sid.value
finally:
kernel32.LocalFree(ctypes.cast(string_sid, ctypes.c_void_p))
def _windows_handle_has_owner_only_acl(handle: int) -> bool:
ctypes, wintypes, kernel32, advapi32 = _windows_acl_api()
class _ACL_SIZE_INFORMATION(ctypes.Structure):
_fields_ = [
("AceCount", wintypes.DWORD),
("AclBytesInUse", wintypes.DWORD),
("AclBytesFree", wintypes.DWORD),
]
descriptor = ctypes.c_void_p()
dacl = ctypes.c_void_p()
owner = ctypes.c_void_p()
result = advapi32.GetSecurityInfo(
wintypes.HANDLE(handle), 1, 0x00000005, ctypes.byref(owner), None,
ctypes.byref(dacl), None, ctypes.byref(descriptor),
)
if result:
raise ctypes.WinError(result)
try:
control = wintypes.WORD()
revision = wintypes.DWORD()
if not advapi32.GetSecurityDescriptorControl(
descriptor, ctypes.byref(control), ctypes.byref(revision)
) or not (control.value & 0x1000):
return False
info = _ACL_SIZE_INFORMATION()
if not advapi32.GetAclInformation(
dacl, ctypes.byref(info), ctypes.sizeof(info), 2
) or info.AceCount != 1:
return False
ace = ctypes.c_void_p()
if not advapi32.GetAce(dacl, 0, ctypes.byref(ace)):
return False
address = ace.value
ace_type = ctypes.c_ubyte.from_address(address).value
ace_flags = ctypes.c_ubyte.from_address(address + 1).value
mask = wintypes.DWORD.from_address(address + 4).value
trustee = _windows_sid_string(address + 8)
owner_sid = _windows_sid_string(owner.value)
current_sid = _windows_current_user_sid()
return (
ace_type == 0
and ace_flags == 0
and mask == 0x001F01FF
and trustee == current_sid
and owner_sid == current_sid
)
finally:
kernel32.LocalFree(descriptor)
def _windows_file_identity(handle: int) -> tuple[int, bytes]:
"""Return FileIdInfo identity, including the 128-bit ReFS-safe file ID."""
ctypes, wintypes, kernel32, _ = _windows_acl_api()
class _FILE_ID_128(ctypes.Structure):
_fields_ = [("Identifier", ctypes.c_ubyte * 16)]
class _FILE_ID_INFO(ctypes.Structure):
_fields_ = [("VolumeSerialNumber", ctypes.c_ulonglong), ("FileId", _FILE_ID_128)]
kernel32.GetFileInformationByHandleEx.argtypes = [
wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD
]
kernel32.GetFileInformationByHandleEx.restype = wintypes.BOOL
info = _FILE_ID_INFO()
if not kernel32.GetFileInformationByHandleEx(
wintypes.HANDLE(handle), 18, ctypes.byref(info), ctypes.sizeof(info)
):
raise ctypes.WinError(ctypes.get_last_error())
return info.VolumeSerialNumber, bytes(info.FileId.Identifier)
def _windows_open_path_guard(path: Path, anchor: int) -> int:
"""Open and verify the current path without delete sharing, then hold it."""
ctypes, wintypes, kernel32, _ = _windows_acl_api()
kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE,
]
kernel32.CreateFileW.restype = wintypes.HANDLE
guard = kernel32.CreateFileW(
str(path),
0x80020000, # GENERIC_READ|READ_CONTROL: participate in share enforcement.
0x00000001, # FILE_SHARE_READ only; deny write/truncate/delete/swap.
None,
3, # OPEN_EXISTING
0x00000080, # FILE_ATTRIBUTE_NORMAL
None,
)
invalid_handle = ctypes.c_void_p(-1).value
if guard == invalid_handle:
raise ctypes.WinError(ctypes.get_last_error())
try:
if _windows_file_identity(guard) != _windows_file_identity(anchor):
raise PermissionError("Private file path no longer identifies the created object")
if not _windows_handle_has_owner_only_acl(guard):
raise PermissionError("Private file path has an invalid owner or DACL")
return guard
except Exception:
kernel32.CloseHandle(guard)
raise
def _windows_delete_by_anchor(anchor: int) -> None:
"""Mark the exact anchored object for deletion without consulting its path."""
ctypes, wintypes, kernel32, _ = _windows_acl_api()
class _FILE_DISPOSITION_INFO(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # Win32 BOOLEAN, exactly 1 byte.
kernel32.SetFileInformationByHandle.argtypes = [
wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD
]
kernel32.SetFileInformationByHandle.restype = wintypes.BOOL
invalid_handle = ctypes.c_void_p(-1).value
deletion_handle = kernel32.ReOpenFile(
wintypes.HANDLE(anchor),
0x00010000, # DELETE
0x00000007,
0,
)
if deletion_handle == invalid_handle:
raise ctypes.WinError(ctypes.get_last_error())
try:
disposition = _FILE_DISPOSITION_INFO(True)
if not kernel32.SetFileInformationByHandle(
deletion_handle, 4, ctypes.byref(disposition), ctypes.sizeof(disposition)
):
raise ctypes.WinError(ctypes.get_last_error())
finally:
kernel32.CloseHandle(deletion_handle)
def _windows_create_private_temp(
directory: Path, prefix: str, suffix: str, *, temporary: bool
) -> tuple[int, Path, int]:
"""Atomically create a private temp file and return fd, path, anchored ACL handle."""
import msvcrt
ctypes, wintypes, kernel32, advapi32 = _windows_acl_api()
class _SECURITY_ATTRIBUTES(ctypes.Structure):
_fields_ = [
("nLength", wintypes.DWORD),
("lpSecurityDescriptor", ctypes.c_void_p),
("bInheritHandle", wintypes.BOOL),
]
kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
ctypes.POINTER(_SECURITY_ATTRIBUTES), wintypes.DWORD, wintypes.DWORD,
wintypes.HANDLE,
]
kernel32.CreateFileW.restype = wintypes.HANDLE
security_descriptor = ctypes.c_void_p()
current_sid = _windows_current_user_sid()
sddl = f"O:{current_sid}D:P(A;;FA;;;{current_sid})"
if not advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl, 1, ctypes.byref(security_descriptor), None
):
raise ctypes.WinError(ctypes.get_last_error())
file_handle = None
acl_handle = None
path = None
returned = False
try:
present = wintypes.BOOL()
defaulted = wintypes.BOOL()
dacl = ctypes.c_void_p()
if not advapi32.GetSecurityDescriptorDacl(
security_descriptor, ctypes.byref(present), ctypes.byref(dacl),
ctypes.byref(defaulted),
) or not present:
raise ctypes.WinError(ctypes.get_last_error())
attributes = _SECURITY_ATTRIBUTES(
ctypes.sizeof(_SECURITY_ATTRIBUTES), security_descriptor, False
)
invalid_handle = ctypes.c_void_p(-1).value
for _ in range(128):
candidate = directory / f"{prefix}{secrets.token_hex(16)}{suffix}"
file_handle = kernel32.CreateFileW(
str(candidate),
0xC0060000, # GENERIC_READ|GENERIC_WRITE|READ_CONTROL|WRITE_DAC
0x00000007, # FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_SHARE_DELETE
ctypes.byref(attributes),
1, # CREATE_NEW
0x00000100 if temporary else 0x00000080,
None,
)
if file_handle != invalid_handle:
path = candidate
break
error = ctypes.get_last_error()
if error not in {80, 183}: # ERROR_FILE_EXISTS / ERROR_ALREADY_EXISTS
raise ctypes.WinError(error)
if path is None or file_handle == invalid_handle:
raise FileExistsError("Could not allocate a unique private temporary file")
acl_handle = kernel32.ReOpenFile(
file_handle,
0x00060000, # READ_CONTROL|WRITE_DAC
0x00000007,
0,
)
if acl_handle == invalid_handle:
raise ctypes.WinError(ctypes.get_last_error())
result = advapi32.SetSecurityInfo(
acl_handle, 1, 0x80000004, None, None, dacl, None
)
if result:
raise ctypes.WinError(result)
if not _windows_handle_has_owner_only_acl(acl_handle):
raise PermissionError(f"Owner-only Windows ACL verification failed for {path.name}")
descriptor = msvcrt.open_osfhandle(
file_handle, os.O_RDWR | getattr(os, "O_BINARY", 0)
)
file_handle = None # The CRT descriptor now owns the original handle.
returned = True
return descriptor, path, acl_handle
finally:
if not returned:
cleanup_anchor = acl_handle if acl_handle not in {None, ctypes.c_void_p(-1).value} else file_handle
if cleanup_anchor not in {None, ctypes.c_void_p(-1).value}:
try:
_windows_delete_by_anchor(cleanup_anchor)
except OSError:
pass
if file_handle not in {None, ctypes.c_void_p(-1).value}:
kernel32.CloseHandle(file_handle)
if not returned and acl_handle not in {None, ctypes.c_void_p(-1).value}:
kernel32.CloseHandle(acl_handle)
kernel32.LocalFree(security_descriptor)
def _close_windows_handle(handle: int | None) -> None:
if handle is None:
return
_, wintypes, kernel32, _ = _windows_acl_api()
if not kernel32.CloseHandle(wintypes.HANDLE(handle)):
raise OSError("Could not close the Windows ACL anchor handle")
def _windows_path_has_owner_only_acl(path: Path) -> bool:
if os.name != "nt":
return False
ctypes, wintypes, kernel32, _ = _windows_acl_api()
kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE,
]
kernel32.CreateFileW.restype = wintypes.HANDLE
handle = kernel32.CreateFileW(
str(path),
0x00020000, # READ_CONTROL
0x00000007,
None,
3, # OPEN_EXISTING
0x00000080, # FILE_ATTRIBUTE_NORMAL
None,
)
invalid_handle = ctypes.c_void_p(-1).value
if handle == invalid_handle:
raise ctypes.WinError(ctypes.get_last_error())
try:
return _windows_handle_has_owner_only_acl(handle)
finally:
kernel32.CloseHandle(handle)
def _set_private_permissions(path: Path, descriptor: int | None = None) -> None:
"""Apply and verify owner-only permissions on the current platform."""
if os.name == "nt":
raise RuntimeError("Windows private files must use atomic CreateFileW creation")
elif descriptor is not None and hasattr(os, "fchmod"):
os.fchmod(descriptor, 0o600)
else:
os.chmod(path, 0o600)
def _path_has_private_permissions(path: Path) -> bool:
if os.name == "nt":
return _windows_path_has_owner_only_acl(path)
return stat.S_IMODE(path.stat().st_mode) == 0o600
MAX_REFERENCE_IMAGE_BYTES = 20 * 1024 * 1024
MAX_REFERENCE_DECODED_BYTES = 128 * 1024 * 1024
_ALLOWED_IMAGE_EXTENSIONS = {'.png'}
_WINDOWS_RESERVED_NAMES = {
"con", "prn", "aux", "nul",
*(f"com{number}" for number in range(1, 10)),
*(f"lpt{number}" for number in range(1, 10)),
"com¹", "com²", "com³", "lpt¹", "lpt²", "lpt³",
}
_PROMPT_SUMMARY = "[redacted: raw prompt is ephemeral and is not persisted]"
def _prompt_record(prompt: str) -> dict[str, str]:
"""Return the irreversible prompt identity allowed in shipped JSON."""
return {
"prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"prompt_summary": _PROMPT_SUMMARY,
}
def _reference_input_root(input_root: str | None) -> Path:
"""Return the explicit, canonical operator boundary for reference images."""
configured = input_root or os.environ.get("CLAUDE_ADS_INPUT_ROOT")
if not configured:
raise ValueError(
"Reference images require --input-root or CLAUDE_ADS_INPUT_ROOT"
)
candidate = Path(configured).expanduser()
try:
resolved = candidate.resolve(strict=True)
except OSError as exc:
raise ValueError("Reference-image input root does not exist") from exc
if not resolved.is_dir():
raise ValueError("Reference-image input root must be a directory")
return resolved
def _resolve_reference_source(reference: Any, input_root: Path) -> Path:
"""Resolve one untrusted batch reference beneath an explicit local root.
Batch documents are data, not authority to read arbitrary workstation files.
Reference paths are therefore portable relative paths, may not traverse or use
symlinks, and must resolve to a bounded regular image file.
"""
if not isinstance(reference, str) or not reference or "\x00" in reference:
raise ValueError("Reference image must be a non-empty relative path")
if reference != reference.strip():
raise ValueError("Reference image path may not have surrounding whitespace")
value = reference
if (
Path(value).is_absolute()
or value.startswith(("/", "\\"))
or re.match(r"^[A-Za-z]:", value)
or "\\" in value
):
raise ValueError("Reference image must be relative to the configured input root")
parts = Path(value).parts
if not parts or any(part in {"", ".", ".."} for part in parts):
raise ValueError("Reference image path traversal is forbidden")
if any(ord(character) < 32 or ord(character) == 127 for character in value):
raise ValueError("Reference image path contains a control character")
for part in parts:
if re.search(r'[<>:"|?*]', part):
raise ValueError("Reference image path is not portable")
if part.endswith((" ", ".")):
raise ValueError("Reference image path has a trailing space or dot")
if part.split(".", 1)[0].casefold() in _WINDOWS_RESERVED_NAMES:
raise ValueError("Reference image path uses a Windows-reserved name")
if Path(value).suffix.lower() not in _ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("Unsupported reference image format")
lexical = input_root.joinpath(*parts)
current = input_root
for part in parts:
current = current / part
if current.is_symlink():
raise ValueError("Symlinked reference images and parent paths are forbidden")
try:
resolved = lexical.resolve(strict=True)
resolved.relative_to(input_root)
except (OSError, ValueError) as exc:
raise ValueError("Reference image escapes the configured input root") from exc
if not resolved.is_file():
raise ValueError("Reference image must be a regular file")
return resolved, parts
def _assert_descriptor_beneath_root(
descriptor: int, input_root: Path, expected_path: Path
) -> None:
"""Bind the opened handle—not merely its pre-open pathname—to the input root."""
if os.name == "nt": # pragma: no cover - exercised by the native Windows matrix
import ctypes
import msvcrt
handle = msvcrt.get_osfhandle(descriptor)
buffer = ctypes.create_unicode_buffer(32768)
get_final_path = ctypes.WinDLL("kernel32", use_last_error=True).GetFinalPathNameByHandleW
get_final_path.argtypes = [
ctypes.c_void_p,
ctypes.c_wchar_p,
ctypes.c_uint32,
ctypes.c_uint32,
]
get_final_path.restype = ctypes.c_uint32
length = get_final_path(
ctypes.c_void_p(handle), buffer, len(buffer), 0
)
if not length or length >= len(buffer):
raise ValueError("Cannot verify the opened reference-image handle")
final_name = buffer.value
if final_name.startswith("\\\\?\\UNC\\"):
final_name = "\\\\" + final_name[8:]
elif final_name.startswith("\\\\?\\"):
final_name = final_name[4:]
final_path = Path(final_name).resolve(strict=True)
try:
final_path.relative_to(input_root)
except ValueError as exc:
raise ValueError("Opened reference image escapes the configured input root") from exc
return
proc_descriptor = Path(f"/proc/self/fd/{descriptor}")
if proc_descriptor.exists():
try:
proc_descriptor.resolve(strict=True).relative_to(input_root)
except (OSError, ValueError) as exc:
raise ValueError("Opened reference image escapes the configured input root") from exc
return
if sys.platform == "darwin": # pragma: no cover - exercised by native macOS CI
import fcntl
try:
raw_path = fcntl.fcntl(descriptor, 50, b"\x00" * 1024) # F_GETPATH
final_path = Path(raw_path.split(b"\x00", 1)[0].decode()).resolve(strict=True)
final_path.relative_to(input_root)
except (OSError, UnicodeDecodeError, ValueError) as exc:
raise ValueError("Opened reference image escapes the configured input root") from exc
return
raise ValueError("This platform cannot attest the opened reference-image path")
def _open_reference_beneath(
input_root: Path, parts: tuple[str, ...], resolved: Path
) -> int:
"""Open a relative file through anchored directory descriptors when supported."""
nofollow = getattr(os, "O_NOFOLLOW", 0)
cloexec = getattr(os, "O_CLOEXEC", 0)
nonblock = getattr(os, "O_NONBLOCK", 0)
directory = getattr(os, "O_DIRECTORY", 0)
if os.open in os.supports_dir_fd and directory and nofollow:
root_descriptor = os.open(input_root, os.O_RDONLY | directory | nofollow | cloexec)
current_descriptor = root_descriptor
opened_directories: list[int] = []
try:
for part in parts[:-1]:
next_descriptor = os.open(
part,
os.O_RDONLY | directory | nofollow | cloexec,
dir_fd=current_descriptor,
)
opened_directories.append(next_descriptor)
current_descriptor = next_descriptor
descriptor = os.open(
parts[-1],
os.O_RDONLY | nofollow | cloexec | nonblock,
dir_fd=current_descriptor,
)
try:
_assert_descriptor_beneath_root(descriptor, input_root, resolved)
except Exception:
os.close(descriptor)
raise
return descriptor
finally:
for opened in reversed(opened_directories):
os.close(opened)
os.close(root_descriptor)
descriptor = os.open(resolved, os.O_RDONLY | nofollow | cloexec | nonblock)
try:
_assert_descriptor_beneath_root(descriptor, input_root, resolved)
except Exception:
os.close(descriptor)
raise
return descriptor
def _validate_reference_payload(payload: bytes, suffix: str) -> None:
"""Validate a bounded, decodable PNG raster—not only a spoofable prefix."""
if suffix != ".png" or not _validate_png(payload):
raise ValueError("Reference image bytes do not form a valid supported PNG")
def _validate_png(payload: bytes) -> bool:
if not payload.startswith(b"\x89PNG\r\n\x1a\n"):
return False
position = 8
seen_ihdr = seen_idat = seen_iend = False
idat_finished = False
compressed = bytearray()
width = height = bit_depth = color_type = interlace = 0
while position + 12 <= len(payload):
length = struct.unpack(">I", payload[position:position + 4])[0]
chunk_type = payload[position + 4:position + 8]
if not re.fullmatch(rb"[A-Za-z]{4}", chunk_type) or not chunk_type[2:3].isupper():
return False
data_start = position + 8
data_end = data_start + length
crc_end = data_end + 4
if crc_end > len(payload):
return False
expected_crc = struct.unpack(">I", payload[data_end:crc_end])[0]
if zlib.crc32(chunk_type + payload[data_start:data_end]) & 0xFFFFFFFF != expected_crc:
return False
if not seen_ihdr:
if chunk_type != b"IHDR" or length != 13:
return False
width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(
">IIBBBBB", payload[data_start:data_end]
)
if not (1 <= width <= MAX_DIMENSION and 1 <= height <= MAX_DIMENSION):
return False
if (
bit_depth != 8
or color_type not in {0, 2, 4, 6}
or compression != 0
or filter_method != 0
or interlace != 0
):
return False
seen_ihdr = True
elif chunk_type == b"IHDR":
return False
if chunk_type == b"PLTE":
return False
if chunk_type == b"IDAT":
if idat_finished:
return False
seen_idat = True
compressed.extend(payload[data_start:data_end])
elif seen_idat and chunk_type != b"IEND":
idat_finished = True
if chunk_type not in {b"IHDR", b"PLTE", b"IDAT", b"IEND"} and chunk_type[:1].isupper():
return False
if chunk_type == b"IEND":
if length != 0 or not seen_idat:
return False
seen_iend = True
position = crc_end
break
position = crc_end
if not (seen_ihdr and seen_idat and seen_iend and position == len(payload)):
return False
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[color_type]
bits_per_pixel = channels * bit_depth
row_size = 1 + (width * bits_per_pixel + 7) // 8
expected_size = height * row_size
row_sizes = [row_size] * height
if expected_size > MAX_REFERENCE_DECODED_BYTES or not compressed:
return False
try:
decompressor = zlib.decompressobj()
decoded = decompressor.decompress(bytes(compressed), expected_size + 1)
except zlib.error:
return False
if (
len(decoded) != expected_size
or not decompressor.eof
or decompressor.unused_data
or decompressor.unconsumed_tail
):
return False
offset = 0
for row_size in row_sizes:
if offset >= len(decoded) or decoded[offset] > 4:
return False
offset += row_size
return offset == len(decoded)
@contextmanager
def _private_reference_snapshot(reference: Any, input_root: Path):
"""Yield an immutable private snapshot of a validated reference image."""
if reference is None:
yield None, None
return
source, parts = _resolve_reference_source(reference, input_root)
descriptor = _open_reference_beneath(input_root, parts, source)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise ValueError("Reference image must be a regular file")
if metadata.st_size > MAX_REFERENCE_IMAGE_BYTES:
raise ValueError("Reference image exceeds the 20 MiB limit")
with os.fdopen(descriptor, "rb", closefd=False) as stream:
payload = stream.read(MAX_REFERENCE_IMAGE_BYTES + 1)
if len(payload) > MAX_REFERENCE_IMAGE_BYTES:
raise ValueError("Reference image exceeds the 20 MiB limit")
_validate_reference_payload(payload, source.suffix.lower())
finally:
os.close(descriptor)
snapshot_acl_handle = None
snapshot_path_guard = None
if os.name == "nt":
snapshot_descriptor, snapshot, snapshot_acl_handle = _windows_create_private_temp(
Path(tempfile.gettempdir()),
".claude-ads-reference-",
source.suffix.lower(),
temporary=True,
)
else:
snapshot_descriptor, snapshot_name = tempfile.mkstemp(
prefix=".claude-ads-reference-", suffix=source.suffix.lower()
)
snapshot = Path(snapshot_name)
try:
if os.name != "nt":
try:
_set_private_permissions(snapshot, snapshot_descriptor)
except Exception:
os.close(snapshot_descriptor)
raise
with os.fdopen(snapshot_descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
if snapshot_acl_handle is not None:
snapshot_path_guard = _windows_open_path_guard(
snapshot, snapshot_acl_handle
)
yield str(snapshot), hashlib.sha256(payload).hexdigest()
finally:
if os.name == "nt":
try:
_close_windows_handle(snapshot_path_guard)
finally:
try:
if snapshot_acl_handle is not None:
_windows_delete_by_anchor(snapshot_acl_handle)
finally:
_close_windows_handle(snapshot_acl_handle)
else:
snapshot.unlink(missing_ok=True)
def _write_private(path: Path, data: bytes) -> None:
"""Atomically write a generated asset with owner-only permissions."""
path.parent.mkdir(parents=True, exist_ok=True)
acl_handle = None
path_guard = None
keep_output = False
if os.name == "nt":
descriptor, temporary, acl_handle = _windows_create_private_temp(
path.parent, f".{path.name}.", ".tmp", temporary=False
)
else:
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
temporary = Path(temporary_name)
try:
if os.name != "nt":
try:
_set_private_permissions(temporary, descriptor)
except Exception:
os.close(descriptor)
raise
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
if acl_handle is not None:
path_guard = _windows_open_path_guard(path, acl_handle)
else:
_set_private_permissions(path)
keep_output = True
finally:
if os.name == "nt":
try:
_close_windows_handle(path_guard)
finally:
try:
if acl_handle is not None and not keep_output:
_windows_delete_by_anchor(acl_handle)
finally:
_close_windows_handle(acl_handle)
else:
temporary.unlink(missing_ok=True)
def _actual_dimensions(image_bytes: bytes) -> tuple[int, int] | None:
"""Return dimensions only for a complete, bounded, supported PNG raster."""
if len(image_bytes) > MAX_GENERATED_IMAGE_BYTES or not _validate_png(image_bytes):
return None
return struct.unpack(">II", image_bytes[16:24])
def _require_generated_png(image_bytes: bytes) -> tuple[int, int]:
"""Fail closed unless provider output is a bounded, fully validated PNG."""
if not isinstance(image_bytes, bytes):
raise RuntimeError("Image provider returned a non-bytes payload")
if not image_bytes:
raise RuntimeError("Image provider returned an empty payload")
if len(image_bytes) > MAX_GENERATED_IMAGE_BYTES:
raise RuntimeError("Image provider output exceeds the 25 MiB limit")
dimensions = _actual_dimensions(image_bytes)
if dimensions is None:
raise RuntimeError("Image provider output is not a valid supported PNG image")
return dimensions
def _require_png_output_path(path: str | Path) -> None:
"""Keep the declared output extension consistent with the validated bytes."""
if Path(path).suffix.casefold() != ".png":
raise ValueError("Generated image output path must use the .png extension")
def _bounded_png_response(response: Any, operation: str) -> bytes:
"""Read one streamed provider response within a hard limit and always close it."""
try:
status_code = getattr(response, "status_code", None)
if not isinstance(status_code, int) or not 200 <= status_code < 300:
raise RuntimeError(f"{operation} failed with HTTP {status_code}")
headers = getattr(response, "headers", {})
content_type = str(headers.get("content-type", "")).split(";", 1)[0].strip().lower()
if content_type and content_type != "image/png":
raise RuntimeError(f"{operation} returned unsupported content type {content_type}")
content_length = headers.get("content-length")
if content_length is not None:
try:
declared_size = int(content_length)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"{operation} returned an invalid Content-Length") from exc
if declared_size < 0 or declared_size > MAX_GENERATED_IMAGE_BYTES:
raise RuntimeError(f"{operation} output exceeds the 25 MiB limit")
iterator = getattr(response, "iter_content", None)
if not callable(iterator):
raise RuntimeError(f"{operation} response is not stream-readable")
chunks: list[bytes] = []
total = 0
for chunk in iterator(chunk_size=64 * 1024):
if not chunk:
continue
if not isinstance(chunk, bytes):
raise RuntimeError(f"{operation} returned a non-bytes response chunk")
total += len(chunk)
if total > MAX_GENERATED_IMAGE_BYTES:
raise RuntimeError(f"{operation} output exceeds the 25 MiB limit")
chunks.append(chunk)
return b"".join(chunks)
finally:
close = getattr(response, "close", None)
if callable(close):
close()
def _get_api_key(provider: str) -> str:
"""Retrieve API key for the given provider from environment."""
key_map = {
"gemini": ("GOOGLE_API_KEY", "console.cloud.google.com/apis/credentials"),
"openai": ("OPENAI_API_KEY", "platform.openai.com/api-keys"),
"stability": ("STABILITY_API_KEY", "platform.stability.ai"),
"replicate": ("REPLICATE_API_TOKEN", "replicate.com/account/api-tokens"),
}
if provider not in key_map:
print(
f"Error: Unknown provider '{provider}'. Valid options: gemini, openai, stability, replicate",
file=sys.stderr,
)
sys.exit(1)
env_var, url = key_map[provider]
key = os.environ.get(env_var)
if not key:
print(
f"Error: {env_var} not set.\n"
f"To use the {provider} provider:\n"
f" export {env_var}=\"your-key\"\n"
f" Get a key at: {url}\n"
f"\nTo use a different approved capability, set both "
f"ADS_IMAGE_PROVIDER and ADS_IMAGE_MODEL explicitly.",
file=sys.stderr,
)
sys.exit(1)
return key
def _dims_from_ratio(ratio: str) -> tuple[int, int]:
"""Return (width, height) for a ratio string."""
if ratio in ASPECT_RATIOS:
return ASPECT_RATIOS[ratio]
# Try parsing WxH directly (e.g. "1200x628")
if "x" in ratio:
try:
w, h = int(ratio.lower().split("x")[0]), int(ratio.lower().split("x")[1])
if w < 1 or h < 1 or w > MAX_DIMENSION or h > MAX_DIMENSION:
print(f"Error: Dimensions must be 1-{MAX_DIMENSION}. Got {w}x{h}", file=sys.stderr)
sys.exit(1)
return w, h
except (ValueError, IndexError):
pass
print(f"Error: Unknown ratio '{ratio}'. Use one of: {', '.join(ASPECT_RATIOS.keys())} or WxH (e.g. 1200x628)", file=sys.stderr)
sys.exit(1)
def generate_gemini(prompt: str, width: int, height: int, api_key: str, model: str, reference_image_path: str | None = None) -> bytes:
"""Generate image using Gemini API (google-genai package).
Args:
reference_image_path: Optional path to a brand screenshot for style-guided
generation. When provided, the image is passed as a visual style reference
alongside the text prompt when the explicitly selected model supports it.
"""
try:
from google import genai
from google.genai import types
except ImportError:
print(
"Error: google-genai package required.\n"
"Install with: pip install google-genai>=1.16.0",
file=sys.stderr,
)
sys.exit(1)
# Determine closest Gemini aspect ratio
ratio_key = None
for k, (w, h) in ASPECT_RATIOS.items():
if w == width and h == height:
ratio_key = k
break
gemini_ratio = GEMINI_RATIO_MAP.get(ratio_key, "1:1") if ratio_key else "1:1"
client = genai.Client(api_key=api_key)
# Build contents, with optional brand reference image for style guidance
if reference_image_path and os.path.exists(reference_image_path):
if Path(reference_image_path).suffix.lower() not in _ALLOWED_IMAGE_EXTENSIONS:
raise ValueError(f"Unsupported reference image format: {Path(reference_image_path).suffix}")
with open(reference_image_path, 'rb') as f:
ref_bytes = f.read()
mime = {
".png": "image/png",
}[Path(reference_image_path).suffix.lower()]
ref_part = types.Part.from_bytes(data=ref_bytes, mime_type=mime)
contents = [
ref_part,
f"Generate an ad creative that matches the visual style, color palette, "
f"and aesthetic of the brand shown in the reference image. {prompt}"
]
else:
contents = prompt
for attempt in range(MAX_RETRIES):
try:
response = client.models.generate_content(
model=model,
contents=contents,
config=types.GenerateContentConfig(
response_modalities=["IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=gemini_ratio,
),
),
)
for part in response.candidates[0].content.parts:
if hasattr(part, "inline_data") and part.inline_data:
mime_type = getattr(part.inline_data, "mime_type", None)
if mime_type and mime_type != "image/png":
raise RuntimeError(
f"Gemini returned unsupported image content type {mime_type}"
)
return part.inline_data.data # already bytes in google-genai >= 1.16.0
raise RuntimeError("No image data in Gemini response")
except Exception as e:
err_str = _sanitize_error(e)
if "429" in err_str or "RESOURCE_EXHAUSTED" in err_str:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF[attempt]
print(f"Rate limit hit, retrying in {wait}s...", file=sys.stderr)
time.sleep(wait)
continue
raise
def generate_openai(prompt: str, width: int, height: int, api_key: str, model: str) -> bytes:
"""Generate image using OpenAI API."""
try:
from openai import OpenAI
except ImportError:
print(
"Error: openai package required.\n"
"Install with: pip install openai>=1.75.0",
file=sys.stderr,
)
sys.exit(1)
client = OpenAI(api_key=api_key)
# OpenAI gpt-image-1 supported sizes: 1024x1024, 1536x1024, 1024x1536
if width == height:
size = "1024x1024"
elif width > height:
size = "1536x1024"
else:
size = "1024x1536"
response = client.images.generate(
model=model,
prompt=prompt,
n=1,
size=size,
response_format="b64_json",
)
try:
return base64.b64decode(response.data[0].b64_json, validate=True)
except (ValueError, TypeError) as exc:
raise RuntimeError("OpenAI returned invalid base64 image data") from exc
def generate_stability(prompt: str, width: int, height: int, api_key: str, model: str) -> bytes:
"""Generate image using Stability AI API."""
try:
import requests
except ImportError:
print("Error: requests package required. pip install requests", file=sys.stderr)
sys.exit(1)
url = "https://api.stability.ai/v2beta/stable-image/generate/sd3"
headers = {
"authorization": f"Bearer {api_key}",
"accept": "image/*",
}
data = {
"prompt": prompt,
"model": model.split("/")[-1] if "/" in model else model,
"aspect_ratio": _nearest_stability_ratio(width, height),
"output_format": "png",
}
resp = guarded_request(
requests,
"POST",
url,
headers=headers,
files={"none": ""},
data=data,
stream=True,
timeout=120,
)
return _bounded_png_response(resp, "Stability API image generation")
def _nearest_stability_ratio(width: int, height: int) -> str:
"""Map dimensions to Stability AI's supported aspect ratio strings."""
ratio = width / height
stability_ratios = {
"1:1": 1.0,
"16:9": 16/9,
"9:16": 9/16,
"4:5": 4/5,
"5:4": 5/4,
"3:2": 3/2,
"2:3": 2/3,
}
return min(stability_ratios.keys(), key=lambda r: abs(stability_ratios[r] - ratio))
def generate_replicate(prompt: str, width: int, height: int, api_key: str, model: str) -> bytes:
"""Generate image using Replicate API."""
try:
import replicate
import requests
except ImportError:
print(
"Error: replicate package required.\n"
"Install with: pip install replicate>=1.0.4",
file=sys.stderr,
)
sys.exit(1)
client = replicate.Client(api_token=api_key)
output = client.run(
model,
input={
"prompt": prompt,
"width": width,
"height": height,
"output_format": "png",
},
)
# Output is a URL or list of URLs
url = output[0] if isinstance(output, list) else str(output)
if urlparse(url).scheme != "https":
raise RuntimeError(f"Replicate returned non-HTTPS URL: {url[:100]}")
# Defense-in-depth: Replicate is trusted but revalidate against the SSRF
# blocklist so an upstream compromise can't redirect us to a private IP.
try:
from url_utils import validate_url as _validate_url
_validate_url(url)
except ValueError as ve:
raise RuntimeError(f"Replicate URL failed SSRF validation: {ve}") from ve
# allow_redirects=False — the SSRF check above only validated the original
# URL. A redirect target could be a private IP; refuse to follow at all.
resp = guarded_request(requests, "GET", url, stream=True, timeout=120)
return _bounded_png_response(resp, "Replicate image download")
def generate_image(
prompt: str,
ratio: str,
provider: str,
model: str,
api_key: str,
reference_image_path: str | None = None,
) -> tuple[bytes, int, int]:
"""
Generate a single image. Returns (image_bytes, width, height).
"""
provider, model = _require_selection(provider, model)
width, height = _dims_from_ratio(ratio)
if provider == "gemini":
image_bytes = generate_gemini(
prompt, width, height, api_key, model, reference_image_path
)
elif provider == "openai":
if reference_image_path:
raise ValueError(
"The openai adapter does not declare reference-image support; "
"choose a verified compatible capability or omit the reference image"
)
image_bytes = generate_openai(prompt, width, height, api_key, model)
elif provider == "stability":
if reference_image_path:
raise ValueError(
"The stability adapter does not declare reference-image support; "
"choose a verified compatible capability or omit the reference image"
)
image_bytes = generate_stability(prompt, width, height, api_key, model)
elif provider == "replicate":
if reference_image_path:
raise ValueError(
"The replicate adapter does not declare reference-image support; "
"choose a verified compatible capability or omit the reference image"
)
image_bytes = generate_replicate(prompt, width, height, api_key, model)
else:
print(f"Error: Unknown provider '{provider}'", file=sys.stderr)
sys.exit(1)
# Validate the complete raster before it can become a trusted artifact.
# This also reports native dimensions after provider ratio remapping.
width, height = _require_generated_png(image_bytes)
return image_bytes, width, height
def _require_selection(provider: str | None, model: str | None) -> tuple[str, str]:
"""Return explicit provider/model identifiers or fail before credentials/network."""
selected_provider = (provider or "").strip().lower()
selected_model = (model or "").strip()
if not selected_provider:
raise ValueError(
"Image provider is required; use --provider or ADS_IMAGE_PROVIDER "
"after capability discovery"
)
if not selected_model:
raise ValueError(
"Image model is required; use --model or ADS_IMAGE_MODEL after "
"capability discovery"
)
return selected_provider, selected_model
def run_batch(
batch_file: str,
output_dir: str,
provider: str,
model: str,
api_key: str,
as_json: bool,
data_lifecycle: Mapping[str, Any],
input_root: str | None = None,
) -> None:
"""
Process a batch JSON file of generation jobs.
Batch file format:
[
{"prompt": "...", "ratio": "9:16", "output": "tiktok-ad.png"},
{"prompt": "...", "ratio": "1:1", "output": "meta-square.png"}
]
"""
provider, model = _require_selection(provider, model)
validate_contract("data-lifecycle", data_lifecycle)
with open(batch_file) as f:
jobs = json.load(f)
if not isinstance(jobs, list) or any(not isinstance(job, dict) for job in jobs):
raise ValueError("Batch file must contain a JSON array of job objects")
has_references = any(job.get("reference_image") is not None for job in jobs)
reference_root = _reference_input_root(input_root) if has_references else Path.cwd()
if len(jobs) > MAX_BATCH_SIZE:
print(f"Error: Batch file contains {len(jobs)} jobs, max is {MAX_BATCH_SIZE}", file=sys.stderr)
sys.exit(1)
output_dir_path = resolve_output_path(output_dir)
output_dir_path.mkdir(parents=True, exist_ok=True)
results = []
for i, job in enumerate(jobs):
prompt = job.get("prompt", "")
ratio = job.get("ratio", "1:1")
output_name = job.get("output", f"image_{i:03d}.png")
# Security: strip path components to prevent directory traversal
output_name = Path(output_name).name
output_path = resolve_output_path(output_dir_path / output_name)
reference_image = job.get("reference_image", None)
result = {
"index": i,
**_prompt_record(prompt),
"ratio": ratio,
"file_locator": artifact_locator(output_path),
"provider": provider,
"model": model,
"reference_image_sha256": None,
"data_lifecycle": dict(data_lifecycle),
"generation_success": False,
"error": None,
}
try:
print(f"[{i+1}/{len(jobs)}] Generating {output_name}...", file=sys.stderr)
_require_png_output_path(output_name)
with _private_reference_snapshot(reference_image, reference_root) as (
reference_snapshot,
reference_sha256,
):
image_bytes, width, height = generate_image(
prompt, ratio, provider, model, api_key, reference_snapshot
)
_write_private(output_path, image_bytes)
result["reference_image_sha256"] = reference_sha256
result["generation_success"] = True
result["width"] = width
result["height"] = height
print(f" ✓ Saved artifact {artifact_locator(output_path)} ({width}×{height})", file=sys.stderr)
except Exception as e:
result["error"] = "generation failed; inspect private ephemeral runtime logs"
print(f" ✗ Error: {_sanitize_error(e)}", file=sys.stderr)
results.append(result)
if as_json:
print(json.dumps(results, indent=2))
else:
passed = sum(1 for r in results if r["generation_success"])
print(f"\nBatch complete: {passed}/{len(results)} images generated")
for r in results:
status = "" if r["generation_success"] else ""
print(f" {status} {r['file_locator']}")
def main():
parser = argparse.ArgumentParser(
description="Generate ad creative images using a pluggable image generation API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python generate_image.py "approved creative prompt" --provider "$ADS_IMAGE_PROVIDER" --model "$ADS_IMAGE_MODEL" --ratio 16:9 --output ad.png
python generate_image.py --batch prompts.json --provider "$ADS_IMAGE_PROVIDER" --model "$ADS_IMAGE_MODEL" --output-dir ./ad-assets/
Supported ratios: 1:1 9:16 16:9 4:5 4:3 3:4 1.91:1 4:1 21:9
Or use --size WxH for exact dimensions (e.g. --size 1200x628)
Both provider and model are required. Discover an approved capability first, then
use --provider/--model or ADS_IMAGE_PROVIDER/ADS_IMAGE_MODEL.
""",
)
# Prompt or batch mode
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("prompt", nargs="?", help="Image generation prompt")
group.add_argument("--batch", "-b", metavar="FILE", help="Batch JSON file with multiple generation jobs")
# Output options
parser.add_argument("--output", "-o", metavar="FILE", help="Output file path (default: ad_[ratio].png)")
parser.add_argument("--output-dir", metavar="DIR", default=".", help="Output directory for batch mode")
parser.add_argument(
"--input-root",
metavar="DIR",
help="Root containing rights-cleared reference images. Batch references must "
"be relative paths beneath this root. CLAUDE_ADS_INPUT_ROOT is used "
"when this option is omitted; one of them is required for references.",
)
# Dimension options
dim_group = parser.add_mutually_exclusive_group()
dim_group.add_argument(
"--ratio", "-r",
default="1:1",
help="Aspect ratio shorthand (e.g. 9:16, 16:9, 4:5, 1:1). Default: 1:1",
)
dim_group.add_argument(
"--size", "-s",
metavar="WxH",
help="Exact dimensions (e.g. 1200x628). Overrides --ratio.",
)
# Provider / model
parser.add_argument(
"--provider", "-p",
default=None,
help="Required provider adapter ID. Overrides ADS_IMAGE_PROVIDER.",
)
parser.add_argument(
"--model", "-m",
default=None,
help="Required model ID from current capability evidence. Overrides ADS_IMAGE_MODEL.",
)
parser.add_argument(
"--reference-image", "-i",
metavar="FILE",
dest="reference_image",
help="Path to a rights-cleared brand reference image. Requires explicit "
"support from the selected provider/model adapter.",
)
# Output format
parser.add_argument("--json", "-j", action="store_true", help="Output result as JSON")
parser.add_argument(
"--data-lifecycle",
required=True,
help="Path to a valid data-lifecycle JSON contract for this generation run.",
)
args = parser.parse_args()
# Resolve the explicitly selected, capability-verified provider and model.
try:
provider, model = _require_selection(
args.provider or os.environ.get("ADS_IMAGE_PROVIDER"),
args.model or os.environ.get("ADS_IMAGE_MODEL"),
)
except ValueError as exc:
parser.error(str(exc))
api_key = _get_api_key(provider)
try:
data_lifecycle = load_contract("data-lifecycle", args.data_lifecycle)
except ContractError as exc:
print(f"Error: {_sanitize_error(exc)}", file=sys.stderr)
sys.exit(1)
# Batch mode
if args.batch:
run_batch(
args.batch,
args.output_dir,
provider,
model,
api_key,
args.json,
data_lifecycle,
args.input_root,
)
return
# Single image mode
ratio = args.size if args.size else args.ratio
output_path = args.output
if not output_path:
safe_ratio = ratio.replace(":", "-").replace(".", "_")
output_path = f"ad_{safe_ratio}.png"
try:
_require_png_output_path(output_path)
except ValueError as exc:
print(f"Error: {_sanitize_error(exc)}", file=sys.stderr)
sys.exit(1)
try:
if args.reference_image and not (
args.input_root or os.environ.get("CLAUDE_ADS_INPUT_ROOT")
):
raise ValueError(
"--reference-image requires --input-root or CLAUDE_ADS_INPUT_ROOT; "
"use a relative path beneath that root"
)
direct_root = _reference_input_root(args.input_root) if args.reference_image else Path.cwd()
with _private_reference_snapshot(args.reference_image, direct_root) as (
reference_snapshot,
reference_sha256,
):
image_bytes, width, height = generate_image(
args.prompt, ratio, provider, model, api_key, reference_snapshot
)
except Exception as e:
print(f"Error: {_sanitize_error(e)}", file=sys.stderr)
sys.exit(1)
try:
resolved_output = resolve_output_path(output_path, create_parent=True)
except ValueError as exc:
print(f"Error: {_sanitize_error(exc)}", file=sys.stderr)
sys.exit(1)
_write_private(resolved_output, image_bytes)
result = {
"success": True,
"file_locator": artifact_locator(resolved_output),
"provider": provider,
"model": model,
"width": width,
"height": height,
"ratio": ratio,
**_prompt_record(args.prompt),
"reference_image_sha256": reference_sha256,
"data_lifecycle": data_lifecycle,
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"✓ Generated {width}×{height} image → {artifact_locator(resolved_output)}")
if __name__ == "__main__":
main()