diff --git a/apps/api/src/opendirector_api/localization.py b/apps/api/src/opendirector_api/localization.py index 0586213f..c5a4cbf6 100644 --- a/apps/api/src/opendirector_api/localization.py +++ b/apps/api/src/opendirector_api/localization.py @@ -47,6 +47,9 @@ "moss_speech_voice": "Enter a Voice ID available to your MOSS account.", "moss_speech_reference": "MOSS speech generation does not accept references. Enter speech text and a Voice ID.", "moss_prompt_limit": "MOSS requires a nonempty prompt of at most 4,000 characters, including text references.", + "minimax_image_reference": "MiniMax H3 references must be PNG, JPEG, or WebP, with edges of 128–8192 pixels and a long-to-short edge ratio of at most 4:1.", + "minimax_image_count": "MiniMax H3 image editing accepts up to 16 reference images.", + "minimax_image_bytes": "MiniMax H3 image reference requests cannot exceed 64 MiB.", "moss_image_size": "MOSS image dimensions must be 256–2048, multiples of 16, and no more than 2,097,152 pixels in total.", "moss_image_reference": "MOSS image generation does not accept image references or editing inputs.", "moss_layout_reference": "MOSS layout JSON does not accept additional text references. Put the requirements in the JSON or remove the text references.", diff --git a/packages/core/src/opendirector/application/creation_generation.py b/packages/core/src/opendirector/application/creation_generation.py index 6baa9778..6f35659f 100644 --- a/packages/core/src/opendirector/application/creation_generation.py +++ b/packages/core/src/opendirector/application/creation_generation.py @@ -224,7 +224,7 @@ def _execution(kind, parameters, config): if kind == 'image': profile = input_rules(config)['profile'] return dict(kind=kind, size=parameters['size'], - responseFormat='b64_json' if profile in {'vllm-omni-image', 'vllm-omni-qwen-image'} else 'url' if config['protocol'] == 'volcengine' or profile == 'moss-image' else None, + responseFormat='b64_json' if profile in {'vllm-omni-image', 'vllm-omni-qwen-image'} else 'url' if config['protocol'] == 'volcengine' or profile in {'moss-image', 'minimax-h3-image'} else None, outputFormat='png', watermark=parameters.get('watermark'), numInferenceSteps=parameters.get('num_inference_steps'), guidanceScale=parameters.get('guidance_scale')) if kind == 'audio' and parameters.get('creation_type') == 'music': diff --git a/packages/core/src/opendirector/domain/creation/commands.py b/packages/core/src/opendirector/domain/creation/commands.py index 7ca6b9a4..d712a054 100644 --- a/packages/core/src/opendirector/domain/creation/commands.py +++ b/packages/core/src/opendirector/domain/creation/commands.py @@ -401,6 +401,7 @@ def command_fingerprint(command: Command) -> str: 'capability_unsupported', 'preview_expired', 'storage_unavailable', 'submission_unknown', 'provider_error', 'moss_speech_text', 'moss_speech_voice', 'moss_speech_reference', 'moss_prompt_limit', 'moss_image_size', 'moss_image_reference', 'moss_layout_reference', + 'minimax_image_reference', 'minimax_image_count', 'minimax_image_bytes', 'voice_library_unsupported', 'voice_connection_changed', 'voice_reference_required', 'voice_transcript_required', 'voice_transcript_too_long', 'voice_description_too_long', 'voice_not_found', 'voice_delete_unsupported', diff --git a/packages/core/src/opendirector/domain/image_output.py b/packages/core/src/opendirector/domain/image_output.py index 5d978ee6..bd96ed50 100644 --- a/packages/core/src/opendirector/domain/image_output.py +++ b/packages/core/src/opendirector/domain/image_output.py @@ -8,6 +8,12 @@ def image_outputs(config, profile): + if profile == 'minimax-h3-image': + return [dict(aspectRatio=ratio, size=size) for ratio, size in [ + ('1:1', '2048x2048'), ('4:3', '2368x1760'), ('3:4', '1760x2368'), + ('16:9', '2720x1536'), ('9:16', '1536x2720'), ('3:2', '2496x1664'), + ('2:3', '1664x2496'), ('21:9', '3136x1344'), ('9:21', '1344x3136'), + ]] if config.get('protocol') == 'volcengine': # This adapter only declares the existing automatic 2K request. Do not # promise a ratio without an explicit output contract for that service. diff --git a/packages/core/src/opendirector/domain/input_rules.py b/packages/core/src/opendirector/domain/input_rules.py index 3ffbcf22..80532a38 100644 --- a/packages/core/src/opendirector/domain/input_rules.py +++ b/packages/core/src/opendirector/domain/input_rules.py @@ -18,6 +18,12 @@ def _adapter_rules(config): 'maxAudios': 0, 'imageBytes': 8 * 1024 * 1024, 'requestBytes': 48 * 1024 * 1024, 'videoModes': ['first_frame'] if kind == 'video' else []} rules.update(maxVideos=0, videoBytes=DEFAULT_VIDEO_BYTES) + if (kind == 'image' and config.get('protocol') == 'openai' + and url.scheme == 'https' and host == 'api.minimax.cn' and url.port in {None, 443} + and path == '/v1' and model in {'MiniMax-H3-Image', 'MiniMax-H3-Image-Highspeed'}): + rules.update(profile='minimax-h3-image', maxImages=16, + imageBytes=64 * 1024 * 1024, requestBytes=64 * 1024 * 1024) + return rules if is_moss_image(config): rules.update(profile='moss-image', allowedKinds=['text'], maxImages=0, maxPromptCharacters=MOSS_IMAGE_PROMPT_CHARACTERS) diff --git a/packages/core/src/opendirector/domain/minimax_image.py b/packages/core/src/opendirector/domain/minimax_image.py new file mode 100644 index 00000000..100c2921 --- /dev/null +++ b/packages/core/src/opendirector/domain/minimax_image.py @@ -0,0 +1,9 @@ +from .projects import ProjectError + + +def validate_minimax_image_reference(mime, width, height): + if (mime not in {'image/png', 'image/jpeg', 'image/webp'} or not width or not height + or not 128 <= min(width, height) <= max(width, height) <= 8192 + or max(width, height) > 4 * min(width, height)): + raise ProjectError('MiniMax H3 图片参考须为 PNG、JPEG 或 WebP,边长 128–8192 像素,长短边之比不超过 4:1。', + 422, code='minimax_image_reference') diff --git a/packages/core/src/opendirector/integrations/model_adapters.py b/packages/core/src/opendirector/integrations/model_adapters.py index a95d4ba2..a78204ab 100644 --- a/packages/core/src/opendirector/integrations/model_adapters.py +++ b/packages/core/src/opendirector/integrations/model_adapters.py @@ -7,24 +7,27 @@ import asyncio import base64 +import io import json import math import re from urllib.parse import quote, urlsplit import httpx +from PIL import Image from opendirector.domain.assets import MAX_ASSET_BYTES from opendirector.domain.capabilities import (ChatRequest, CutoutRequest, DepthRequest, EditMask, ImageRequest, MediaResult, MusicRequest, ScoreRequest, Segment, SegmentRequest, SpeechRequest, TrackRequest, Tracks, VideoEditRequest, VideoRequest) from opendirector.domain.input_rules import input_rules +from opendirector.domain.minimax_image import validate_minimax_image_reference from opendirector.domain.moss_image import validate_moss_image_prompt, validate_moss_image_size from opendirector.domain.moss_speech import validate_moss_speech_input from opendirector.domain.projects import ProjectError from opendirector.integrations.generation_errors import checked, protocol_failure, provider_failure, transport_failure from opendirector.integrations.http_settings import MODEL_HTTP_HOOKS, auth_headers, trust_environment -from opendirector.integrations.reference_inputs import data_uri, image_type +from opendirector.integrations.reference_inputs import data_uri, image_type, request_size from opendirector.integrations.volcengine import VolcengineGeneration, bounded, endpoint from opendirector.media import processing as media from opendirector.media.async_work import blocking @@ -74,7 +77,7 @@ def _decoded(data): raise protocol_failure('submit') -def _moss_result_url(value, stage): +def _result_url(value, stage): if not isinstance(value, str) or len(value) > 16384: raise protocol_failure(stage) url = urlsplit(value) @@ -86,7 +89,7 @@ def _moss_result_url(value, stage): async def _download_moss_result(adapter, config, url): async def read(): - _moss_result_url(url, 'download') + _result_url(url, 'download') base, target = urlsplit(config['url']), urlsplit(url) headers = auth_headers(config) if (base.scheme, base.netloc) == (target.scheme, target.netloc) else {} async with httpx.AsyncClient(event_hooks=MODEL_HTTP_HOOKS, transport=adapter.transport, @@ -163,7 +166,7 @@ async def submit(): results = value['data'] if not isinstance(results, list) or len(results) != 1: raise protocol_failure('submit') - return _moss_result_url(results[0]['url'], 'submit') + return _result_url(results[0]['url'], 'submit') url = await _call('submit', submit) if result_ready: @@ -195,7 +198,7 @@ async def submit(): raise provider_failure(value, stage='submit', http_status=200, secrets=secrets) if value.get('object', 'audio.speech') != 'audio.speech' or value.get('response_format', 'mp3') != 'mp3': raise protocol_failure('submit') - return _moss_result_url(value['url'], 'submit') + return _result_url(value['url'], 'submit') url = await _call('submit', submit) if result_ready: @@ -210,6 +213,66 @@ async def download(self, config, url): return data +class MinimaxH3Images(ModelAdapter): + capabilities = frozenset({'image.generate', 'image.edit'}) + max_images = 16 + + async def image(self, config, request: ImageRequest) -> MediaResult: + images = [*([request.image] if request.image is not None else []), *request.references] + if len(images) > self.max_images: + raise ProjectError('MiniMax H3 图片编辑最多接受 16 张参考图。', 422, code='minimax_image_count') + encoded = [] + for data in images: + mime = image_type(data) + try: + with Image.open(io.BytesIO(data)) as image: + dimensions = image.size + except (OSError, ValueError, Image.DecompressionBombError): + dimensions = (None, None) + validate_minimax_image_reference(mime, *dimensions) + if len(data) > 64 * 1024 * 1024: + raise ProjectError('MiniMax H3 图片参考请求超过 64 MiB。', 413, code='minimax_image_bytes') + encoded.append(data_uri(data, mime)) + width, height = request.width, request.height + ratio = min(3, max(1 / 3, width / height)) + scale = 2048 / math.sqrt(ratio) + width, height = round(scale * ratio), round(scale) + size = f'{min(width, 3 * height)}x{min(height, 3 * width)}' + return await self.generate(config, request.prompt, size, encoded) + + async def generate(self, config, prompt, size, images, *, result_ready=None): + payload = {'model': config['modelId'], 'prompt': prompt, 'size': size, 'n': 1, + 'output_format': 'png', 'response_format': 'url'} + route = '/images/generations' + if images: + route = '/images/edits' + payload['images'] = [{'image_url': image} for image in images] + request_size(payload, 64 * 1024 * 1024) + + async def submit(): + async with _client(self, config, timeout=600) as client: + secrets = (config.get('apiKey', ''),) + async with client.stream('POST', endpoint(config['url'], route), + headers=auth_headers(config), json=payload) as response: + value = json.loads(await bounded(response, stage='submit', secrets=secrets, limit=1024 * 1024)) + result = value['data'][0] + if result.get('error'): + raise provider_failure(result, stage='submit', http_status=200, secrets=secrets) + return _result_url(result['url'], 'submit') + + url = await _call('submit', submit) + if result_ready: + await result_ready('png', url) + return await self.download(config, url) + + async def download(self, config, url): + _, data = await VolcengineGeneration(None, transport=self.transport).resume_result( + config, 'png', url, public_only=True) + if not data.startswith(b'\x89PNG\r\n\x1a\n'): + raise protocol_failure('download') + return MediaResult(data, 'png') + + class OpenAIImages(ModelAdapter): """OpenAI Images API: /images/generations and multipart /images/edits. @@ -670,6 +733,8 @@ def adapter_for(config, transport=None, poll_interval=3) -> ModelAdapter | None: return VllmOmniScore(transport, poll_interval) if kind == 'agent': return OpenAIChat(transport, poll_interval) + if profile == 'minimax-h3-image': + return MinimaxH3Images(transport, poll_interval) if profile == 'moss-image': return MossImages(transport, poll_interval) if profile == 'moss-speech': diff --git a/packages/core/src/opendirector/integrations/providers.py b/packages/core/src/opendirector/integrations/providers.py index b6385dd5..3c96dd02 100644 --- a/packages/core/src/opendirector/integrations/providers.py +++ b/packages/core/src/opendirector/integrations/providers.py @@ -18,7 +18,7 @@ from opendirector.integrations.kling_video import KlingVideoGeneration from opendirector.integrations.vllm_omni import VllmOmniGeneration from opendirector.integrations.vllm_omni_image import VllmOmniImageGeneration -from opendirector.integrations.model_adapters import MossImages, MossSpeech, OpenAISpeech, VllmOmniMusic, VllmOmniScore +from opendirector.integrations.model_adapters import MinimaxH3Images, MossImages, MossSpeech, OpenAISpeech, VllmOmniMusic, VllmOmniScore from opendirector.domain.capabilities import ImageRequest, MusicRequest, ScoreRequest, SpeechRequest from opendirector.integrations.model_adapters import OpenAISpeech, VllmOmniMusic, VllmOmniScore, image_parts from opendirector.domain.capabilities import MusicRequest, ScoreRequest, SpeechRequest @@ -34,6 +34,7 @@ def __init__(self, store, transport=None, poll_interval=3, retry_delays=(0.5, 1. self.kling_video = KlingVideoGeneration(store, transport, poll_interval, retry_delays) self.vllm_omni = VllmOmniGeneration(store, transport, poll_interval) self.vllm_omni_image = VllmOmniImageGeneration(store, transport, poll_interval) + self.minimax_image = MinimaxH3Images(transport, poll_interval) self.moss_image = MossImages(transport, poll_interval) self.moss_speech = MossSpeech(transport, poll_interval) @@ -85,6 +86,9 @@ async def resume_result(self, config, suffix, url): adapter = self._profile_adapter(config) if adapter: return await adapter.resume_result(config, suffix, url) + if config and input_rules(config)['profile'] == 'minimax-h3-image': + result = await self.minimax_image.download(config, url) + return result.suffix, result.data if config and input_rules(config)['profile'] == 'moss-image': return suffix, await self.moss_image.download(config, url) if config and input_rules(config)['profile'] == 'moss-speech': @@ -111,6 +115,11 @@ async def generate(self, config, task, submitted, *, result_ready=None): adapter = self._profile_adapter(config) if adapter and task['kind'] != 'text': return await adapter.generate(config, task, submitted, result_ready=result_ready) + if input_rules(config)['profile'] == 'minimax-h3-image': + prepared = await asyncio.to_thread(generation_references, self.store, task, config) + result = await self.minimax_image.generate(config, prepared['prompt'], task['parameters']['size'], + prepared['images'], result_ready=result_ready) + return result.suffix, result.data if input_rules(config)['profile'] == 'moss-image': prepared = await asyncio.to_thread(generation_references, self.store, task, config) width, height = map(int, task['parameters']['size'].split('x')) diff --git a/packages/core/src/opendirector/integrations/reference_inputs.py b/packages/core/src/opendirector/integrations/reference_inputs.py index f1a9d102..9914a035 100644 --- a/packages/core/src/opendirector/integrations/reference_inputs.py +++ b/packages/core/src/opendirector/integrations/reference_inputs.py @@ -8,6 +8,7 @@ FASTH3_MEDIA_REFERENCE_LIMIT, REFERENCE_MEDIA_SECONDS, TEXT_REFERENCE_BYTES, ) from opendirector.domain.projects import ProjectError +from opendirector.domain.minimax_image import validate_minimax_image_reference from opendirector.domain.references import project_content @@ -156,6 +157,8 @@ def generation_references(store, task, config, *, complete=True, overrides=None) raise ProjectError(f'“{meta["name"]}”不符合视频参考图片尺寸要求:边长 300—6000 像素,宽高比 0.4—2.5。') if rules['profile'] == 'seedream-5.0-pro' and (width is None or height is None or min(width, height) <= 14 or not 196 <= width * height <= 36000000 or not 1 / 16 <= width / height <= 16): raise ProjectError(f'“{meta["name"]}”不符合当前图片模型的参考尺寸要求。') + if rules['profile'] == 'minimax-h3-image': + validate_minimax_image_reference(mime, width, height) role = 'reference_image' if kind == 'video' and mode == 'first_frame': # Frame slots are per-slot: one image each, and the slot the diff --git a/src/canvas/providerPresets.ts b/src/canvas/providerPresets.ts index 4845cc42..99974cc9 100644 --- a/src/canvas/providerPresets.ts +++ b/src/canvas/providerPresets.ts @@ -7,6 +7,7 @@ const EN_NAMES: Record = { "OpenAI 兼容": "OpenAI compatible", "MiniMax 官方视频": "MiniMax official video", "可灵官方视频": "Kling official video", + "MiniMax 官方图片": "MiniMax Official Images", "MOSS 开放平台": "MOSS Open Platform", "火山方舟 / Agent Plan": "Volcano Ark / Agent Plan", "vLLM-Omni 图片接口": "vLLM-Omni image API", @@ -84,6 +85,17 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ connectionType: "api", authMode: "bearer", }, + { + id: "minimax-image", + logoOrganization: "MiniMaxAI", + name: "MiniMax 官方图片", + kinds: ["image"], + url: "https://api.minimax.cn/v1", + protocol: "openai", + connectionType: "api", + authMode: "bearer", + modelsByKind: { image: ["MiniMax-H3-Image", "MiniMax-H3-Image-Highspeed"] }, + }, { id: "minimax-video", logoOrganization: "MiniMaxAI", diff --git a/src/contracts/creation.schema.json b/src/contracts/creation.schema.json index ae80a918..5ff5a4d7 100644 --- a/src/contracts/creation.schema.json +++ b/src/contracts/creation.schema.json @@ -3484,6 +3484,9 @@ "moss_image_size", "moss_image_reference", "moss_layout_reference", + "minimax_image_reference", + "minimax_image_count", + "minimax_image_bytes", "voice_library_unsupported", "voice_connection_changed", "voice_reference_required", @@ -18647,6 +18650,9 @@ "moss_image_size", "moss_image_reference", "moss_layout_reference", + "minimax_image_reference", + "minimax_image_count", + "minimax_image_bytes", "voice_library_unsupported", "voice_connection_changed", "voice_reference_required", diff --git a/src/contracts/generated.ts b/src/contracts/generated.ts index c42ea178..ebf5b9c8 100644 --- a/src/contracts/generated.ts +++ b/src/contracts/generated.ts @@ -387,7 +387,7 @@ export interface ContractInput { } export interface ContractIssueInput extends ContractInput { - readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; + readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "minimax_image_reference" | "minimax_image_count" | "minimax_image_bytes" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; readonly message: string; readonly objectId?: string | null; readonly field?: string | null; @@ -1959,7 +1959,7 @@ export interface Contract { } export interface ContractIssue extends Contract { - readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; + readonly code: "timeline_package_invalid" | "timeline_package_too_large" | "timeline_revision_missing" | "timeline_import_missing" | "timeline_import_conflict" | "timeline_registration_rejected" | "timeline_font_too_large" | "timeline_font_unavailable" | "timeline_font_unsupported" | "timeline_font_invalid" | "timeline_font_fingerprint" | "timeline_font_coverage" | "timeline_font_total_bytes" | "timeline_font_unverified" | "timeline_font_mime" | "timeline_bundled_font_fingerprint" | "timeline_bundled_font_unavailable" | "invalid_input" | "revision_conflict" | "role_restricted" | "missing_object" | "reference_invalid" | "kling_model" | "kling_mode" | "kling_image" | "kling_frames" | "kling_prompt" | "kling_aspect" | "kling_output" | "kling_cancel" | "capability_unsupported" | "preview_expired" | "storage_unavailable" | "submission_unknown" | "provider_error" | "moss_speech_text" | "moss_speech_voice" | "moss_speech_reference" | "moss_prompt_limit" | "moss_image_size" | "moss_image_reference" | "moss_layout_reference" | "minimax_image_reference" | "minimax_image_count" | "minimax_image_bytes" | "voice_library_unsupported" | "voice_connection_changed" | "voice_reference_required" | "voice_transcript_required" | "voice_transcript_too_long" | "voice_description_too_long" | "voice_not_found" | "voice_delete_unsupported" | "voice_consent_required" | "voice_name_invalid" | "voice_audio_invalid" | "voice_format" | "voice_size" | "voice_duration" | "voice_provider_protocol" | "voice_provider_error" | "voice_provider_timeout" | "voice_model_unsupported"; readonly message: string; readonly objectId: string | null; readonly field: string | null; diff --git a/tests/providerPresets.test.ts b/tests/providerPresets.test.ts index 77c00b4a..7f070f34 100644 --- a/tests/providerPresets.test.ts +++ b/tests/providerPresets.test.ts @@ -116,3 +116,20 @@ test("Kling Flash descriptions distinguish first-frame capabilities in both lang assert.doesNotMatch(modelDescription(model)!, /尾帧/); } finally { setActiveLocale(previous); } }); + +test("MiniMax H3 image preset exposes both models in both display languages", () => { + const previous = getActiveLocale(); + try { + const preset = PROVIDER_PRESETS.find((item) => item.id === "minimax-image")!; + assert.equal(preset.url, "https://api.minimax.cn/v1"); + assert.deepEqual(preset.modelsByKind?.image, ["MiniMax-H3-Image", "MiniMax-H3-Image-Highspeed"]); + for (const [locale, name] of [ + ["zh-CN", "MiniMax 官方图片"], ["en-US", "MiniMax Official Images"], + ] as const) { + setActiveLocale(locale); + assert.equal(providerOptions("image").find((item) => item.id === preset.id)?.name, name); + } + } finally { + setActiveLocale(previous); + } +}); diff --git a/tests/test_minimax_image.py b/tests/test_minimax_image.py new file mode 100644 index 00000000..1fa62cb0 --- /dev/null +++ b/tests/test_minimax_image.py @@ -0,0 +1,90 @@ +import asyncio +import base64 +import io +import json + +import httpx +import pytest +from PIL import Image + +from opendirector.domain.capabilities import ImageRequest +from opendirector.domain.generation import GenerationError +from opendirector.infrastructure.model_configs import ModelConfigs +from opendirector.integrations.model_adapters import adapter_for +from opendirector.integrations.providers import GenerationProviders + +URL = 'https://api.minimax.cn/v1' +RESULT = 'https://93.184.215.14/output.png' +MODELS = ('MiniMax-H3-Image', 'MiniMax-H3-Image-Highspeed') + + +def png(color): + buffer = io.BytesIO() + Image.new('RGB', (128, 128), color).save(buffer, format='PNG') + return buffer.getvalue() + + +@pytest.mark.parametrize('model', MODELS) +def test_image_generation_and_ordered_edits(tmp_path, model): + configs = ModelConfigs(tmp_path) + saved = configs.save('image', URL, model, 'test-key', protocol='openai') + assert saved['inputRules']['maxImages'] == 16 + config = configs.resolve(saved['id'], 'image') + first, second = png('red'), png('blue') + calls = [] + checkpoints = [] + + def respond(request): + if request.method == 'GET': + assert 'authorization' not in request.headers + return httpx.Response(200, content=first) + assert request.headers['authorization'] == 'Bearer test-key' + assert request.extensions['timeout']['read'] == 600 + body = json.loads(request.content) + assert body['model'] == model and body['output_format'] == 'png' + calls.append((request.url.path, body)) + return httpx.Response(200, json={'data': [{'url': RESULT}]}) + + class Store: + def asset(self, owner, asset_id): + data = {'first': first, 'second': second}[asset_id] + return data, dict(id=asset_id, name=asset_id + '.png', type='image/png', + width=128, height=128, fingerprint='test-fingerprint') + + async def ready(suffix, url): + checkpoints.append((suffix, url)) + + async def run(): + transport = httpx.MockTransport(respond) + provider = GenerationProviders(Store(), transport) + task = dict(kind='image', projectId='project', prompt='A portrait', + referenceIds=['first', 'second'], parameters={'size': '2720x1536'}) + assert await provider.generate(config, task, None, result_ready=ready) == ('png', first) + assert await provider.resume_result(config, 'png', RESULT) == ('png', first) + adapter = adapter_for(config, transport) + assert (await adapter.image(config, ImageRequest('A landscape', 1024, 1024))).data == first + assert (await adapter.image(config, ImageRequest('Combine', 1024, 1024, first, references=(second,)))).data == first + + asyncio.run(run()) + assert checkpoints == [('png', RESULT)] + assert [path for path, _ in calls] == ['/v1/images/edits', '/v1/images/generations', '/v1/images/edits'] + assert calls[0][1]['size'] == '2720x1536' + expected = [{'image_url': 'data:image/png;base64,' + base64.b64encode(data).decode()} for data in (first, second)] + assert calls[0][1]['images'] == calls[2][1]['images'] == expected + + +def test_submission_timeout_does_not_retry(tmp_path): + configs = ModelConfigs(tmp_path) + saved = configs.save('image', URL, MODELS[0], 'test-key', protocol='openai') + config = configs.resolve(saved['id'], 'image') + calls = [] + + def timeout(request): + calls.append(request.method) + raise httpx.ReadTimeout('timed out') + + adapter = adapter_for(config, httpx.MockTransport(timeout)) + with pytest.raises(GenerationError) as failure: + asyncio.run(adapter.image(config, ImageRequest('A landscape', 1024, 1024))) + assert failure.value.details.category == 'submission_unknown' + assert calls == ['POST']