init
This commit is contained in:
BIN
.opencode/skills/ai-multimodal/scripts/.coverage
Normal file
BIN
.opencode/skills/ai-multimodal/scripts/.coverage
Normal file
Binary file not shown.
315
.opencode/skills/ai-multimodal/scripts/check_setup.py
Executable file
315
.opencode/skills/ai-multimodal/scripts/check_setup.py
Executable file
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate ai-multimodal skill setup and configuration.
|
||||
|
||||
Checks:
|
||||
- API key presence and format
|
||||
- Python dependencies
|
||||
- Centralized resolver availability
|
||||
- Directory structure
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Fix Windows cp1252 encoding: Unicode symbols (✓, ⚠, ✗) can't encode on Windows.
|
||||
# Reconfigure stdout to UTF-8 with replacement (Python 3.7+).
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, 'reconfigure'):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Color codes for terminal output
|
||||
GREEN = '\033[92m'
|
||||
YELLOW = '\033[93m'
|
||||
RED = '\033[91m'
|
||||
BLUE = '\033[94m'
|
||||
RESET = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
|
||||
def print_header(text):
|
||||
"""Print section header."""
|
||||
print(f"\n{BOLD}{BLUE}{'='*60}{RESET}")
|
||||
print(f"{BOLD}{BLUE}{text}{RESET}")
|
||||
print(f"{BOLD}{BLUE}{'='*60}{RESET}\n")
|
||||
|
||||
|
||||
def print_success(text):
|
||||
"""Print success message."""
|
||||
print(f"{GREEN}✓ {text}{RESET}")
|
||||
|
||||
|
||||
def print_warning(text):
|
||||
"""Print warning message."""
|
||||
print(f"{YELLOW}⚠ {text}{RESET}")
|
||||
|
||||
|
||||
def print_error(text):
|
||||
"""Print error message."""
|
||||
print(f"{RED}✗ {text}{RESET}")
|
||||
|
||||
|
||||
def print_info(text):
|
||||
"""Print info message."""
|
||||
print(f"{BLUE}ℹ {text}{RESET}")
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Check if required Python packages are installed."""
|
||||
print_header("Checking Python Dependencies")
|
||||
|
||||
dependencies = {
|
||||
'google.genai': 'google-genai',
|
||||
'dotenv': 'python-dotenv',
|
||||
'PIL': 'pillow'
|
||||
}
|
||||
|
||||
missing = []
|
||||
|
||||
for module_name, package_name in dependencies.items():
|
||||
try:
|
||||
__import__(module_name)
|
||||
print_success(f"{package_name} is installed")
|
||||
except ImportError:
|
||||
print_error(f"{package_name} is NOT installed")
|
||||
missing.append(package_name)
|
||||
|
||||
if missing:
|
||||
print_error("\nMissing dependencies detected!")
|
||||
print_info(f"Install with: pip install {' '.join(missing)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_centralized_resolver():
|
||||
"""Check if centralized resolver is available."""
|
||||
print_header("Checking Centralized Resolver")
|
||||
|
||||
claude_root = Path(__file__).parent.parent.parent.parent
|
||||
resolver_path = claude_root / 'scripts' / 'resolve_env.py'
|
||||
|
||||
if resolver_path.exists():
|
||||
print_success(f"Centralized resolver found: {resolver_path}")
|
||||
|
||||
# Try to import it
|
||||
sys.path.insert(0, str(resolver_path.parent))
|
||||
try:
|
||||
from resolve_env import resolve_env
|
||||
print_success("Centralized resolver can be imported")
|
||||
return True
|
||||
except ImportError as e:
|
||||
print_error(f"Centralized resolver exists but cannot be imported: {e}")
|
||||
return False
|
||||
else:
|
||||
print_warning(f"Centralized resolver not found: {resolver_path}")
|
||||
print_info("Skill will use fallback resolution logic")
|
||||
return True # Not critical, fallback works
|
||||
|
||||
|
||||
def find_api_key():
|
||||
"""Find and validate API key using centralized resolver."""
|
||||
print_header("Checking API Key Configuration")
|
||||
|
||||
# Try to use centralized resolver
|
||||
claude_root = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(claude_root / 'scripts'))
|
||||
try:
|
||||
from resolve_env import resolve_env
|
||||
|
||||
print_info("Using centralized resolver...")
|
||||
api_key = resolve_env('GEMINI_API_KEY', skill='ai-multimodal')
|
||||
|
||||
if api_key:
|
||||
print_success("API key found via centralized resolver")
|
||||
print_info(f"Key preview: {api_key[:20]}...{api_key[-4:]}")
|
||||
|
||||
# Show hierarchy
|
||||
print_info("\nTo see where the key was found, run:")
|
||||
print_info("python ~/.opencode/scripts/resolve_env.py GEMINI_API_KEY --skill ai-multimodal --verbose")
|
||||
|
||||
return api_key
|
||||
else:
|
||||
print_error("API key not found in any location")
|
||||
return None
|
||||
|
||||
except ImportError:
|
||||
print_warning("Centralized resolver not available, using fallback")
|
||||
|
||||
# Fallback: check environment
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if api_key:
|
||||
print_success("API key found in process.env")
|
||||
print_info(f"Key preview: {api_key[:20]}...{api_key[-4:]}")
|
||||
return api_key
|
||||
else:
|
||||
print_error("API key not found")
|
||||
return None
|
||||
|
||||
|
||||
def validate_api_key_format(api_key):
|
||||
"""Basic validation of API key format."""
|
||||
if not api_key:
|
||||
return False
|
||||
|
||||
# Google AI Studio keys typically start with 'AIza'
|
||||
if api_key.startswith('AIza'):
|
||||
print_success("API key format looks valid (Google AI Studio)")
|
||||
return True
|
||||
elif len(api_key) > 20:
|
||||
print_warning("API key format not recognized (may be Vertex AI or custom)")
|
||||
return True
|
||||
else:
|
||||
print_error("API key format looks invalid (too short)")
|
||||
return False
|
||||
|
||||
|
||||
def test_api_connection(api_key):
|
||||
"""Test API connection with a simple request."""
|
||||
print_header("Testing API Connection")
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
|
||||
print_info("Initializing Gemini client...")
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
print_info("Fetching available models...")
|
||||
# List models to verify API key works
|
||||
models = list(client.models.list())
|
||||
|
||||
print_success(f"API connection successful! Found {len(models)} available models")
|
||||
|
||||
# Show some available models
|
||||
print_info("\nSample available models:")
|
||||
for model in models[:5]:
|
||||
print(f" - {model.name}")
|
||||
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
print_error("google-genai package not installed")
|
||||
return False
|
||||
except Exception as e:
|
||||
print_error(f"API connection failed: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def check_directory_structure():
|
||||
"""Verify skill directory structure."""
|
||||
print_header("Checking Directory Structure")
|
||||
|
||||
script_dir = Path(__file__).parent
|
||||
skill_dir = script_dir.parent
|
||||
|
||||
required_files = [
|
||||
('SKILL.md', skill_dir / 'SKILL.md'),
|
||||
('.env.example', skill_dir / '.env.example'),
|
||||
('gemini_batch_process.py', script_dir / 'gemini_batch_process.py'),
|
||||
]
|
||||
|
||||
all_exist = True
|
||||
|
||||
for name, path in required_files:
|
||||
if path.exists():
|
||||
print_success(f"{name} exists")
|
||||
else:
|
||||
print_error(f"{name} NOT found at {path}")
|
||||
all_exist = False
|
||||
|
||||
return all_exist
|
||||
|
||||
|
||||
def provide_setup_instructions():
|
||||
"""Provide setup instructions if configuration is incomplete."""
|
||||
print_header("Setup Instructions")
|
||||
|
||||
print_info("To configure the ai-multimodal skill:")
|
||||
print("\n1. Get a Gemini API key:")
|
||||
print(" → Visit: https://aistudio.google.com/apikey")
|
||||
|
||||
print("\n2. Configure the API key (choose one method):")
|
||||
|
||||
print(f"\n Option A: User global config (recommended)")
|
||||
print(f" $ echo 'GEMINI_API_KEY=your-api-key-here' >> ~/.opencode/.env")
|
||||
|
||||
script_dir = Path(__file__).parent
|
||||
skill_dir = script_dir.parent
|
||||
|
||||
print(f"\n Option B: Skill-specific config")
|
||||
print(f" $ cd {skill_dir}")
|
||||
print(f" $ cp .env.example .env")
|
||||
print(f" $ # Edit .env and add your API key")
|
||||
|
||||
print(f"\n Option C: Runtime environment (temporary)")
|
||||
print(f" $ export GEMINI_API_KEY='your-api-key-here'")
|
||||
|
||||
print("\n3. Verify setup:")
|
||||
print(f" $ python {Path(__file__)}")
|
||||
|
||||
print("\n4. Debug if needed:")
|
||||
print(f" $ python ~/.opencode/scripts/resolve_env.py --show-hierarchy --skill ai-multimodal")
|
||||
print(f" $ python ~/.opencode/scripts/resolve_env.py GEMINI_API_KEY --skill ai-multimodal --verbose")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all setup checks."""
|
||||
print(f"\n{BOLD}AI Multimodal Skill - Setup Checker{RESET}")
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Check directory structure
|
||||
if not check_directory_structure():
|
||||
all_passed = False
|
||||
|
||||
# Check centralized resolver
|
||||
check_centralized_resolver()
|
||||
|
||||
# Check dependencies
|
||||
if not check_dependencies():
|
||||
all_passed = False
|
||||
provide_setup_instructions()
|
||||
sys.exit(1)
|
||||
|
||||
# Check API key
|
||||
api_key = find_api_key()
|
||||
|
||||
if not api_key:
|
||||
print_error("\n❌ GEMINI_API_KEY not found in any location")
|
||||
all_passed = False
|
||||
provide_setup_instructions()
|
||||
sys.exit(1)
|
||||
|
||||
# Validate API key format
|
||||
if not validate_api_key_format(api_key):
|
||||
all_passed = False
|
||||
|
||||
# Test API connection
|
||||
if not test_api_connection(api_key):
|
||||
all_passed = False
|
||||
|
||||
# Final summary
|
||||
print_header("Setup Summary")
|
||||
|
||||
if all_passed:
|
||||
print_success("✅ All checks passed! The ai-multimodal skill is ready to use.")
|
||||
print_info("\nNext steps:")
|
||||
print(" • Read SKILL.md for usage examples")
|
||||
print(" • Try: python scripts/gemini_batch_process.py --help")
|
||||
print("\nImage generation models:")
|
||||
print(" • gemini-2.5-flash-image - Nano Banana Flash (DEFAULT - fast)")
|
||||
print(" • imagen-4.0-generate-001 - Imagen 4 (alternative - production)")
|
||||
print(" • gemini-3-pro-image-preview - Nano Banana Pro (4K text, reasoning)")
|
||||
print("\nExample (uses default model):")
|
||||
print(" python scripts/gemini_batch_process.py --task generate \\")
|
||||
print(" --prompt 'A sunset over mountains' --aspect-ratio 16:9 --size 2K")
|
||||
else:
|
||||
print_error("❌ Some checks failed. Please fix the issues above.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
395
.opencode/skills/ai-multimodal/scripts/document_converter.py
Executable file
395
.opencode/skills/ai-multimodal/scripts/document_converter.py
Executable file
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert documents to Markdown using Gemini API.
|
||||
|
||||
Supports all document types:
|
||||
- PDF documents (native vision processing)
|
||||
- Images (JPEG, PNG, WEBP, HEIC)
|
||||
- Office documents (DOCX, XLSX, PPTX)
|
||||
- HTML, TXT, and other text formats
|
||||
|
||||
Features:
|
||||
- Converts to clean markdown format
|
||||
- Preserves structure, tables, and formatting
|
||||
- Extracts text from images and scanned documents
|
||||
- Batch conversion support
|
||||
- Saves to docs/assets/document-extraction.md by default
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
except ImportError:
|
||||
print("Error: google-genai package not installed")
|
||||
print("Install with: pip install google-genai")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError:
|
||||
load_dotenv = None
|
||||
|
||||
|
||||
def find_api_key() -> Optional[str]:
|
||||
"""Find Gemini API key using correct priority order.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. process.env (runtime environment variables)
|
||||
2. .opencode/skills/ai-multimodal/.env (skill-specific config)
|
||||
3. .opencode/skills/.env (shared skills config)
|
||||
4. .opencode/.env (Claude global config)
|
||||
"""
|
||||
# Priority 1: Already in process.env (highest)
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Load .env files if dotenv available
|
||||
if load_dotenv:
|
||||
# Determine base paths
|
||||
script_dir = Path(__file__).parent
|
||||
skill_dir = script_dir.parent # .opencode/skills/ai-multimodal
|
||||
skills_dir = skill_dir.parent # .opencode/skills
|
||||
claude_dir = skills_dir.parent # .claude
|
||||
|
||||
# Priority 2: Skill-specific .env
|
||||
env_file = skill_dir / '.env'
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Priority 3: Shared skills .env
|
||||
env_file = skills_dir / '.env'
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Priority 4: Claude global .env
|
||||
env_file = claude_dir / '.env'
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_project_root() -> Path:
|
||||
"""Find project root directory."""
|
||||
script_dir = Path(__file__).parent
|
||||
|
||||
# Look for .git or .claude directory
|
||||
for parent in [script_dir] + list(script_dir.parents):
|
||||
if (parent / '.git').exists() or (parent / '.claude').exists():
|
||||
return parent
|
||||
|
||||
return script_dir
|
||||
|
||||
|
||||
def get_mime_type(file_path: str) -> str:
|
||||
"""Determine MIME type from file extension."""
|
||||
ext = Path(file_path).suffix.lower()
|
||||
|
||||
mime_types = {
|
||||
# Documents
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.md': 'text/markdown',
|
||||
'.csv': 'text/csv',
|
||||
# Images
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
# Office (need to be uploaded as binary)
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
}
|
||||
|
||||
return mime_types.get(ext, 'application/octet-stream')
|
||||
|
||||
|
||||
def upload_file(client: genai.Client, file_path: str, verbose: bool = False) -> Any:
|
||||
"""Upload file to Gemini File API."""
|
||||
if verbose:
|
||||
print(f"Uploading {file_path}...")
|
||||
|
||||
myfile = client.files.upload(file=file_path)
|
||||
|
||||
# Wait for processing if needed
|
||||
max_wait = 300 # 5 minutes
|
||||
elapsed = 0
|
||||
while myfile.state.name == 'PROCESSING' and elapsed < max_wait:
|
||||
time.sleep(2)
|
||||
myfile = client.files.get(name=myfile.name)
|
||||
elapsed += 2
|
||||
if verbose and elapsed % 10 == 0:
|
||||
print(f" Processing... {elapsed}s")
|
||||
|
||||
if myfile.state.name == 'FAILED':
|
||||
raise ValueError(f"File processing failed: {file_path}")
|
||||
|
||||
if myfile.state.name == 'PROCESSING':
|
||||
raise TimeoutError(f"Processing timeout after {max_wait}s: {file_path}")
|
||||
|
||||
if verbose:
|
||||
print(f" Uploaded: {myfile.name}")
|
||||
|
||||
return myfile
|
||||
|
||||
|
||||
def convert_to_markdown(
|
||||
client: genai.Client,
|
||||
file_path: str,
|
||||
model: str = 'gemini-2.5-flash',
|
||||
custom_prompt: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
max_retries: int = 3
|
||||
) -> Dict[str, Any]:
|
||||
"""Convert a document to markdown using Gemini."""
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
file_path_obj = Path(file_path)
|
||||
file_size = file_path_obj.stat().st_size
|
||||
use_file_api = file_size > 20 * 1024 * 1024 # >20MB
|
||||
|
||||
# Default prompt for markdown conversion
|
||||
if custom_prompt:
|
||||
prompt = custom_prompt
|
||||
else:
|
||||
prompt = """Convert this document to clean, well-formatted Markdown.
|
||||
|
||||
Requirements:
|
||||
- Preserve all content, structure, and formatting
|
||||
- Convert tables to markdown table format
|
||||
- Maintain heading hierarchy (# ## ### etc)
|
||||
- Preserve lists, code blocks, and quotes
|
||||
- Extract text from images if present
|
||||
- Keep formatting consistent and readable
|
||||
|
||||
Output only the markdown content without any preamble or explanation."""
|
||||
|
||||
# Upload or inline the file
|
||||
if use_file_api:
|
||||
myfile = upload_file(client, str(file_path), verbose)
|
||||
content = [prompt, myfile]
|
||||
else:
|
||||
with open(file_path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
mime_type = get_mime_type(str(file_path))
|
||||
content = [
|
||||
prompt,
|
||||
types.Part.from_bytes(data=file_bytes, mime_type=mime_type)
|
||||
]
|
||||
|
||||
# Generate markdown
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=content
|
||||
)
|
||||
|
||||
markdown_content = response.text if hasattr(response, 'text') else ''
|
||||
|
||||
return {
|
||||
'file': str(file_path),
|
||||
'status': 'success',
|
||||
'markdown': markdown_content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if attempt == max_retries - 1:
|
||||
return {
|
||||
'file': str(file_path),
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'markdown': None
|
||||
}
|
||||
|
||||
wait_time = 2 ** attempt
|
||||
if verbose:
|
||||
print(f" Retry {attempt + 1} after {wait_time}s: {e}")
|
||||
time.sleep(wait_time)
|
||||
|
||||
|
||||
def batch_convert(
|
||||
files: List[str],
|
||||
output_file: Optional[str] = None,
|
||||
auto_name: bool = False,
|
||||
model: str = 'gemini-2.5-flash',
|
||||
custom_prompt: Optional[str] = None,
|
||||
verbose: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Batch convert multiple files to markdown."""
|
||||
|
||||
api_key = find_api_key()
|
||||
if not api_key:
|
||||
print("Error: GEMINI_API_KEY not found")
|
||||
print("Set via: export GEMINI_API_KEY='your-key'")
|
||||
print("Or create .env file with: GEMINI_API_KEY=your-key")
|
||||
sys.exit(1)
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
results = []
|
||||
|
||||
# Determine output path
|
||||
if not output_file:
|
||||
project_root = find_project_root()
|
||||
output_dir = project_root / 'docs' / 'assets'
|
||||
|
||||
if auto_name and len(files) == 1:
|
||||
# Auto-generate meaningful filename from input
|
||||
input_path = Path(files[0])
|
||||
base_name = input_path.stem
|
||||
output_file = str(output_dir / f"{base_name}-extraction.md")
|
||||
else:
|
||||
output_file = str(output_dir / 'document-extraction.md')
|
||||
|
||||
output_path = Path(output_file)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Process each file
|
||||
for i, file_path in enumerate(files, 1):
|
||||
if verbose:
|
||||
print(f"\n[{i}/{len(files)}] Converting: {file_path}")
|
||||
|
||||
result = convert_to_markdown(
|
||||
client=client,
|
||||
file_path=file_path,
|
||||
model=model,
|
||||
custom_prompt=custom_prompt,
|
||||
verbose=verbose
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
|
||||
if verbose:
|
||||
status = result.get('status', 'unknown')
|
||||
print(f" Status: {status}")
|
||||
|
||||
# Save combined markdown
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write("# Document Extraction Results\n\n")
|
||||
f.write(f"Converted {len(files)} document(s) to markdown.\n\n")
|
||||
f.write("---\n\n")
|
||||
|
||||
for result in results:
|
||||
f.write(f"## {Path(result['file']).name}\n\n")
|
||||
|
||||
if result['status'] == 'success' and result.get('markdown'):
|
||||
f.write(result['markdown'])
|
||||
f.write("\n\n")
|
||||
elif result['status'] == 'success':
|
||||
f.write("**Note**: Conversion succeeded but no content was returned.\n\n")
|
||||
else:
|
||||
f.write(f"**Error**: {result.get('error', 'Unknown error')}\n\n")
|
||||
|
||||
f.write("---\n\n")
|
||||
|
||||
if verbose or True: # Always show output location
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Converted: {len(results)} file(s)")
|
||||
print(f"Success: {sum(1 for r in results if r['status'] == 'success')}")
|
||||
print(f"Failed: {sum(1 for r in results if r['status'] == 'error')}")
|
||||
print(f"Output saved to: {output_path}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Convert documents to Markdown using Gemini API',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Convert single PDF to markdown (default name)
|
||||
%(prog)s --input document.pdf
|
||||
|
||||
# Auto-generate meaningful filename
|
||||
%(prog)s --input testpdf.pdf --auto-name
|
||||
# Output: docs/assets/testpdf-extraction.md
|
||||
|
||||
# Convert multiple files
|
||||
%(prog)s --input doc1.pdf doc2.docx image.png
|
||||
|
||||
# Specify custom output location
|
||||
%(prog)s --input document.pdf --output ./output.md
|
||||
|
||||
# Use custom prompt
|
||||
%(prog)s --input document.pdf --prompt "Extract only the tables as markdown"
|
||||
|
||||
# Batch convert directory
|
||||
%(prog)s --input ./documents/*.pdf --verbose
|
||||
|
||||
Supported formats:
|
||||
- PDF documents (up to 1,000 pages)
|
||||
- Images (JPEG, PNG, WEBP, HEIC)
|
||||
- Office documents (DOCX, XLSX, PPTX)
|
||||
- Text formats (TXT, HTML, Markdown, CSV)
|
||||
|
||||
Default output: <project-root>/docs/assets/document-extraction.md
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--input', '-i', nargs='+', required=True,
|
||||
help='Input file(s) to convert')
|
||||
parser.add_argument('--output', '-o',
|
||||
help='Output markdown file (default: docs/assets/document-extraction.md)')
|
||||
parser.add_argument('--auto-name', '-a', action='store_true',
|
||||
help='Auto-generate meaningful output filename from input (e.g., document.pdf -> document-extraction.md)')
|
||||
parser.add_argument('--model', default='gemini-2.5-flash',
|
||||
help='Gemini model to use (default: gemini-2.5-flash)')
|
||||
parser.add_argument('--prompt', '-p',
|
||||
help='Custom prompt for conversion')
|
||||
parser.add_argument('--verbose', '-v', action='store_true',
|
||||
help='Verbose output')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate input files
|
||||
files = []
|
||||
for file_pattern in args.input:
|
||||
file_path = Path(file_pattern)
|
||||
if file_path.exists() and file_path.is_file():
|
||||
files.append(str(file_path))
|
||||
else:
|
||||
# Try glob pattern
|
||||
import glob
|
||||
matched = glob.glob(file_pattern)
|
||||
files.extend([f for f in matched if Path(f).is_file()])
|
||||
|
||||
if not files:
|
||||
print("Error: No valid input files found")
|
||||
sys.exit(1)
|
||||
|
||||
# Convert files
|
||||
batch_convert(
|
||||
files=files,
|
||||
output_file=args.output,
|
||||
auto_name=args.auto_name,
|
||||
model=args.model,
|
||||
custom_prompt=args.prompt,
|
||||
verbose=args.verbose
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1211
.opencode/skills/ai-multimodal/scripts/gemini_batch_process.py
Executable file
1211
.opencode/skills/ai-multimodal/scripts/gemini_batch_process.py
Executable file
File diff suppressed because it is too large
Load Diff
506
.opencode/skills/ai-multimodal/scripts/media_optimizer.py
Executable file
506
.opencode/skills/ai-multimodal/scripts/media_optimizer.py
Executable file
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Optimize media files for Gemini API processing.
|
||||
|
||||
Features:
|
||||
- Compress videos/audio for size limits
|
||||
- Resize images appropriately
|
||||
- Split long videos into chunks
|
||||
- Format conversion
|
||||
- Quality vs size optimization
|
||||
- Validation before upload
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError:
|
||||
load_dotenv = None
|
||||
|
||||
|
||||
def load_env_files():
|
||||
"""Load .env files in correct priority order.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. process.env (runtime environment variables)
|
||||
2. .opencode/skills/ai-multimodal/.env (skill-specific config)
|
||||
3. .opencode/skills/.env (shared skills config)
|
||||
4. .opencode/.env (Claude global config)
|
||||
"""
|
||||
if not load_dotenv:
|
||||
return
|
||||
|
||||
# Determine base paths
|
||||
script_dir = Path(__file__).parent
|
||||
skill_dir = script_dir.parent # .opencode/skills/ai-multimodal
|
||||
skills_dir = skill_dir.parent # .opencode/skills
|
||||
claude_dir = skills_dir.parent # .claude
|
||||
|
||||
# Priority 2: Skill-specific .env
|
||||
env_file = skill_dir / '.env'
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
|
||||
# Priority 3: Shared skills .env
|
||||
env_file = skills_dir / '.env'
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
|
||||
# Priority 4: Claude global .env
|
||||
env_file = claude_dir / '.env'
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
|
||||
|
||||
# Load environment variables at module level
|
||||
load_env_files()
|
||||
|
||||
|
||||
def check_ffmpeg() -> bool:
|
||||
"""Check if ffmpeg is installed."""
|
||||
try:
|
||||
subprocess.run(['ffmpeg', '-version'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
def get_media_info(file_path: str) -> Dict[str, Any]:
|
||||
"""Get media file information using ffprobe."""
|
||||
if not check_ffmpeg():
|
||||
return {}
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
'ffprobe',
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
file_path
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
info = {
|
||||
'size': int(data['format'].get('size', 0)),
|
||||
'duration': float(data['format'].get('duration', 0)),
|
||||
'bit_rate': int(data['format'].get('bit_rate', 0)),
|
||||
}
|
||||
|
||||
# Get video/audio specific info
|
||||
for stream in data.get('streams', []):
|
||||
if stream['codec_type'] == 'video':
|
||||
info['width'] = stream.get('width', 0)
|
||||
info['height'] = stream.get('height', 0)
|
||||
info['fps'] = eval(stream.get('r_frame_rate', '0/1'))
|
||||
elif stream['codec_type'] == 'audio':
|
||||
info['sample_rate'] = int(stream.get('sample_rate', 0))
|
||||
info['channels'] = stream.get('channels', 0)
|
||||
|
||||
return info
|
||||
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, Exception):
|
||||
return {}
|
||||
|
||||
|
||||
def optimize_video(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
target_size_mb: Optional[int] = None,
|
||||
max_duration: Optional[int] = None,
|
||||
quality: int = 23,
|
||||
resolution: Optional[str] = None,
|
||||
verbose: bool = False
|
||||
) -> bool:
|
||||
"""Optimize video file for Gemini API."""
|
||||
if not check_ffmpeg():
|
||||
print("Error: ffmpeg not installed")
|
||||
print("Install: apt-get install ffmpeg (Linux) or brew install ffmpeg (Mac)")
|
||||
return False
|
||||
|
||||
info = get_media_info(input_path)
|
||||
if not info:
|
||||
print(f"Error: Could not read media info from {input_path}")
|
||||
return False
|
||||
|
||||
if verbose:
|
||||
print(f"Input: {Path(input_path).name}")
|
||||
print(f" Size: {info['size'] / (1024*1024):.2f} MB")
|
||||
print(f" Duration: {info['duration']:.2f}s")
|
||||
if 'width' in info:
|
||||
print(f" Resolution: {info['width']}x{info['height']}")
|
||||
print(f" Bit rate: {info['bit_rate'] / 1000:.0f} kbps")
|
||||
|
||||
# Build ffmpeg command
|
||||
cmd = ['ffmpeg', '-i', input_path, '-y']
|
||||
|
||||
# Video codec
|
||||
cmd.extend(['-c:v', 'libx264', '-crf', str(quality)])
|
||||
|
||||
# Resolution
|
||||
if resolution:
|
||||
cmd.extend(['-vf', f'scale={resolution}'])
|
||||
elif 'width' in info and info['width'] > 1920:
|
||||
cmd.extend(['-vf', 'scale=1920:-2']) # Max 1080p
|
||||
|
||||
# Audio codec
|
||||
cmd.extend(['-c:a', 'aac', '-b:a', '128k', '-ac', '2'])
|
||||
|
||||
# Duration limit
|
||||
if max_duration and info['duration'] > max_duration:
|
||||
cmd.extend(['-t', str(max_duration)])
|
||||
|
||||
# Target size (rough estimate using bitrate)
|
||||
if target_size_mb:
|
||||
target_bits = target_size_mb * 8 * 1024 * 1024
|
||||
duration = min(info['duration'], max_duration) if max_duration else info['duration']
|
||||
target_bitrate = int(target_bits / duration)
|
||||
# Reserve some for audio (128kbps)
|
||||
video_bitrate = max(target_bitrate - 128000, 500000)
|
||||
cmd.extend(['-b:v', str(video_bitrate)])
|
||||
|
||||
cmd.append(output_path)
|
||||
|
||||
if verbose:
|
||||
print(f"\nOptimizing...")
|
||||
print(f" Command: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=not verbose)
|
||||
|
||||
# Check output
|
||||
output_info = get_media_info(output_path)
|
||||
if output_info and verbose:
|
||||
print(f"\nOutput: {Path(output_path).name}")
|
||||
print(f" Size: {output_info['size'] / (1024*1024):.2f} MB")
|
||||
print(f" Duration: {output_info['duration']:.2f}s")
|
||||
if 'width' in output_info:
|
||||
print(f" Resolution: {output_info['width']}x{output_info['height']}")
|
||||
compression = (1 - output_info['size'] / info['size']) * 100
|
||||
print(f" Compression: {compression:.1f}%")
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error optimizing video: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def optimize_audio(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
target_size_mb: Optional[int] = None,
|
||||
bitrate: str = '64k',
|
||||
sample_rate: int = 16000,
|
||||
verbose: bool = False
|
||||
) -> bool:
|
||||
"""Optimize audio file for Gemini API."""
|
||||
if not check_ffmpeg():
|
||||
print("Error: ffmpeg not installed")
|
||||
return False
|
||||
|
||||
info = get_media_info(input_path)
|
||||
if not info:
|
||||
print(f"Error: Could not read media info from {input_path}")
|
||||
return False
|
||||
|
||||
if verbose:
|
||||
print(f"Input: {Path(input_path).name}")
|
||||
print(f" Size: {info['size'] / (1024*1024):.2f} MB")
|
||||
print(f" Duration: {info['duration']:.2f}s")
|
||||
|
||||
# Build command
|
||||
cmd = [
|
||||
'ffmpeg', '-i', input_path, '-y',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', bitrate,
|
||||
'-ar', str(sample_rate),
|
||||
'-ac', '1', # Mono (Gemini uses mono anyway)
|
||||
output_path
|
||||
]
|
||||
|
||||
if verbose:
|
||||
print(f"\nOptimizing...")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=not verbose)
|
||||
|
||||
output_info = get_media_info(output_path)
|
||||
if output_info and verbose:
|
||||
print(f"\nOutput: {Path(output_path).name}")
|
||||
print(f" Size: {output_info['size'] / (1024*1024):.2f} MB")
|
||||
compression = (1 - output_info['size'] / info['size']) * 100
|
||||
print(f" Compression: {compression:.1f}%")
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error optimizing audio: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def optimize_image(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
max_width: int = 1920,
|
||||
quality: int = 85,
|
||||
verbose: bool = False
|
||||
) -> bool:
|
||||
"""Optimize image file for Gemini API."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("Error: Pillow not installed")
|
||||
print("Install with: pip install pillow")
|
||||
return False
|
||||
|
||||
try:
|
||||
img = Image.open(input_path)
|
||||
|
||||
if verbose:
|
||||
print(f"Input: {Path(input_path).name}")
|
||||
print(f" Size: {Path(input_path).stat().st_size / 1024:.2f} KB")
|
||||
print(f" Resolution: {img.width}x{img.height}")
|
||||
|
||||
# Resize if needed
|
||||
if img.width > max_width:
|
||||
ratio = max_width / img.width
|
||||
new_height = int(img.height * ratio)
|
||||
img = img.resize((max_width, new_height), Image.Resampling.LANCZOS)
|
||||
if verbose:
|
||||
print(f" Resized to: {img.width}x{img.height}")
|
||||
|
||||
# Convert RGBA to RGB if saving as JPEG
|
||||
if output_path.lower().endswith('.jpg') or output_path.lower().endswith('.jpeg'):
|
||||
if img.mode == 'RGBA':
|
||||
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
|
||||
rgb_img.paste(img, mask=img.split()[3])
|
||||
img = rgb_img
|
||||
|
||||
# Save
|
||||
img.save(output_path, quality=quality, optimize=True)
|
||||
|
||||
if verbose:
|
||||
print(f"\nOutput: {Path(output_path).name}")
|
||||
print(f" Size: {Path(output_path).stat().st_size / 1024:.2f} KB")
|
||||
compression = (1 - Path(output_path).stat().st_size / Path(input_path).stat().st_size) * 100
|
||||
print(f" Compression: {compression:.1f}%")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error optimizing image: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def split_video(
|
||||
input_path: str,
|
||||
output_dir: str,
|
||||
chunk_duration: int = 3600,
|
||||
verbose: bool = False
|
||||
) -> List[str]:
|
||||
"""Split long video into chunks."""
|
||||
if not check_ffmpeg():
|
||||
print("Error: ffmpeg not installed")
|
||||
return []
|
||||
|
||||
info = get_media_info(input_path)
|
||||
if not info:
|
||||
return []
|
||||
|
||||
total_duration = info['duration']
|
||||
num_chunks = int(total_duration / chunk_duration) + 1
|
||||
|
||||
if num_chunks == 1:
|
||||
if verbose:
|
||||
print("Video is short enough, no splitting needed")
|
||||
return [input_path]
|
||||
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
output_files = []
|
||||
|
||||
for i in range(num_chunks):
|
||||
start_time = i * chunk_duration
|
||||
output_file = Path(output_dir) / f"{Path(input_path).stem}_chunk_{i+1}.mp4"
|
||||
|
||||
cmd = [
|
||||
'ffmpeg', '-i', input_path, '-y',
|
||||
'-ss', str(start_time),
|
||||
'-t', str(chunk_duration),
|
||||
'-c', 'copy',
|
||||
str(output_file)
|
||||
]
|
||||
|
||||
if verbose:
|
||||
print(f"Creating chunk {i+1}/{num_chunks}...")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=not verbose)
|
||||
output_files.append(str(output_file))
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error creating chunk {i+1}: {e}")
|
||||
|
||||
return output_files
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Optimize media files for Gemini API',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Optimize video to 100MB
|
||||
%(prog)s --input video.mp4 --output optimized.mp4 --target-size 100
|
||||
|
||||
# Optimize audio
|
||||
%(prog)s --input audio.mp3 --output optimized.m4a --bitrate 64k
|
||||
|
||||
# Resize image
|
||||
%(prog)s --input image.jpg --output resized.jpg --max-width 1920
|
||||
|
||||
# Split long video
|
||||
%(prog)s --input long-video.mp4 --split --chunk-duration 3600 --output-dir ./chunks
|
||||
|
||||
# Batch optimize directory
|
||||
%(prog)s --input-dir ./videos --output-dir ./optimized --quality 85
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--input', help='Input file')
|
||||
parser.add_argument('--output', help='Output file')
|
||||
parser.add_argument('--input-dir', help='Input directory for batch processing')
|
||||
parser.add_argument('--output-dir', help='Output directory for batch processing')
|
||||
parser.add_argument('--target-size', type=int, help='Target size in MB')
|
||||
parser.add_argument('--quality', type=int, default=85,
|
||||
help='Quality (video: 0-51 CRF, image: 1-100) (default: 85)')
|
||||
parser.add_argument('--max-width', type=int, default=1920,
|
||||
help='Max image width (default: 1920)')
|
||||
parser.add_argument('--bitrate', default='64k',
|
||||
help='Audio bitrate (default: 64k)')
|
||||
parser.add_argument('--resolution', help='Video resolution (e.g., 1920x1080)')
|
||||
parser.add_argument('--split', action='store_true', help='Split long video into chunks')
|
||||
parser.add_argument('--chunk-duration', type=int, default=3600,
|
||||
help='Chunk duration in seconds (default: 3600 = 1 hour)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate arguments
|
||||
if not args.input and not args.input_dir:
|
||||
parser.error("Either --input or --input-dir required")
|
||||
|
||||
# Single file processing
|
||||
if args.input:
|
||||
input_path = Path(args.input)
|
||||
if not input_path.exists():
|
||||
print(f"Error: Input file not found: {input_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if args.split:
|
||||
output_dir = args.output_dir or './chunks'
|
||||
chunks = split_video(str(input_path), output_dir, args.chunk_duration, args.verbose)
|
||||
print(f"\nCreated {len(chunks)} chunks in {output_dir}")
|
||||
sys.exit(0)
|
||||
|
||||
if not args.output:
|
||||
parser.error("--output required for single file processing")
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Determine file type
|
||||
ext = input_path.suffix.lower()
|
||||
|
||||
if ext in ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv']:
|
||||
success = optimize_video(
|
||||
str(input_path),
|
||||
str(output_path),
|
||||
target_size_mb=args.target_size,
|
||||
quality=args.quality,
|
||||
resolution=args.resolution,
|
||||
verbose=args.verbose
|
||||
)
|
||||
elif ext in ['.mp3', '.wav', '.m4a', '.flac', '.aac']:
|
||||
success = optimize_audio(
|
||||
str(input_path),
|
||||
str(output_path),
|
||||
target_size_mb=args.target_size,
|
||||
bitrate=args.bitrate,
|
||||
verbose=args.verbose
|
||||
)
|
||||
elif ext in ['.jpg', '.jpeg', '.png', '.webp']:
|
||||
success = optimize_image(
|
||||
str(input_path),
|
||||
str(output_path),
|
||||
max_width=args.max_width,
|
||||
quality=args.quality,
|
||||
verbose=args.verbose
|
||||
)
|
||||
else:
|
||||
print(f"Error: Unsupported file type: {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
# Batch processing
|
||||
if args.input_dir:
|
||||
if not args.output_dir:
|
||||
parser.error("--output-dir required for batch processing")
|
||||
|
||||
input_dir = Path(args.input_dir)
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find all media files
|
||||
patterns = ['*.mp4', '*.mov', '*.avi', '*.mkv', '*.webm',
|
||||
'*.mp3', '*.wav', '*.m4a', '*.flac',
|
||||
'*.jpg', '*.jpeg', '*.png', '*.webp']
|
||||
|
||||
files = []
|
||||
for pattern in patterns:
|
||||
files.extend(input_dir.glob(pattern))
|
||||
|
||||
if not files:
|
||||
print(f"No media files found in {input_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(files)} files to process")
|
||||
|
||||
success_count = 0
|
||||
for input_file in files:
|
||||
output_file = output_dir / input_file.name
|
||||
|
||||
ext = input_file.suffix.lower()
|
||||
success = False
|
||||
|
||||
if ext in ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv']:
|
||||
success = optimize_video(str(input_file), str(output_file),
|
||||
quality=args.quality, verbose=args.verbose)
|
||||
elif ext in ['.mp3', '.wav', '.m4a', '.flac', '.aac']:
|
||||
success = optimize_audio(str(input_file), str(output_file),
|
||||
bitrate=args.bitrate, verbose=args.verbose)
|
||||
elif ext in ['.jpg', '.jpeg', '.png', '.webp']:
|
||||
success = optimize_image(str(input_file), str(output_file),
|
||||
max_width=args.max_width, quality=args.quality,
|
||||
verbose=args.verbose)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
|
||||
print(f"\nProcessed: {success_count}/{len(files)} files")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
189
.opencode/skills/ai-multimodal/scripts/minimax_api_client.py
Normal file
189
.opencode/skills/ai-multimodal/scripts/minimax_api_client.py
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MiniMax API client - shared HTTP utilities for all MiniMax generation tasks.
|
||||
|
||||
Handles authentication, API calls, async task polling, and file downloads.
|
||||
Base URL: https://api.minimax.io/v1
|
||||
Auth: Bearer token via MINIMAX_API_KEY environment variable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("Error: requests package not installed")
|
||||
print("Install with: pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
# Import centralized environment resolver
|
||||
CLAUDE_ROOT = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(CLAUDE_ROOT / 'scripts'))
|
||||
try:
|
||||
from resolve_env import resolve_env
|
||||
CENTRALIZED_RESOLVER_AVAILABLE = True
|
||||
except ImportError:
|
||||
CENTRALIZED_RESOLVER_AVAILABLE = False
|
||||
|
||||
BASE_URL = "https://api.minimax.io/v1"
|
||||
|
||||
|
||||
def find_minimax_api_key() -> Optional[str]:
|
||||
"""Find MINIMAX_API_KEY using centralized resolver or environment."""
|
||||
if CENTRALIZED_RESOLVER_AVAILABLE:
|
||||
return resolve_env('MINIMAX_API_KEY', skill='ai-multimodal')
|
||||
|
||||
# Fallback: check environment and .env files
|
||||
api_key = os.getenv('MINIMAX_API_KEY')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Check .env files in skill directory hierarchy
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
for env_path in [skill_dir / '.env', skill_dir.parent / '.env']:
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=True)
|
||||
api_key = os.getenv('MINIMAX_API_KEY')
|
||||
if api_key:
|
||||
return api_key
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_headers(api_key: str) -> Dict[str, str]:
|
||||
"""Build authorization headers for MiniMax API."""
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def api_post(endpoint: str, payload: Dict[str, Any], api_key: str,
|
||||
verbose: bool = False, timeout: int = 120) -> Dict[str, Any]:
|
||||
"""Make POST request to MiniMax API with error handling."""
|
||||
url = f"{BASE_URL}/{endpoint}"
|
||||
headers = get_headers(api_key)
|
||||
|
||||
if verbose:
|
||||
print(f" POST {url}", file=sys.stderr)
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(
|
||||
f"MiniMax API error (HTTP {response.status_code}): {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Check MiniMax-specific error codes
|
||||
base_resp = data.get("base_resp", {})
|
||||
status_code = base_resp.get("status_code", 0)
|
||||
if status_code != 0:
|
||||
raise Exception(
|
||||
f"MiniMax API error (code {status_code}): "
|
||||
f"{base_resp.get('status_msg', 'Unknown error')}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def api_get(endpoint: str, params: Dict[str, str], api_key: str,
|
||||
verbose: bool = False) -> Dict[str, Any]:
|
||||
"""Make GET request to MiniMax API."""
|
||||
url = f"{BASE_URL}/{endpoint}"
|
||||
headers = get_headers(api_key)
|
||||
|
||||
if verbose:
|
||||
print(f" GET {url}", file=sys.stderr)
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=60)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(
|
||||
f"MiniMax API error (HTTP {response.status_code}): {response.text}"
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def poll_async_task(task_id: str, task_type: str, api_key: str,
|
||||
poll_interval: int = 10, max_wait: int = 600,
|
||||
verbose: bool = False) -> Dict[str, Any]:
|
||||
"""Poll async task (video/music) until completion.
|
||||
|
||||
Args:
|
||||
task_id: The task ID returned from creation endpoint
|
||||
task_type: 'video_generation' or 'music_generation'
|
||||
poll_interval: Seconds between polls (default 10)
|
||||
max_wait: Maximum wait time in seconds (default 600)
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < max_wait:
|
||||
result = api_get(
|
||||
f"query/{task_type}",
|
||||
{"task_id": task_id},
|
||||
api_key,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
status = result.get("status", "Unknown")
|
||||
if verbose and elapsed > 0 and elapsed % 30 == 0:
|
||||
print(f" Polling... {elapsed}s elapsed, status: {status}",
|
||||
file=sys.stderr)
|
||||
|
||||
if status == "Success":
|
||||
return result
|
||||
elif status in ("Failed", "Error"):
|
||||
raise Exception(f"Task failed: {json.dumps(result)}")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
raise TimeoutError(f"Task {task_id} timed out after {max_wait}s")
|
||||
|
||||
|
||||
def download_file(file_id: str, api_key: str, output_path: str,
|
||||
verbose: bool = False) -> str:
|
||||
"""Download file from MiniMax file service."""
|
||||
result = api_get("files/retrieve", {"file_id": file_id}, api_key, verbose)
|
||||
|
||||
download_url = result.get("file", {}).get("download_url")
|
||||
if not download_url:
|
||||
raise Exception(f"No download URL in response: {json.dumps(result)}")
|
||||
|
||||
if verbose:
|
||||
print(f" Downloading to: {output_path}", file=sys.stderr)
|
||||
|
||||
response = requests.get(download_url, stream=True, timeout=300)
|
||||
response.raise_for_status()
|
||||
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def get_output_dir() -> Path:
|
||||
"""Get project output directory for generated assets."""
|
||||
script_dir = Path(__file__).parent
|
||||
for parent in [script_dir] + list(script_dir.parents):
|
||||
if (parent / '.git').exists() or (parent / '.claude').exists():
|
||||
output_dir = parent / 'docs' / 'assets'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return output_dir
|
||||
# Fallback
|
||||
output_dir = script_dir.parent / 'assets'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return output_dir
|
||||
178
.opencode/skills/ai-multimodal/scripts/minimax_cli.py
Normal file
178
.opencode/skills/ai-multimodal/scripts/minimax_cli.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MiniMax CLI entry point - standalone CLI for MiniMax generation tasks.
|
||||
|
||||
Can be called directly or delegated to from gemini_batch_process.py
|
||||
when MiniMax models are detected.
|
||||
|
||||
Usage:
|
||||
python minimax_cli.py --task generate --prompt "A cat" --model image-01
|
||||
python minimax_cli.py --task generate-video --prompt "A dancer" --model MiniMax-Hailuo-2.3
|
||||
python minimax_cli.py --task generate-speech --text "Hello" --model speech-2.8-hd --voice English_Warm_Bestie
|
||||
python minimax_cli.py --task generate-music --lyrics "La la la" --prompt "pop song" --model music-2.5
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from minimax_api_client import find_minimax_api_key
|
||||
from minimax_generate import (
|
||||
generate_image, generate_video, generate_speech, generate_music
|
||||
)
|
||||
|
||||
TASK_DEFAULTS = {
|
||||
'generate': 'image-01',
|
||||
'generate-video': 'MiniMax-Hailuo-2.3',
|
||||
'generate-speech': 'speech-2.8-hd',
|
||||
'generate-music': 'music-2.5'
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='MiniMax AI generation CLI (image/video/speech/music)',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Generate image
|
||||
%(prog)s --task generate --prompt "A cyberpunk city at night" --model image-01 --aspect-ratio 16:9
|
||||
|
||||
# Generate video (async, ~30-60s)
|
||||
%(prog)s --task generate-video --prompt "A dancer performing" --model MiniMax-Hailuo-2.3
|
||||
|
||||
# Generate speech
|
||||
%(prog)s --task generate-speech --text "Welcome to the show" --model speech-2.8-hd --voice English_Warm_Bestie
|
||||
|
||||
# Generate music with lyrics
|
||||
%(prog)s --task generate-music --lyrics "Verse 1\\nHello world" --prompt "upbeat pop" --model music-2.5
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--task', required=True,
|
||||
choices=['generate', 'generate-video',
|
||||
'generate-speech', 'generate-music'],
|
||||
help='Generation task type')
|
||||
parser.add_argument('--prompt', help='Text prompt for generation')
|
||||
parser.add_argument('--text', help='Text for speech generation')
|
||||
parser.add_argument('--lyrics', help='Lyrics for music generation')
|
||||
parser.add_argument('--model', help='Model name (auto-detected from task)')
|
||||
parser.add_argument('--aspect-ratio', default='1:1',
|
||||
choices=['1:1', '16:9', '4:3', '3:2', '2:3',
|
||||
'3:4', '9:16', '21:9'],
|
||||
help='Aspect ratio for image generation')
|
||||
parser.add_argument('--num-images', type=int, default=1,
|
||||
help='Number of images (1-9, default: 1)')
|
||||
parser.add_argument('--duration', type=int, default=6,
|
||||
choices=[6, 10],
|
||||
help='Video duration in seconds (6 or 10)')
|
||||
parser.add_argument('--resolution', default='1080P',
|
||||
choices=['720P', '1080P'],
|
||||
help='Video resolution')
|
||||
parser.add_argument('--voice', default='English_expressive_narrator',
|
||||
help='Voice ID for speech (default: English_expressive_narrator)')
|
||||
parser.add_argument('--emotion', default='neutral',
|
||||
choices=['happy', 'sad', 'angry', 'fearful',
|
||||
'disgusted', 'surprised', 'neutral'],
|
||||
help='Emotion for speech')
|
||||
parser.add_argument('--output-format', default='mp3',
|
||||
choices=['mp3', 'wav', 'flac', 'pcm'],
|
||||
help='Audio output format')
|
||||
parser.add_argument('--first-frame', help='Image URL for video first frame')
|
||||
parser.add_argument('--output', '-o', help='Output file path')
|
||||
parser.add_argument('--verbose', '-v', action='store_true')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Auto-detect model from task
|
||||
if not args.model:
|
||||
args.model = TASK_DEFAULTS.get(args.task, 'image-01')
|
||||
if args.verbose:
|
||||
print(f"Auto-detected model: {args.model}")
|
||||
|
||||
# Find API key
|
||||
api_key = find_minimax_api_key()
|
||||
if not api_key:
|
||||
print("Error: MINIMAX_API_KEY not found")
|
||||
print("\nSetup:")
|
||||
print("1. export MINIMAX_API_KEY='your-key'")
|
||||
print("2. Or add to .env: MINIMAX_API_KEY=your-key")
|
||||
print("\nGet key at: https://platform.minimax.io/user-center/basic-information/interface-key")
|
||||
sys.exit(1)
|
||||
|
||||
# Dispatch to task handler
|
||||
try:
|
||||
if args.task == 'generate':
|
||||
if not args.prompt:
|
||||
parser.error("--prompt required for image generation")
|
||||
result = generate_image(
|
||||
api_key, args.prompt, args.model,
|
||||
args.aspect_ratio, args.num_images,
|
||||
args.output, args.verbose
|
||||
)
|
||||
elif args.task == 'generate-video':
|
||||
if not args.prompt:
|
||||
parser.error("--prompt required for video generation")
|
||||
result = generate_video(
|
||||
api_key, args.prompt, args.model,
|
||||
args.duration, args.resolution,
|
||||
args.first_frame, args.output, args.verbose
|
||||
)
|
||||
elif args.task == 'generate-speech':
|
||||
text = args.text or args.prompt
|
||||
if not text:
|
||||
parser.error("--text or --prompt required for speech")
|
||||
result = generate_speech(
|
||||
api_key, text, args.model,
|
||||
args.voice, args.emotion, args.output_format,
|
||||
output=args.output, verbose=args.verbose
|
||||
)
|
||||
elif args.task == 'generate-music':
|
||||
if not args.lyrics and not args.prompt:
|
||||
parser.error("--lyrics or --prompt required for music")
|
||||
result = generate_music(
|
||||
api_key, args.lyrics or '', args.prompt or '',
|
||||
args.model, args.output_format,
|
||||
args.output, args.verbose
|
||||
)
|
||||
else:
|
||||
parser.error(f"Unknown task: {args.task}")
|
||||
return
|
||||
|
||||
# Print results
|
||||
print_result(result, args.task)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def print_result(result: dict, task: str):
|
||||
"""Print generation result in LLM-friendly format."""
|
||||
print(f"\n=== RESULTS ===\n")
|
||||
print(f"[{task}]")
|
||||
print(f"Status: {result.get('status', 'unknown')}")
|
||||
|
||||
if result.get('status') == 'success':
|
||||
if 'generated_images' in result:
|
||||
for img in result['generated_images']:
|
||||
print(f"Generated image: {img}")
|
||||
if 'generated_video' in result:
|
||||
print(f"Generated video: {result['generated_video']}")
|
||||
if 'generation_time' in result:
|
||||
print(f"Generation time: {result['generation_time']:.1f}s")
|
||||
if 'generated_audio' in result:
|
||||
print(f"Generated audio: {result['generated_audio']}")
|
||||
if 'duration_ms' in result:
|
||||
dur = result['duration_ms'] / 1000
|
||||
print(f"Duration: {dur:.1f}s")
|
||||
elif result.get('error'):
|
||||
print(f"Error: {result['error']}")
|
||||
|
||||
print(f"\nModel: {result.get('model', 'unknown')}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
278
.opencode/skills/ai-multimodal/scripts/minimax_generate.py
Normal file
278
.opencode/skills/ai-multimodal/scripts/minimax_generate.py
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MiniMax generation CLI - image, video, speech, and music generation.
|
||||
|
||||
Models:
|
||||
- Image: image-01, image-01-live
|
||||
- Video: MiniMax-Hailuo-2.3, MiniMax-Hailuo-2.3-Fast, MiniMax-Hailuo-02, S2V-01
|
||||
- Speech: speech-2.8-hd, speech-2.8-turbo, speech-2.6-hd, speech-2.6-turbo
|
||||
- Music: music-2.5
|
||||
|
||||
Usage:
|
||||
python minimax_generate.py --task generate --prompt "A cat in space" --model image-01
|
||||
python minimax_generate.py --task generate-video --prompt "A dancer" --model MiniMax-Hailuo-2.3
|
||||
python minimax_generate.py --task generate-speech --text "Hello world" --model speech-2.8-hd
|
||||
python minimax_generate.py --task generate-music --lyrics "Verse 1..." --model music-2.5
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from minimax_api_client import (
|
||||
find_minimax_api_key, api_post, poll_async_task,
|
||||
download_file, get_output_dir
|
||||
)
|
||||
|
||||
# Model registries
|
||||
MINIMAX_IMAGE_MODELS = {'image-01', 'image-01-live'}
|
||||
MINIMAX_VIDEO_MODELS = {
|
||||
'MiniMax-Hailuo-2.3', 'MiniMax-Hailuo-2.3-Fast',
|
||||
'MiniMax-Hailuo-02', 'S2V-01'
|
||||
}
|
||||
MINIMAX_SPEECH_MODELS = {
|
||||
'speech-2.8-hd', 'speech-2.8-turbo',
|
||||
'speech-2.6-hd', 'speech-2.6-turbo',
|
||||
'speech-02-hd', 'speech-02-turbo'
|
||||
}
|
||||
MINIMAX_MUSIC_MODELS = {'music-2.5', 'music-2.0'}
|
||||
|
||||
ALL_MINIMAX_MODELS = (
|
||||
MINIMAX_IMAGE_MODELS | MINIMAX_VIDEO_MODELS |
|
||||
MINIMAX_SPEECH_MODELS | MINIMAX_MUSIC_MODELS
|
||||
)
|
||||
|
||||
|
||||
def is_minimax_model(model: str) -> bool:
|
||||
"""Check if model is a MiniMax model."""
|
||||
return (
|
||||
model in ALL_MINIMAX_MODELS or
|
||||
model.startswith('MiniMax-') or
|
||||
model.startswith('image-01') or
|
||||
model.startswith('speech-') or
|
||||
model.startswith('music-') or
|
||||
model.startswith('S2V-')
|
||||
)
|
||||
|
||||
|
||||
def generate_image(api_key: str, prompt: str, model: str = 'image-01',
|
||||
aspect_ratio: str = '1:1', num_images: int = 1,
|
||||
output: str = None, verbose: bool = False) -> dict:
|
||||
"""Generate image using MiniMax image-01 model."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"n": min(num_images, 9),
|
||||
"response_format": "url",
|
||||
"prompt_optimizer": True
|
||||
}
|
||||
|
||||
if verbose:
|
||||
print(f"Generating {num_images} image(s) with {model}...")
|
||||
|
||||
result = api_post("image_generation", payload, api_key, verbose)
|
||||
|
||||
# Download images
|
||||
image_urls = result.get("data", {}).get("image_urls", [])
|
||||
if not image_urls:
|
||||
return {"status": "error", "error": "No images in response"}
|
||||
|
||||
output_dir = get_output_dir()
|
||||
saved_files = []
|
||||
import requests as req
|
||||
|
||||
for i, url in enumerate(image_urls):
|
||||
ts = int(time.time())
|
||||
fname = f"minimax_image_{ts}_{i}.png"
|
||||
fpath = output_dir / fname
|
||||
|
||||
resp = req.get(url, timeout=60)
|
||||
resp.raise_for_status()
|
||||
with open(fpath, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
saved_files.append(str(fpath))
|
||||
|
||||
if verbose:
|
||||
print(f" Saved: {fpath}")
|
||||
|
||||
# Copy first image to output if specified
|
||||
if output and saved_files:
|
||||
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(saved_files[0], output)
|
||||
|
||||
return {"status": "success", "generated_images": saved_files, "model": model}
|
||||
|
||||
|
||||
def generate_video(api_key: str, prompt: str, model: str = 'MiniMax-Hailuo-2.3',
|
||||
duration: int = 6, resolution: str = '1080P',
|
||||
first_frame: str = None, output: str = None,
|
||||
verbose: bool = False) -> dict:
|
||||
"""Generate video using MiniMax Hailuo models (async)."""
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"duration": duration,
|
||||
"resolution": resolution
|
||||
}
|
||||
if first_frame:
|
||||
payload["first_frame_image"] = first_frame
|
||||
|
||||
if verbose:
|
||||
print(f"Submitting video generation with {model}...")
|
||||
|
||||
result = api_post("video_generation", payload, api_key, verbose)
|
||||
task_id = result.get("task_id")
|
||||
if not task_id:
|
||||
return {"status": "error", "error": f"No task_id: {json.dumps(result)}"}
|
||||
|
||||
if verbose:
|
||||
print(f" Task ID: {task_id}, polling...")
|
||||
|
||||
start = time.time()
|
||||
poll_result = poll_async_task(task_id, "video_generation", api_key,
|
||||
poll_interval=10, verbose=verbose)
|
||||
|
||||
file_id = poll_result.get("file_id")
|
||||
if not file_id:
|
||||
return {"status": "error", "error": f"No file_id: {json.dumps(poll_result)}"}
|
||||
|
||||
output_dir = get_output_dir()
|
||||
ts = int(time.time())
|
||||
output_path = str(output_dir / f"minimax_video_{ts}.mp4")
|
||||
download_file(file_id, api_key, output_path, verbose)
|
||||
|
||||
elapsed = time.time() - start
|
||||
file_size = Path(output_path).stat().st_size / (1024 * 1024)
|
||||
|
||||
if output:
|
||||
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(output_path, output)
|
||||
|
||||
if verbose:
|
||||
print(f" Generated in {elapsed:.1f}s, size: {file_size:.2f} MB")
|
||||
|
||||
return {
|
||||
"status": "success", "generated_video": output_path,
|
||||
"generation_time": elapsed, "file_size_mb": file_size, "model": model
|
||||
}
|
||||
|
||||
|
||||
def generate_speech(api_key: str, text: str, model: str = 'speech-2.8-hd',
|
||||
voice: str = 'English_expressive_narrator',
|
||||
emotion: str = 'neutral', output_format: str = 'mp3',
|
||||
rate: float = 1.0, output: str = None,
|
||||
verbose: bool = False) -> dict:
|
||||
"""Generate speech using MiniMax TTS v2 API."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"text": text[:10000],
|
||||
"stream": False,
|
||||
"language_boost": "auto",
|
||||
"output_format": "hex",
|
||||
"voice_setting": {
|
||||
"voice_id": voice,
|
||||
"speed": rate,
|
||||
"vol": 1.0,
|
||||
"pitch": 0
|
||||
},
|
||||
"audio_setting": {
|
||||
"sample_rate": 32000,
|
||||
"bitrate": 128000,
|
||||
"format": output_format,
|
||||
"channel": 1
|
||||
}
|
||||
}
|
||||
|
||||
if verbose:
|
||||
print(f"Generating speech with {model}, voice: {voice}...")
|
||||
|
||||
result = api_post("t2a_v2", payload, api_key, verbose)
|
||||
|
||||
audio_data = result.get("data", {}).get("audio")
|
||||
if not audio_data:
|
||||
return {"status": "error", "error": "No audio in response"}
|
||||
|
||||
output_dir = get_output_dir()
|
||||
ts = int(time.time())
|
||||
ext = output_format if output_format in ('mp3', 'wav', 'flac') else 'mp3'
|
||||
output_path = str(output_dir / f"minimax_speech_{ts}.{ext}")
|
||||
|
||||
# Audio returned as hex-encoded string from t2a_v2
|
||||
audio_bytes = bytes.fromhex(audio_data)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
if output:
|
||||
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(output_path, output)
|
||||
|
||||
if verbose:
|
||||
size_kb = len(audio_bytes) / 1024
|
||||
print(f" Saved: {output_path} ({size_kb:.1f} KB)")
|
||||
|
||||
return {"status": "success", "generated_audio": output_path, "model": model}
|
||||
|
||||
|
||||
def generate_music(api_key: str, lyrics: str = '', prompt: str = '',
|
||||
model: str = 'music-2.5', output_format: str = 'mp3',
|
||||
output: str = None, verbose: bool = False) -> dict:
|
||||
"""Generate music using MiniMax music models."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"output_format": "url",
|
||||
"audio_setting": {
|
||||
"sample_rate": 44100,
|
||||
"bitrate": 128000,
|
||||
"format": output_format
|
||||
}
|
||||
}
|
||||
if lyrics:
|
||||
payload["lyrics"] = lyrics[:3500]
|
||||
if prompt:
|
||||
payload["prompt"] = prompt[:2000]
|
||||
|
||||
if verbose:
|
||||
print(f"Generating music with {model}...")
|
||||
|
||||
result = api_post("music_generation", payload, api_key, verbose, timeout=300)
|
||||
|
||||
audio_data = result.get("data", {}).get("audio")
|
||||
extra = result.get("extra_info", {})
|
||||
duration_ms = extra.get("music_duration", 0)
|
||||
|
||||
if not audio_data:
|
||||
return {"status": "error", "error": "No audio in response"}
|
||||
|
||||
output_dir = get_output_dir()
|
||||
ts = int(time.time())
|
||||
output_path = str(output_dir / f"minimax_music_{ts}.{output_format}")
|
||||
|
||||
# Download from URL or decode hex
|
||||
if audio_data.startswith("http"):
|
||||
import requests as req
|
||||
resp = req.get(audio_data, timeout=120)
|
||||
resp.raise_for_status()
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
else:
|
||||
audio_bytes = bytes.fromhex(audio_data)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
if output:
|
||||
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(output_path, output)
|
||||
|
||||
if verbose:
|
||||
dur_s = duration_ms / 1000 if duration_ms else 0
|
||||
print(f" Saved: {output_path} ({dur_s:.1f}s)")
|
||||
|
||||
return {
|
||||
"status": "success", "generated_audio": output_path,
|
||||
"duration_ms": duration_ms, "model": model
|
||||
}
|
||||
26
.opencode/skills/ai-multimodal/scripts/requirements.txt
Normal file
26
.opencode/skills/ai-multimodal/scripts/requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
# AI Multimodal Skill Dependencies
|
||||
# Python 3.10+ required
|
||||
|
||||
# Google Gemini API
|
||||
google-genai>=0.1.0
|
||||
|
||||
# PDF processing
|
||||
pypdf>=4.0.0
|
||||
|
||||
# Document conversion
|
||||
python-docx>=1.0.0
|
||||
docx2pdf>=0.1.8 # Windows only, optional on Linux/macOS
|
||||
|
||||
# Markdown processing
|
||||
markdown>=3.5.0
|
||||
|
||||
# Image processing
|
||||
Pillow>=10.0.0
|
||||
|
||||
# Environment variable management
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Testing dependencies (dev)
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
BIN
.opencode/skills/ai-multimodal/scripts/tests/.coverage
Normal file
BIN
.opencode/skills/ai-multimodal/scripts/tests/.coverage
Normal file
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
# Core dependencies
|
||||
google-genai>=0.2.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Image processing
|
||||
pillow>=10.0.0
|
||||
|
||||
# PDF processing
|
||||
pypdf>=3.0.0
|
||||
|
||||
# Document conversion
|
||||
markdown>=3.5
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
|
||||
# Optional dependencies for full functionality
|
||||
# ffmpeg-python>=0.2.0 # For media optimization (requires ffmpeg installed)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Tests for document_converter.py
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock, mock_open
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import document_converter as dc
|
||||
|
||||
|
||||
class TestAPIKeyFinder:
|
||||
"""Test API key finding logic."""
|
||||
|
||||
@patch.dict('os.environ', {'GEMINI_API_KEY': 'test-key-from-env'})
|
||||
def test_find_api_key_from_env(self):
|
||||
"""Test finding API key from environment."""
|
||||
api_key = dc.find_api_key()
|
||||
assert api_key == 'test-key-from-env'
|
||||
|
||||
@patch.dict('os.environ', {}, clear=True)
|
||||
@patch('document_converter.load_dotenv', None)
|
||||
def test_find_api_key_no_key(self):
|
||||
"""Test when no API key is available."""
|
||||
api_key = dc.find_api_key()
|
||||
assert api_key is None
|
||||
|
||||
|
||||
class TestProjectRoot:
|
||||
"""Test project root finding."""
|
||||
|
||||
@patch('pathlib.Path.exists')
|
||||
def test_find_project_root_with_git(self, mock_exists):
|
||||
"""Test finding project root with .git directory."""
|
||||
root = dc.find_project_root()
|
||||
assert isinstance(root, Path)
|
||||
|
||||
|
||||
class TestMimeType:
|
||||
"""Test MIME type detection."""
|
||||
|
||||
def test_pdf_mime_type(self):
|
||||
"""Test PDF MIME type."""
|
||||
assert dc.get_mime_type('document.pdf') == 'application/pdf'
|
||||
|
||||
def test_image_mime_types(self):
|
||||
"""Test image MIME types."""
|
||||
assert dc.get_mime_type('image.jpg') == 'image/jpeg'
|
||||
assert dc.get_mime_type('image.png') == 'image/png'
|
||||
|
||||
def test_unknown_mime_type(self):
|
||||
"""Test unknown file extension."""
|
||||
assert dc.get_mime_type('file.unknown') == 'application/octet-stream'
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests."""
|
||||
|
||||
def test_mime_type_integration(self):
|
||||
"""Test MIME type detection with various extensions."""
|
||||
test_cases = [
|
||||
('document.pdf', 'application/pdf'),
|
||||
('image.jpg', 'image/jpeg'),
|
||||
('unknown.xyz', 'application/octet-stream'),
|
||||
]
|
||||
for file_path, expected_mime in test_cases:
|
||||
assert dc.get_mime_type(file_path) == expected_mime
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v', '--cov=document_converter', '--cov-report=term-missing'])
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Tests for gemini_batch_process.py
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import gemini_batch_process as gbp
|
||||
|
||||
|
||||
class TestAPIKeyFinder:
|
||||
"""Test API key detection."""
|
||||
|
||||
def test_find_api_key_from_env(self, monkeypatch):
|
||||
"""Test finding API key from environment variable."""
|
||||
monkeypatch.setenv('GEMINI_API_KEY', 'test_key_123')
|
||||
assert gbp.find_api_key() == 'test_key_123'
|
||||
|
||||
@patch('gemini_batch_process.load_dotenv')
|
||||
def test_find_api_key_not_found(self, mock_load_dotenv, monkeypatch):
|
||||
"""Test when API key is not found."""
|
||||
monkeypatch.delenv('GEMINI_API_KEY', raising=False)
|
||||
# Mock load_dotenv to not actually load any files
|
||||
mock_load_dotenv.return_value = None
|
||||
assert gbp.find_api_key() is None
|
||||
|
||||
|
||||
class TestMimeTypeDetection:
|
||||
"""Test MIME type detection."""
|
||||
|
||||
def test_audio_mime_types(self):
|
||||
"""Test audio file MIME types."""
|
||||
assert gbp.get_mime_type('test.mp3') == 'audio/mp3'
|
||||
assert gbp.get_mime_type('test.wav') == 'audio/wav'
|
||||
assert gbp.get_mime_type('test.aac') == 'audio/aac'
|
||||
assert gbp.get_mime_type('test.flac') == 'audio/flac'
|
||||
|
||||
def test_image_mime_types(self):
|
||||
"""Test image file MIME types."""
|
||||
assert gbp.get_mime_type('test.jpg') == 'image/jpeg'
|
||||
assert gbp.get_mime_type('test.jpeg') == 'image/jpeg'
|
||||
assert gbp.get_mime_type('test.png') == 'image/png'
|
||||
assert gbp.get_mime_type('test.webp') == 'image/webp'
|
||||
|
||||
def test_video_mime_types(self):
|
||||
"""Test video file MIME types."""
|
||||
assert gbp.get_mime_type('test.mp4') == 'video/mp4'
|
||||
assert gbp.get_mime_type('test.mov') == 'video/quicktime'
|
||||
assert gbp.get_mime_type('test.avi') == 'video/x-msvideo'
|
||||
|
||||
def test_document_mime_types(self):
|
||||
"""Test document file MIME types."""
|
||||
assert gbp.get_mime_type('test.pdf') == 'application/pdf'
|
||||
assert gbp.get_mime_type('test.txt') == 'text/plain'
|
||||
|
||||
def test_unknown_mime_type(self):
|
||||
"""Test unknown file extension."""
|
||||
assert gbp.get_mime_type('test.xyz') == 'application/octet-stream'
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Test case-insensitive extension matching."""
|
||||
assert gbp.get_mime_type('TEST.MP3') == 'audio/mp3'
|
||||
assert gbp.get_mime_type('Test.JPG') == 'image/jpeg'
|
||||
|
||||
|
||||
class TestFileUpload:
|
||||
"""Test file upload functionality."""
|
||||
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
def test_upload_file_success(self, mock_client_class):
|
||||
"""Test successful file upload."""
|
||||
# Mock client and file
|
||||
mock_client = Mock()
|
||||
mock_file = Mock()
|
||||
mock_file.state.name = 'ACTIVE'
|
||||
mock_file.name = 'test_file'
|
||||
mock_client.files.upload.return_value = mock_file
|
||||
|
||||
result = gbp.upload_file(mock_client, 'test.jpg', verbose=False)
|
||||
|
||||
assert result == mock_file
|
||||
mock_client.files.upload.assert_called_once_with(file='test.jpg')
|
||||
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
@patch('gemini_batch_process.time.sleep')
|
||||
def test_upload_video_with_processing(self, mock_sleep, mock_client_class):
|
||||
"""Test video upload with processing wait."""
|
||||
mock_client = Mock()
|
||||
|
||||
# First call: PROCESSING, second call: ACTIVE
|
||||
mock_file_processing = Mock()
|
||||
mock_file_processing.state.name = 'PROCESSING'
|
||||
mock_file_processing.name = 'test_video'
|
||||
|
||||
mock_file_active = Mock()
|
||||
mock_file_active.state.name = 'ACTIVE'
|
||||
mock_file_active.name = 'test_video'
|
||||
|
||||
mock_client.files.upload.return_value = mock_file_processing
|
||||
mock_client.files.get.return_value = mock_file_active
|
||||
|
||||
result = gbp.upload_file(mock_client, 'test.mp4', verbose=False)
|
||||
|
||||
assert result.state.name == 'ACTIVE'
|
||||
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
def test_upload_file_failed(self, mock_client_class):
|
||||
"""Test failed file upload."""
|
||||
mock_client = Mock()
|
||||
mock_file = Mock()
|
||||
mock_file.state.name = 'FAILED'
|
||||
mock_client.files.upload.return_value = mock_file
|
||||
mock_client.files.get.return_value = mock_file
|
||||
|
||||
with pytest.raises(ValueError, match="File processing failed"):
|
||||
gbp.upload_file(mock_client, 'test.mp4', verbose=False)
|
||||
|
||||
|
||||
class TestProcessFile:
|
||||
"""Test file processing functionality."""
|
||||
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
@patch('builtins.open', create=True)
|
||||
@patch('pathlib.Path.stat')
|
||||
def test_process_small_file_inline(self, mock_stat, mock_open, mock_client_class):
|
||||
"""Test processing small file with inline data."""
|
||||
# Mock small file
|
||||
mock_stat.return_value.st_size = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
# Mock file content
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = b'test_data'
|
||||
|
||||
# Mock client and response
|
||||
mock_client = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.text = 'Test response'
|
||||
mock_client.models.generate_content.return_value = mock_response
|
||||
|
||||
result = gbp.process_file(
|
||||
client=mock_client,
|
||||
file_path='test.jpg',
|
||||
prompt='Describe this image',
|
||||
model='gemini-2.5-flash',
|
||||
task='analyze',
|
||||
format_output='text',
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result['status'] == 'success'
|
||||
assert result['response'] == 'Test response'
|
||||
|
||||
@patch('gemini_batch_process.upload_file')
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
@patch('pathlib.Path.stat')
|
||||
def test_process_large_file_api(self, mock_stat, mock_client_class, mock_upload):
|
||||
"""Test processing large file with File API."""
|
||||
# Mock large file
|
||||
mock_stat.return_value.st_size = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
# Mock upload and response
|
||||
mock_file = Mock()
|
||||
mock_upload.return_value = mock_file
|
||||
|
||||
mock_client = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.text = 'Test response'
|
||||
mock_client.models.generate_content.return_value = mock_response
|
||||
|
||||
result = gbp.process_file(
|
||||
client=mock_client,
|
||||
file_path='test.mp4',
|
||||
prompt='Summarize this video',
|
||||
model='gemini-2.5-flash',
|
||||
task='analyze',
|
||||
format_output='text',
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result['status'] == 'success'
|
||||
mock_upload.assert_called_once()
|
||||
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
@patch('builtins.open', create=True)
|
||||
@patch('pathlib.Path.stat')
|
||||
def test_process_file_error_handling(self, mock_stat, mock_open, mock_client_class):
|
||||
"""Test error handling in file processing."""
|
||||
mock_stat.return_value.st_size = 1024
|
||||
|
||||
# Mock file read
|
||||
mock_file = MagicMock()
|
||||
mock_file.__enter__.return_value.read.return_value = b'test_data'
|
||||
mock_open.return_value = mock_file
|
||||
|
||||
mock_client = Mock()
|
||||
mock_client.models.generate_content.side_effect = Exception("API Error")
|
||||
|
||||
result = gbp.process_file(
|
||||
client=mock_client,
|
||||
file_path='test.jpg',
|
||||
prompt='Test',
|
||||
model='gemini-2.5-flash',
|
||||
task='analyze',
|
||||
format_output='text',
|
||||
verbose=False,
|
||||
max_retries=1
|
||||
)
|
||||
|
||||
assert result['status'] == 'error'
|
||||
assert 'API Error' in result['error']
|
||||
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
@patch('builtins.open', create=True)
|
||||
@patch('pathlib.Path.stat')
|
||||
def test_image_generation_with_aspect_ratio(self, mock_stat, mock_open, mock_client_class):
|
||||
"""Test image generation with aspect ratio config."""
|
||||
mock_stat.return_value.st_size = 1024
|
||||
|
||||
# Mock file read
|
||||
mock_file = MagicMock()
|
||||
mock_file.__enter__.return_value.read.return_value = b'test'
|
||||
mock_open.return_value = mock_file
|
||||
|
||||
mock_client = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.candidates = [Mock()]
|
||||
mock_response.candidates[0].content.parts = [
|
||||
Mock(inline_data=Mock(data=b'fake_image_data'))
|
||||
]
|
||||
mock_client.models.generate_content.return_value = mock_response
|
||||
|
||||
result = gbp.process_file(
|
||||
client=mock_client,
|
||||
file_path='test.txt',
|
||||
prompt='Generate mountain landscape',
|
||||
model='gemini-2.5-flash-image',
|
||||
task='generate',
|
||||
format_output='text',
|
||||
aspect_ratio='16:9',
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify config was called with correct structure
|
||||
call_args = mock_client.models.generate_content.call_args
|
||||
config = call_args.kwargs.get('config')
|
||||
assert config is not None
|
||||
assert result['status'] == 'success'
|
||||
assert 'generated_image' in result
|
||||
|
||||
|
||||
class TestBatchProcessing:
|
||||
"""Test batch processing functionality."""
|
||||
|
||||
@patch('gemini_batch_process.find_api_key')
|
||||
@patch('gemini_batch_process.process_file')
|
||||
@patch('gemini_batch_process.genai.Client')
|
||||
def test_batch_process_success(self, mock_client_class, mock_process, mock_find_key):
|
||||
"""Test successful batch processing."""
|
||||
mock_find_key.return_value = 'test_key'
|
||||
mock_process.return_value = {'status': 'success', 'response': 'Test'}
|
||||
|
||||
results = gbp.batch_process(
|
||||
files=['test1.jpg', 'test2.jpg'],
|
||||
prompt='Analyze',
|
||||
model='gemini-2.5-flash',
|
||||
task='analyze',
|
||||
format_output='text',
|
||||
verbose=False,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(r['status'] == 'success' for r in results)
|
||||
|
||||
@patch('gemini_batch_process.find_api_key')
|
||||
def test_batch_process_no_api_key(self, mock_find_key):
|
||||
"""Test batch processing without API key."""
|
||||
mock_find_key.return_value = None
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
gbp.batch_process(
|
||||
files=['test.jpg'],
|
||||
prompt='Test',
|
||||
model='gemini-2.5-flash',
|
||||
task='analyze',
|
||||
format_output='text',
|
||||
verbose=False,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
@patch('gemini_batch_process.find_api_key')
|
||||
def test_batch_process_dry_run(self, mock_find_key):
|
||||
"""Test dry run mode."""
|
||||
# API key not needed for dry run, but we mock it to avoid sys.exit
|
||||
mock_find_key.return_value = 'test_key'
|
||||
|
||||
results = gbp.batch_process(
|
||||
files=['test1.jpg', 'test2.jpg'],
|
||||
prompt='Test',
|
||||
model='gemini-2.5-flash',
|
||||
task='analyze',
|
||||
format_output='text',
|
||||
verbose=False,
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
class TestResultsSaving:
|
||||
"""Test results saving functionality."""
|
||||
|
||||
@patch('builtins.open', create=True)
|
||||
@patch('json.dump')
|
||||
def test_save_results_json(self, mock_json_dump, mock_open):
|
||||
"""Test saving results as JSON."""
|
||||
results = [
|
||||
{'file': 'test1.jpg', 'status': 'success', 'response': 'Test1'},
|
||||
{'file': 'test2.jpg', 'status': 'success', 'response': 'Test2'}
|
||||
]
|
||||
|
||||
gbp.save_results(results, 'output.json', 'json')
|
||||
|
||||
mock_json_dump.assert_called_once()
|
||||
|
||||
@patch('builtins.open', create=True)
|
||||
@patch('csv.DictWriter')
|
||||
def test_save_results_csv(self, mock_csv_writer, mock_open):
|
||||
"""Test saving results as CSV."""
|
||||
results = [
|
||||
{'file': 'test1.jpg', 'status': 'success', 'response': 'Test1'},
|
||||
{'file': 'test2.jpg', 'status': 'success', 'response': 'Test2'}
|
||||
]
|
||||
|
||||
gbp.save_results(results, 'output.csv', 'csv')
|
||||
|
||||
# Verify CSV writer was used
|
||||
mock_csv_writer.assert_called_once()
|
||||
|
||||
@patch('builtins.open', create=True)
|
||||
def test_save_results_markdown(self, mock_open):
|
||||
"""Test saving results as Markdown."""
|
||||
mock_file = MagicMock()
|
||||
mock_open.return_value.__enter__.return_value = mock_file
|
||||
|
||||
results = [
|
||||
{'file': 'test1.jpg', 'status': 'success', 'response': 'Test1'},
|
||||
{'file': 'test2.jpg', 'status': 'error', 'error': 'Failed'}
|
||||
]
|
||||
|
||||
gbp.save_results(results, 'output.md', 'markdown')
|
||||
|
||||
# Verify write was called
|
||||
assert mock_file.write.call_count > 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v', '--cov=gemini_batch_process', '--cov-report=term-missing'])
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Tests for media_optimizer.py
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import json
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import media_optimizer as mo
|
||||
|
||||
|
||||
class TestEnvLoading:
|
||||
"""Test environment variable loading."""
|
||||
|
||||
@patch('media_optimizer.load_dotenv')
|
||||
@patch('pathlib.Path.exists')
|
||||
def test_load_env_files_success(self, mock_exists, mock_load_dotenv):
|
||||
"""Test successful .env file loading."""
|
||||
mock_exists.return_value = True
|
||||
mo.load_env_files()
|
||||
# Should be called for skill, skills, and claude dirs
|
||||
assert mock_load_dotenv.call_count >= 1
|
||||
|
||||
@patch('media_optimizer.load_dotenv', None)
|
||||
def test_load_env_files_no_dotenv(self):
|
||||
"""Test when dotenv is not available."""
|
||||
# Should not raise an error
|
||||
mo.load_env_files()
|
||||
|
||||
|
||||
class TestFFmpegCheck:
|
||||
"""Test ffmpeg availability checking."""
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_ffmpeg_installed(self, mock_run):
|
||||
"""Test when ffmpeg is installed."""
|
||||
mock_run.return_value = Mock()
|
||||
assert mo.check_ffmpeg() is True
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_ffmpeg_not_installed(self, mock_run):
|
||||
"""Test when ffmpeg is not installed."""
|
||||
mock_run.side_effect = FileNotFoundError()
|
||||
assert mo.check_ffmpeg() is False
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_ffmpeg_error(self, mock_run):
|
||||
"""Test ffmpeg command error."""
|
||||
mock_run.side_effect = Exception("Error")
|
||||
assert mo.check_ffmpeg() is False
|
||||
|
||||
|
||||
class TestMediaInfo:
|
||||
"""Test media information extraction."""
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('subprocess.run')
|
||||
def test_get_video_info(self, mock_run, mock_check):
|
||||
"""Test extracting video information."""
|
||||
mock_check.return_value = True
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.stdout = json.dumps({
|
||||
'format': {
|
||||
'size': '10485760',
|
||||
'duration': '120.5',
|
||||
'bit_rate': '691200'
|
||||
},
|
||||
'streams': [
|
||||
{
|
||||
'codec_type': 'video',
|
||||
'width': 1920,
|
||||
'height': 1080,
|
||||
'r_frame_rate': '30/1'
|
||||
},
|
||||
{
|
||||
'codec_type': 'audio',
|
||||
'sample_rate': '48000',
|
||||
'channels': 2
|
||||
}
|
||||
]
|
||||
})
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
info = mo.get_media_info('test.mp4')
|
||||
|
||||
assert info['size'] == 10485760
|
||||
assert info['duration'] == 120.5
|
||||
assert info['width'] == 1920
|
||||
assert info['height'] == 1080
|
||||
assert info['sample_rate'] == 48000
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
def test_get_media_info_no_ffmpeg(self, mock_check):
|
||||
"""Test when ffmpeg is not available."""
|
||||
mock_check.return_value = False
|
||||
info = mo.get_media_info('test.mp4')
|
||||
assert info == {}
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('subprocess.run')
|
||||
def test_get_media_info_error(self, mock_run, mock_check):
|
||||
"""Test error handling in media info extraction."""
|
||||
mock_check.return_value = True
|
||||
mock_run.side_effect = Exception("Error")
|
||||
|
||||
info = mo.get_media_info('test.mp4')
|
||||
assert info == {}
|
||||
|
||||
|
||||
class TestVideoOptimization:
|
||||
"""Test video optimization functionality."""
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('media_optimizer.get_media_info')
|
||||
@patch('subprocess.run')
|
||||
def test_optimize_video_success(self, mock_run, mock_info, mock_check):
|
||||
"""Test successful video optimization."""
|
||||
mock_check.return_value = True
|
||||
mock_info.side_effect = [
|
||||
# Input info
|
||||
{
|
||||
'size': 50 * 1024 * 1024,
|
||||
'duration': 120.0,
|
||||
'bit_rate': 3500000,
|
||||
'width': 1920,
|
||||
'height': 1080
|
||||
},
|
||||
# Output info
|
||||
{
|
||||
'size': 25 * 1024 * 1024,
|
||||
'duration': 120.0,
|
||||
'width': 1920,
|
||||
'height': 1080
|
||||
}
|
||||
]
|
||||
|
||||
result = mo.optimize_video(
|
||||
'input.mp4',
|
||||
'output.mp4',
|
||||
quality=23,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
def test_optimize_video_no_ffmpeg(self, mock_check):
|
||||
"""Test video optimization without ffmpeg."""
|
||||
mock_check.return_value = False
|
||||
|
||||
result = mo.optimize_video('input.mp4', 'output.mp4')
|
||||
assert result is False
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('media_optimizer.get_media_info')
|
||||
def test_optimize_video_no_info(self, mock_info, mock_check):
|
||||
"""Test video optimization when info cannot be read."""
|
||||
mock_check.return_value = True
|
||||
mock_info.return_value = {}
|
||||
|
||||
result = mo.optimize_video('input.mp4', 'output.mp4')
|
||||
assert result is False
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('media_optimizer.get_media_info')
|
||||
@patch('subprocess.run')
|
||||
def test_optimize_video_with_target_size(self, mock_run, mock_info, mock_check):
|
||||
"""Test video optimization with target size."""
|
||||
mock_check.return_value = True
|
||||
mock_info.side_effect = [
|
||||
{'size': 100 * 1024 * 1024, 'duration': 60.0, 'bit_rate': 3500000},
|
||||
{'size': 50 * 1024 * 1024, 'duration': 60.0}
|
||||
]
|
||||
|
||||
result = mo.optimize_video(
|
||||
'input.mp4',
|
||||
'output.mp4',
|
||||
target_size_mb=50,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('media_optimizer.get_media_info')
|
||||
@patch('subprocess.run')
|
||||
def test_optimize_video_with_resolution(self, mock_run, mock_info, mock_check):
|
||||
"""Test video optimization with custom resolution."""
|
||||
mock_check.return_value = True
|
||||
mock_info.side_effect = [
|
||||
{'size': 50 * 1024 * 1024, 'duration': 120.0, 'bit_rate': 3500000},
|
||||
{'size': 25 * 1024 * 1024, 'duration': 120.0}
|
||||
]
|
||||
|
||||
result = mo.optimize_video(
|
||||
'input.mp4',
|
||||
'output.mp4',
|
||||
resolution='1280x720',
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestAudioOptimization:
|
||||
"""Test audio optimization functionality."""
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('media_optimizer.get_media_info')
|
||||
@patch('subprocess.run')
|
||||
def test_optimize_audio_success(self, mock_run, mock_info, mock_check):
|
||||
"""Test successful audio optimization."""
|
||||
mock_check.return_value = True
|
||||
mock_info.side_effect = [
|
||||
{'size': 10 * 1024 * 1024, 'duration': 300.0},
|
||||
{'size': 5 * 1024 * 1024, 'duration': 300.0}
|
||||
]
|
||||
|
||||
result = mo.optimize_audio(
|
||||
'input.mp3',
|
||||
'output.m4a',
|
||||
bitrate='64k',
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
def test_optimize_audio_no_ffmpeg(self, mock_check):
|
||||
"""Test audio optimization without ffmpeg."""
|
||||
mock_check.return_value = False
|
||||
|
||||
result = mo.optimize_audio('input.mp3', 'output.m4a')
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestImageOptimization:
|
||||
"""Test image optimization functionality."""
|
||||
|
||||
@patch('PIL.Image.open')
|
||||
@patch('pathlib.Path.stat')
|
||||
def test_optimize_image_success(self, mock_stat, mock_image_open):
|
||||
"""Test successful image optimization."""
|
||||
# Mock image
|
||||
mock_resized = Mock()
|
||||
mock_resized.mode = 'RGB'
|
||||
|
||||
mock_img = Mock()
|
||||
mock_img.width = 3840
|
||||
mock_img.height = 2160
|
||||
mock_img.mode = 'RGB'
|
||||
mock_img.resize.return_value = mock_resized
|
||||
mock_image_open.return_value = mock_img
|
||||
|
||||
# Mock file sizes
|
||||
mock_stat.return_value.st_size = 5 * 1024 * 1024
|
||||
|
||||
result = mo.optimize_image(
|
||||
'input.jpg',
|
||||
'output.jpg',
|
||||
max_width=1920,
|
||||
quality=85,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
# Since image is resized, save is called on the resized image
|
||||
mock_resized.save.assert_called_once()
|
||||
|
||||
@patch('PIL.Image.open')
|
||||
@patch('pathlib.Path.stat')
|
||||
def test_optimize_image_resize(self, mock_stat, mock_image_open):
|
||||
"""Test image resizing during optimization."""
|
||||
mock_img = Mock()
|
||||
mock_img.width = 3840
|
||||
mock_img.height = 2160
|
||||
mock_img.mode = 'RGB'
|
||||
mock_resized = Mock()
|
||||
mock_img.resize.return_value = mock_resized
|
||||
mock_image_open.return_value = mock_img
|
||||
|
||||
mock_stat.return_value.st_size = 5 * 1024 * 1024
|
||||
|
||||
mo.optimize_image('input.jpg', 'output.jpg', max_width=1920, verbose=False)
|
||||
|
||||
mock_img.resize.assert_called_once()
|
||||
|
||||
@patch('PIL.Image.open')
|
||||
@patch('pathlib.Path.stat')
|
||||
def test_optimize_image_rgba_to_jpg(self, mock_stat, mock_image_open):
|
||||
"""Test converting RGBA to RGB for JPEG."""
|
||||
mock_img = Mock()
|
||||
mock_img.width = 1920
|
||||
mock_img.height = 1080
|
||||
mock_img.mode = 'RGBA'
|
||||
mock_img.split.return_value = [Mock(), Mock(), Mock(), Mock()]
|
||||
mock_image_open.return_value = mock_img
|
||||
|
||||
mock_stat.return_value.st_size = 1024 * 1024
|
||||
|
||||
with patch('PIL.Image.new') as mock_new:
|
||||
mock_rgb = Mock()
|
||||
mock_new.return_value = mock_rgb
|
||||
|
||||
mo.optimize_image('input.png', 'output.jpg', verbose=False)
|
||||
|
||||
mock_new.assert_called_once()
|
||||
|
||||
def test_optimize_image_no_pillow(self):
|
||||
"""Test image optimization without Pillow."""
|
||||
with patch.dict('sys.modules', {'PIL': None}):
|
||||
result = mo.optimize_image('input.jpg', 'output.jpg')
|
||||
# Will fail to import but function handles it
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestVideoSplitting:
|
||||
"""Test video splitting functionality."""
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('media_optimizer.get_media_info')
|
||||
@patch('subprocess.run')
|
||||
@patch('pathlib.Path.mkdir')
|
||||
def test_split_video_success(self, mock_mkdir, mock_run, mock_info, mock_check):
|
||||
"""Test successful video splitting."""
|
||||
mock_check.return_value = True
|
||||
mock_info.return_value = {'duration': 7200.0} # 2 hours
|
||||
|
||||
result = mo.split_video(
|
||||
'input.mp4',
|
||||
'./chunks',
|
||||
chunk_duration=3600, # 1 hour chunks
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Duration 7200s / 3600s = 2, +1 for safety = 3 chunks
|
||||
assert len(result) == 3
|
||||
assert mock_run.call_count == 3
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
@patch('media_optimizer.get_media_info')
|
||||
def test_split_video_short_duration(self, mock_info, mock_check):
|
||||
"""Test splitting video shorter than chunk duration."""
|
||||
mock_check.return_value = True
|
||||
mock_info.return_value = {'duration': 1800.0} # 30 minutes
|
||||
|
||||
result = mo.split_video(
|
||||
'input.mp4',
|
||||
'./chunks',
|
||||
chunk_duration=3600, # 1 hour
|
||||
verbose=False
|
||||
)
|
||||
|
||||
assert result == ['input.mp4']
|
||||
|
||||
@patch('media_optimizer.check_ffmpeg')
|
||||
def test_split_video_no_ffmpeg(self, mock_check):
|
||||
"""Test video splitting without ffmpeg."""
|
||||
mock_check.return_value = False
|
||||
|
||||
result = mo.split_video('input.mp4', './chunks')
|
||||
assert result == []
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v', '--cov=media_optimizer', '--cov-report=term-missing'])
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Tests for minimax_api_client.py - HTTP utilities, auth, polling, downloads.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import minimax_api_client as mac
|
||||
|
||||
|
||||
class TestFindMinimaxApiKey:
|
||||
"""Test API key discovery."""
|
||||
|
||||
def test_find_key_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv('MINIMAX_API_KEY', 'test-minimax-key')
|
||||
with patch.object(mac, 'CENTRALIZED_RESOLVER_AVAILABLE', False):
|
||||
assert mac.find_minimax_api_key() == 'test-minimax-key'
|
||||
|
||||
def test_find_key_not_found(self, monkeypatch):
|
||||
monkeypatch.delenv('MINIMAX_API_KEY', raising=False)
|
||||
with patch.object(mac, 'CENTRALIZED_RESOLVER_AVAILABLE', False):
|
||||
result = mac.find_minimax_api_key()
|
||||
assert result is None
|
||||
|
||||
def test_find_key_via_centralized_resolver(self, monkeypatch):
|
||||
mock_resolve = Mock(return_value='resolved-key')
|
||||
with patch.object(mac, 'CENTRALIZED_RESOLVER_AVAILABLE', True), \
|
||||
patch.object(mac, 'resolve_env', mock_resolve, create=True):
|
||||
result = mac.find_minimax_api_key()
|
||||
assert result == 'resolved-key'
|
||||
mock_resolve.assert_called_once_with(
|
||||
'MINIMAX_API_KEY', skill='ai-multimodal'
|
||||
)
|
||||
|
||||
|
||||
class TestGetHeaders:
|
||||
"""Test header generation."""
|
||||
|
||||
def test_headers_contain_bearer_token(self):
|
||||
headers = mac.get_headers('my-api-key')
|
||||
assert headers['Authorization'] == 'Bearer my-api-key'
|
||||
assert headers['Content-Type'] == 'application/json'
|
||||
|
||||
def test_headers_with_different_key(self):
|
||||
headers = mac.get_headers('another-key-123')
|
||||
assert 'another-key-123' in headers['Authorization']
|
||||
|
||||
|
||||
class TestApiPost:
|
||||
"""Test POST request handling."""
|
||||
|
||||
@patch('minimax_api_client.requests.post')
|
||||
def test_successful_post(self, mock_post):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"base_resp": {"status_code": 0},
|
||||
"data": {"result": "ok"}
|
||||
}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
result = mac.api_post("test_endpoint", {"key": "val"}, "api-key")
|
||||
assert result["data"]["result"] == "ok"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch('minimax_api_client.requests.post')
|
||||
def test_http_error_raises(self, mock_post):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 401
|
||||
mock_resp.text = "Unauthorized"
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
with pytest.raises(Exception, match="HTTP 401"):
|
||||
mac.api_post("endpoint", {}, "bad-key")
|
||||
|
||||
@patch('minimax_api_client.requests.post')
|
||||
def test_minimax_error_code_raises(self, mock_post):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"base_resp": {"status_code": 1002, "status_msg": "Rate limit"}
|
||||
}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
with pytest.raises(Exception, match="code 1002.*Rate limit"):
|
||||
mac.api_post("endpoint", {}, "api-key")
|
||||
|
||||
@patch('minimax_api_client.requests.post')
|
||||
def test_custom_timeout(self, mock_post):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"base_resp": {"status_code": 0}}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
mac.api_post("endpoint", {}, "key", timeout=300)
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs['timeout'] == 300
|
||||
|
||||
@patch('minimax_api_client.requests.post')
|
||||
def test_default_timeout_is_120(self, mock_post):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"base_resp": {"status_code": 0}}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
mac.api_post("endpoint", {}, "key")
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs['timeout'] == 120
|
||||
|
||||
@patch('minimax_api_client.requests.post')
|
||||
def test_verbose_prints_url(self, mock_post, capsys):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"base_resp": {"status_code": 0}}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
mac.api_post("image_generation", {}, "key", verbose=True)
|
||||
captured = capsys.readouterr()
|
||||
assert "image_generation" in captured.err
|
||||
|
||||
|
||||
class TestApiGet:
|
||||
"""Test GET request handling."""
|
||||
|
||||
@patch('minimax_api_client.requests.get')
|
||||
def test_successful_get(self, mock_get):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"status": "Success", "file_id": "abc"}
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
result = mac.api_get("query/video_generation", {"task_id": "t1"}, "key")
|
||||
assert result["status"] == "Success"
|
||||
|
||||
@patch('minimax_api_client.requests.get')
|
||||
def test_get_http_error(self, mock_get):
|
||||
mock_resp = Mock()
|
||||
mock_resp.status_code = 500
|
||||
mock_resp.text = "Server Error"
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
with pytest.raises(Exception, match="HTTP 500"):
|
||||
mac.api_get("endpoint", {}, "key")
|
||||
|
||||
|
||||
class TestPollAsyncTask:
|
||||
"""Test async task polling."""
|
||||
|
||||
@patch('minimax_api_client.time.sleep')
|
||||
@patch('minimax_api_client.api_get')
|
||||
def test_poll_success_first_try(self, mock_get, mock_sleep):
|
||||
mock_get.return_value = {"status": "Success", "file_id": "f123"}
|
||||
|
||||
result = mac.poll_async_task("task1", "video_generation", "key")
|
||||
assert result["file_id"] == "f123"
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch('minimax_api_client.time.sleep')
|
||||
@patch('minimax_api_client.api_get')
|
||||
def test_poll_success_after_processing(self, mock_get, mock_sleep):
|
||||
mock_get.side_effect = [
|
||||
{"status": "Processing"},
|
||||
{"status": "Processing"},
|
||||
{"status": "Success", "file_id": "f456"}
|
||||
]
|
||||
|
||||
result = mac.poll_async_task("task2", "video_generation", "key",
|
||||
poll_interval=1)
|
||||
assert result["file_id"] == "f456"
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch('minimax_api_client.time.sleep')
|
||||
@patch('minimax_api_client.api_get')
|
||||
def test_poll_task_failed(self, mock_get, mock_sleep):
|
||||
mock_get.return_value = {"status": "Failed", "error": "bad input"}
|
||||
|
||||
with pytest.raises(Exception, match="Task failed"):
|
||||
mac.poll_async_task("task3", "video_generation", "key")
|
||||
|
||||
@patch('minimax_api_client.time.sleep')
|
||||
@patch('minimax_api_client.api_get')
|
||||
def test_poll_timeout(self, mock_get, mock_sleep):
|
||||
mock_get.return_value = {"status": "Processing"}
|
||||
|
||||
with pytest.raises(TimeoutError, match="timed out"):
|
||||
mac.poll_async_task("task4", "video_generation", "key",
|
||||
poll_interval=1, max_wait=3)
|
||||
|
||||
|
||||
class TestDownloadFile:
|
||||
"""Test file download."""
|
||||
|
||||
@patch('minimax_api_client.requests.get')
|
||||
@patch('minimax_api_client.api_get')
|
||||
def test_download_success(self, mock_api_get, mock_req_get, tmp_path):
|
||||
mock_api_get.return_value = {
|
||||
"file": {"download_url": "https://cdn.minimax.io/video.mp4"}
|
||||
}
|
||||
mock_resp = Mock()
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_resp.iter_content.return_value = [b"video_data"]
|
||||
mock_req_get.return_value = mock_resp
|
||||
|
||||
output = str(tmp_path / "test.mp4")
|
||||
result = mac.download_file("file123", "key", output)
|
||||
assert result == output
|
||||
assert Path(output).exists()
|
||||
|
||||
@patch('minimax_api_client.api_get')
|
||||
def test_download_no_url_raises(self, mock_api_get):
|
||||
mock_api_get.return_value = {"file": {}}
|
||||
|
||||
with pytest.raises(Exception, match="No download URL"):
|
||||
mac.download_file("file123", "key", "/tmp/test.mp4")
|
||||
|
||||
|
||||
class TestGetOutputDir:
|
||||
"""Test output directory resolution."""
|
||||
|
||||
def test_returns_path_object(self):
|
||||
result = mac.get_output_dir()
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_directory_exists(self):
|
||||
result = mac.get_output_dir()
|
||||
assert result.exists()
|
||||
assert result.is_dir()
|
||||
185
.opencode/skills/ai-multimodal/scripts/tests/test_minimax_cli.py
Normal file
185
.opencode/skills/ai-multimodal/scripts/tests/test_minimax_cli.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Tests for minimax_cli.py - CLI argument parsing and task dispatch.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import minimax_cli as cli
|
||||
|
||||
|
||||
class TestTaskDefaults:
|
||||
"""Test task-to-model default mapping."""
|
||||
|
||||
def test_generate_defaults_to_image_01(self):
|
||||
assert cli.TASK_DEFAULTS['generate'] == 'image-01'
|
||||
|
||||
def test_generate_video_defaults_to_hailuo(self):
|
||||
assert cli.TASK_DEFAULTS['generate-video'] == 'MiniMax-Hailuo-2.3'
|
||||
|
||||
def test_generate_speech_defaults_to_speech_28_hd(self):
|
||||
assert cli.TASK_DEFAULTS['generate-speech'] == 'speech-2.8-hd'
|
||||
|
||||
def test_generate_music_defaults_to_music_25(self):
|
||||
assert cli.TASK_DEFAULTS['generate-music'] == 'music-2.5'
|
||||
|
||||
|
||||
class TestPrintResult:
|
||||
"""Test result formatting."""
|
||||
|
||||
def test_success_image(self, capsys):
|
||||
result = {
|
||||
"status": "success",
|
||||
"generated_images": ["/path/to/img.png"],
|
||||
"model": "image-01"
|
||||
}
|
||||
cli.print_result(result, "generate")
|
||||
output = capsys.readouterr().out
|
||||
assert "success" in output.lower()
|
||||
assert "/path/to/img.png" in output
|
||||
assert "image-01" in output
|
||||
|
||||
def test_success_video(self, capsys):
|
||||
result = {
|
||||
"status": "success",
|
||||
"generated_video": "/path/to/vid.mp4",
|
||||
"generation_time": 45.2,
|
||||
"model": "MiniMax-Hailuo-2.3"
|
||||
}
|
||||
cli.print_result(result, "generate-video")
|
||||
output = capsys.readouterr().out
|
||||
assert "/path/to/vid.mp4" in output
|
||||
assert "45.2s" in output
|
||||
|
||||
def test_success_audio(self, capsys):
|
||||
result = {
|
||||
"status": "success",
|
||||
"generated_audio": "/path/to/audio.mp3",
|
||||
"duration_ms": 140000,
|
||||
"model": "music-2.5"
|
||||
}
|
||||
cli.print_result(result, "generate-music")
|
||||
output = capsys.readouterr().out
|
||||
assert "/path/to/audio.mp3" in output
|
||||
assert "140.0s" in output
|
||||
|
||||
def test_error_result(self, capsys):
|
||||
result = {"status": "error", "error": "Rate limit exceeded"}
|
||||
cli.print_result(result, "generate")
|
||||
output = capsys.readouterr().out
|
||||
assert "Rate limit exceeded" in output
|
||||
|
||||
def test_unknown_status(self, capsys):
|
||||
result = {"model": "image-01"}
|
||||
cli.print_result(result, "generate")
|
||||
output = capsys.readouterr().out
|
||||
assert "unknown" in output.lower()
|
||||
|
||||
|
||||
class TestMainCLI:
|
||||
"""Test CLI main() argument parsing and dispatch."""
|
||||
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value=None)
|
||||
def test_no_api_key_exits(self, mock_key, capsys):
|
||||
with patch('sys.argv', ['cli', '--task', 'generate', '--prompt', 'x']):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch('minimax_cli.generate_image')
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_generate_image_dispatch(self, mock_key, mock_gen):
|
||||
mock_gen.return_value = {"status": "success", "generated_images": [],
|
||||
"model": "image-01"}
|
||||
with patch('sys.argv', ['cli', '--task', 'generate',
|
||||
'--prompt', 'A cat']):
|
||||
cli.main()
|
||||
mock_gen.assert_called_once()
|
||||
args = mock_gen.call_args
|
||||
assert args[0][0] == 'test-key'
|
||||
assert args[0][1] == 'A cat'
|
||||
|
||||
@patch('minimax_cli.generate_speech')
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_generate_speech_dispatch(self, mock_key, mock_gen):
|
||||
mock_gen.return_value = {"status": "success",
|
||||
"generated_audio": "/x.mp3",
|
||||
"model": "speech-2.8-hd"}
|
||||
with patch('sys.argv', ['cli', '--task', 'generate-speech',
|
||||
'--text', 'Hello world']):
|
||||
cli.main()
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
@patch('minimax_cli.generate_speech')
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_speech_uses_text_or_prompt(self, mock_key, mock_gen):
|
||||
mock_gen.return_value = {"status": "success",
|
||||
"generated_audio": "/x.mp3",
|
||||
"model": "speech-2.8-hd"}
|
||||
# --prompt should work as fallback for --text
|
||||
with patch('sys.argv', ['cli', '--task', 'generate-speech',
|
||||
'--prompt', 'Fallback text']):
|
||||
cli.main()
|
||||
call_args = mock_gen.call_args
|
||||
assert call_args[0][1] == 'Fallback text'
|
||||
|
||||
@patch('minimax_cli.generate_music')
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_generate_music_dispatch(self, mock_key, mock_gen):
|
||||
mock_gen.return_value = {"status": "success",
|
||||
"generated_audio": "/x.mp3",
|
||||
"duration_ms": 60000,
|
||||
"model": "music-2.5"}
|
||||
with patch('sys.argv', ['cli', '--task', 'generate-music',
|
||||
'--lyrics', 'La la la']):
|
||||
cli.main()
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
@patch('minimax_cli.generate_video')
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_generate_video_dispatch(self, mock_key, mock_gen):
|
||||
mock_gen.return_value = {"status": "success",
|
||||
"generated_video": "/x.mp4",
|
||||
"generation_time": 30.0,
|
||||
"model": "MiniMax-Hailuo-2.3"}
|
||||
with patch('sys.argv', ['cli', '--task', 'generate-video',
|
||||
'--prompt', 'A dancer']):
|
||||
cli.main()
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_auto_model_detection(self, mock_key):
|
||||
with patch('sys.argv', ['cli', '--task', 'generate-speech',
|
||||
'--text', 'hi']):
|
||||
with patch('minimax_cli.generate_speech') as mock_gen:
|
||||
mock_gen.return_value = {"status": "success",
|
||||
"generated_audio": "/x.mp3",
|
||||
"model": "speech-2.8-hd"}
|
||||
cli.main()
|
||||
# Model should be auto-detected
|
||||
assert mock_gen.call_args[0][2] == 'speech-2.8-hd'
|
||||
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_explicit_model_override(self, mock_key):
|
||||
with patch('sys.argv', ['cli', '--task', 'generate-speech',
|
||||
'--text', 'hi', '--model', 'speech-2.8-turbo']):
|
||||
with patch('minimax_cli.generate_speech') as mock_gen:
|
||||
mock_gen.return_value = {"status": "success",
|
||||
"generated_audio": "/x.mp3",
|
||||
"model": "speech-2.8-turbo"}
|
||||
cli.main()
|
||||
assert mock_gen.call_args[0][2] == 'speech-2.8-turbo'
|
||||
|
||||
@patch('minimax_cli.generate_image')
|
||||
@patch('minimax_cli.find_minimax_api_key', return_value='test-key')
|
||||
def test_exception_exits_with_1(self, mock_key, mock_gen):
|
||||
mock_gen.side_effect = Exception("API timeout")
|
||||
with patch('sys.argv', ['cli', '--task', 'generate',
|
||||
'--prompt', 'test']):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main()
|
||||
assert exc_info.value.code == 1
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Tests for minimax_generate.py - generation functions for image, video, speech, music.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock, call
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import minimax_generate as mg
|
||||
|
||||
|
||||
class TestModelRegistries:
|
||||
"""Test model set definitions."""
|
||||
|
||||
def test_image_models(self):
|
||||
assert 'image-01' in mg.MINIMAX_IMAGE_MODELS
|
||||
assert 'image-01-live' in mg.MINIMAX_IMAGE_MODELS
|
||||
|
||||
def test_video_models(self):
|
||||
assert 'MiniMax-Hailuo-2.3' in mg.MINIMAX_VIDEO_MODELS
|
||||
assert 'MiniMax-Hailuo-2.3-Fast' in mg.MINIMAX_VIDEO_MODELS
|
||||
assert 'S2V-01' in mg.MINIMAX_VIDEO_MODELS
|
||||
|
||||
def test_speech_models(self):
|
||||
assert 'speech-2.8-hd' in mg.MINIMAX_SPEECH_MODELS
|
||||
assert 'speech-2.8-turbo' in mg.MINIMAX_SPEECH_MODELS
|
||||
|
||||
def test_music_models(self):
|
||||
assert 'music-2.5' in mg.MINIMAX_MUSIC_MODELS
|
||||
|
||||
def test_all_models_is_union(self):
|
||||
expected = (mg.MINIMAX_IMAGE_MODELS | mg.MINIMAX_VIDEO_MODELS |
|
||||
mg.MINIMAX_SPEECH_MODELS | mg.MINIMAX_MUSIC_MODELS)
|
||||
assert mg.ALL_MINIMAX_MODELS == expected
|
||||
|
||||
|
||||
class TestIsMinimaxModel:
|
||||
"""Test model detection."""
|
||||
|
||||
def test_known_image_model(self):
|
||||
assert mg.is_minimax_model('image-01') is True
|
||||
|
||||
def test_known_video_model(self):
|
||||
assert mg.is_minimax_model('MiniMax-Hailuo-2.3') is True
|
||||
|
||||
def test_known_speech_model(self):
|
||||
assert mg.is_minimax_model('speech-2.8-hd') is True
|
||||
|
||||
def test_known_music_model(self):
|
||||
assert mg.is_minimax_model('music-2.5') is True
|
||||
|
||||
def test_prefix_minimax(self):
|
||||
assert mg.is_minimax_model('MiniMax-Future-Model') is True
|
||||
|
||||
def test_prefix_speech(self):
|
||||
assert mg.is_minimax_model('speech-3.0-ultra') is True
|
||||
|
||||
def test_prefix_s2v(self):
|
||||
assert mg.is_minimax_model('S2V-02') is True
|
||||
|
||||
def test_non_minimax_model(self):
|
||||
assert mg.is_minimax_model('gemini-2.5-flash') is False
|
||||
|
||||
def test_non_minimax_imagen(self):
|
||||
assert mg.is_minimax_model('imagen-4.0-generate-001') is False
|
||||
|
||||
|
||||
class TestGenerateImage:
|
||||
"""Test image generation."""
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_success(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {
|
||||
"data": {"image_urls": ["https://cdn.minimax.io/img1.png"]}
|
||||
}
|
||||
|
||||
with patch('requests.get') as mock_req_get:
|
||||
mock_resp = Mock()
|
||||
mock_resp.content = b'\x89PNG\r\n\x1a\n'
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_req_get.return_value = mock_resp
|
||||
|
||||
result = mg.generate_image("key", "A cat", "image-01")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert len(result["generated_images"]) == 1
|
||||
assert result["model"] == "image-01"
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_no_images_returns_error(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {"data": {"image_urls": []}}
|
||||
|
||||
result = mg.generate_image("key", "A cat", "image-01")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_payload_structure(self, mock_post):
|
||||
mock_post.return_value = {"data": {"image_urls": []}}
|
||||
|
||||
mg.generate_image("key", "A dog", "image-01", "16:9", 3)
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert payload["model"] == "image-01"
|
||||
assert payload["prompt"] == "A dog"
|
||||
assert payload["aspect_ratio"] == "16:9"
|
||||
assert payload["n"] == 3
|
||||
assert payload["response_format"] == "url"
|
||||
assert payload["prompt_optimizer"] is True
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_num_images_capped_at_9(self, mock_post):
|
||||
mock_post.return_value = {"data": {"image_urls": []}}
|
||||
|
||||
mg.generate_image("key", "test", "image-01", num_images=15)
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert payload["n"] == 9
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_output_copy(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {
|
||||
"data": {"image_urls": ["https://cdn.minimax.io/img.png"]}
|
||||
}
|
||||
|
||||
with patch('requests.get') as mock_req_get:
|
||||
mock_resp = Mock()
|
||||
mock_resp.content = b'image_bytes'
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_req_get.return_value = mock_resp
|
||||
|
||||
output_path = str(tmp_path / "custom_output.png")
|
||||
result = mg.generate_image("key", "test", output=output_path)
|
||||
|
||||
assert Path(output_path).exists()
|
||||
|
||||
|
||||
class TestGenerateVideo:
|
||||
"""Test video generation (async workflow)."""
|
||||
|
||||
@patch('minimax_generate.download_file')
|
||||
@patch('minimax_generate.poll_async_task')
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_success(self, mock_post, mock_dir, mock_poll, mock_dl, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {"task_id": "vid-task-123"}
|
||||
mock_poll.return_value = {"file_id": "file-456"}
|
||||
# Create a fake video file so stat() works
|
||||
mock_dl.side_effect = lambda fid, key, path, v: (
|
||||
Path(path).write_bytes(b'fake_video') or path
|
||||
)
|
||||
|
||||
result = mg.generate_video("key", "A dancer")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert "generated_video" in result
|
||||
assert result["model"] == "MiniMax-Hailuo-2.3"
|
||||
mock_poll.assert_called_once()
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_no_task_id_error(self, mock_post):
|
||||
mock_post.return_value = {"error": "bad request"}
|
||||
|
||||
result = mg.generate_video("key", "test")
|
||||
assert result["status"] == "error"
|
||||
assert "No task_id" in result["error"]
|
||||
|
||||
@patch('minimax_generate.poll_async_task')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_no_file_id_error(self, mock_post, mock_poll):
|
||||
mock_post.return_value = {"task_id": "t1"}
|
||||
mock_poll.return_value = {"status": "Success"}
|
||||
|
||||
result = mg.generate_video("key", "test")
|
||||
assert result["status"] == "error"
|
||||
assert "No file_id" in result["error"]
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_payload_with_first_frame(self, mock_post):
|
||||
mock_post.return_value = {"task_id": None}
|
||||
|
||||
mg.generate_video("key", "test", first_frame="https://img.url/frame.png")
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert payload["first_frame_image"] == "https://img.url/frame.png"
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_payload_duration_resolution(self, mock_post):
|
||||
mock_post.return_value = {"task_id": None}
|
||||
|
||||
mg.generate_video("key", "test", duration=10, resolution="720P")
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert payload["duration"] == 10
|
||||
assert payload["resolution"] == "720P"
|
||||
|
||||
|
||||
class TestGenerateSpeech:
|
||||
"""Test speech/TTS generation."""
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_success(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
# hex-encoded audio bytes
|
||||
mock_post.return_value = {
|
||||
"data": {"audio": "48656c6c6f"} # "Hello" in hex
|
||||
}
|
||||
|
||||
result = mg.generate_speech("key", "Hello world")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert "generated_audio" in result
|
||||
assert result["model"] == "speech-2.8-hd"
|
||||
# Verify file was written
|
||||
audio_path = Path(result["generated_audio"])
|
||||
assert audio_path.exists()
|
||||
assert audio_path.read_bytes() == bytes.fromhex("48656c6c6f")
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_no_audio_returns_error(self, mock_post):
|
||||
mock_post.return_value = {"data": {}}
|
||||
|
||||
result = mg.generate_speech("key", "test")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_payload_structure(self, mock_post):
|
||||
mock_post.return_value = {"data": {}}
|
||||
|
||||
mg.generate_speech("key", "Test text", "speech-2.8-turbo",
|
||||
voice="English_Warm_Bestie", emotion="happy",
|
||||
output_format="wav", rate=1.5)
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert payload["model"] == "speech-2.8-turbo"
|
||||
assert payload["text"] == "Test text"
|
||||
assert payload["stream"] is False
|
||||
assert payload["output_format"] == "hex"
|
||||
assert payload["voice_setting"]["voice_id"] == "English_Warm_Bestie"
|
||||
assert payload["voice_setting"]["speed"] == 1.5
|
||||
assert payload["audio_setting"]["format"] == "wav"
|
||||
assert payload["audio_setting"]["sample_rate"] == 32000
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_text_truncated_at_10000(self, mock_post):
|
||||
mock_post.return_value = {"data": {}}
|
||||
long_text = "x" * 15000
|
||||
|
||||
mg.generate_speech("key", long_text)
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert len(payload["text"]) == 10000
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_uses_t2a_v2_endpoint(self, mock_post):
|
||||
mock_post.return_value = {"data": {}}
|
||||
|
||||
mg.generate_speech("key", "test")
|
||||
|
||||
endpoint = mock_post.call_args[0][0]
|
||||
assert endpoint == "t2a_v2"
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_wav_extension(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {"data": {"audio": "aabb"}}
|
||||
|
||||
result = mg.generate_speech("key", "test", output_format="wav")
|
||||
assert result["generated_audio"].endswith(".wav")
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_pcm_defaults_to_mp3_ext(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {"data": {"audio": "aabb"}}
|
||||
|
||||
result = mg.generate_speech("key", "test", output_format="pcm")
|
||||
assert result["generated_audio"].endswith(".mp3")
|
||||
|
||||
|
||||
class TestGenerateMusic:
|
||||
"""Test music generation."""
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_success_with_url(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {
|
||||
"data": {"audio": "https://cdn.minimax.io/music.mp3"},
|
||||
"extra_info": {"music_duration": 120000}
|
||||
}
|
||||
|
||||
with patch('requests.get') as mock_req_get:
|
||||
mock_resp = Mock()
|
||||
mock_resp.content = b'music_data'
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_req_get.return_value = mock_resp
|
||||
|
||||
result = mg.generate_music("key", lyrics="La la la",
|
||||
prompt="pop")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["duration_ms"] == 120000
|
||||
assert result["model"] == "music-2.5"
|
||||
|
||||
@patch('minimax_generate.get_output_dir')
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_success_with_hex(self, mock_post, mock_dir, tmp_path):
|
||||
mock_dir.return_value = tmp_path
|
||||
mock_post.return_value = {
|
||||
"data": {"audio": "deadbeef"},
|
||||
"extra_info": {"music_duration": 60000}
|
||||
}
|
||||
|
||||
result = mg.generate_music("key", lyrics="test")
|
||||
|
||||
assert result["status"] == "success"
|
||||
audio_path = Path(result["generated_audio"])
|
||||
assert audio_path.read_bytes() == bytes.fromhex("deadbeef")
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_no_audio_returns_error(self, mock_post):
|
||||
mock_post.return_value = {"data": {}, "extra_info": {}}
|
||||
|
||||
result = mg.generate_music("key", lyrics="test")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_payload_structure(self, mock_post):
|
||||
mock_post.return_value = {"data": {}, "extra_info": {}}
|
||||
|
||||
mg.generate_music("key", lyrics="Verse 1\nHello",
|
||||
prompt="upbeat pop", model="music-2.5",
|
||||
output_format="wav")
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert payload["model"] == "music-2.5"
|
||||
assert payload["lyrics"] == "Verse 1\nHello"
|
||||
assert payload["prompt"] == "upbeat pop"
|
||||
assert payload["output_format"] == "url"
|
||||
assert payload["audio_setting"]["format"] == "wav"
|
||||
assert payload["audio_setting"]["sample_rate"] == 44100
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_lyrics_truncated_at_3500(self, mock_post):
|
||||
mock_post.return_value = {"data": {}, "extra_info": {}}
|
||||
|
||||
mg.generate_music("key", lyrics="x" * 5000)
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert len(payload["lyrics"]) == 3500
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_prompt_truncated_at_2000(self, mock_post):
|
||||
mock_post.return_value = {"data": {}, "extra_info": {}}
|
||||
|
||||
mg.generate_music("key", prompt="y" * 3000)
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert len(payload["prompt"]) == 2000
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_uses_300s_timeout(self, mock_post):
|
||||
mock_post.return_value = {"data": {}, "extra_info": {}}
|
||||
|
||||
mg.generate_music("key", lyrics="test")
|
||||
|
||||
# Check timeout kwarg passed to api_post
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs.get('timeout') == 300
|
||||
|
||||
@patch('minimax_generate.api_post')
|
||||
def test_empty_lyrics_omitted(self, mock_post):
|
||||
mock_post.return_value = {"data": {}, "extra_info": {}}
|
||||
|
||||
mg.generate_music("key", lyrics="", prompt="jazz")
|
||||
|
||||
payload = mock_post.call_args[0][1]
|
||||
assert "lyrics" not in payload
|
||||
assert payload["prompt"] == "jazz"
|
||||
Reference in New Issue
Block a user