diff --git a/apps/api/src/opendirector_api/localization.py b/apps/api/src/opendirector_api/localization.py index c5a4cbf6..0a12ea6d 100644 --- a/apps/api/src/opendirector_api/localization.py +++ b/apps/api/src/opendirector_api/localization.py @@ -50,6 +50,10 @@ "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.", + "minimax_speech_text": "Enter the text to read aloud, up to 9,999 characters.", + "minimax_speech_model": "Unsupported MiniMax speech model. Choose a model from the preset.", + "minimax_speech_voice": "Choose a voice available to your MiniMax account from the voice library.", + "minimax_speech_reference": "MiniMax speech synthesis does not accept references. Enter the text to read aloud and choose a voice.", "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/docs/development/lessons.md b/docs/development/lessons.md index 13e6dfac..629933ad 100644 --- a/docs/development/lessons.md +++ b/docs/development/lessons.md @@ -65,6 +65,7 @@ When work exposes a consequential risk or a recurring problem, add a lesson or u - **Trigger:** Accepting a cross-origin result URL from a configured model service. - **Lesson:** [PR #210](https://github.com/ThinkFlowLab/OpenDirector/pull/210) added compatible image CDN downloads without provider credentials, but initially left arbitrary local and private hosts reachable. Credential isolation and bounded responses do not constrain the network destination. - **Check:** Preserve explicitly configured local service origins; validate all DNS answers for unconfigured hosts, connect to a checked public IP while retaining the original Host and TLS identity, and revalidate every redirect. Cover local metadata addresses, IPv6, mixed DNS answers, redirect loops, and recovery without another model submission. +- **Recovery follow-up:** [PR #283](https://github.com/ThinkFlowLab/OpenDirector/pull/283) reproduced download recovery losing its adapter's public-host checks after a model was removed or changed. Recover with the accepted model snapshot and no credentials instead of an untyped download. Test private URLs, changed DNS answers and redirects after configuration removal or replacement. ## An acknowledged editor save invalidates earlier automatic callbacks diff --git a/docs/development/validation.md b/docs/development/validation.md index 71e2d2b5..b25a4269 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -269,6 +269,7 @@ The voice browser workflow uses a separate isolated fixture. It runs the real sh ```sh .venv/bin/python -m pytest tests/test_voice_library.py -q +.venv/bin/python -m pytest tests/test_minimax_speech.py -q node --test tests/voiceLibrary.test.ts tests/creationParameters.test.ts # Terminal 1 .venv/bin/python tests/browser/voice_library_server.py --port 8030 @@ -280,6 +281,10 @@ CREATION_API_URL=http://127.0.0.1:8030 CREATION_WEB_URL=http://127.0.0.1:5190 no For a slow-response regression, set `VOICE_REOPEN_DELAY_MS=17000` for the browser command. It delays the reopened asset request beyond the ordinary 15-second interaction timeout. Cold navigation, saved-state and reopened-media waits have explicit 45-second limits; HTTP errors still fail immediately with their status. No generation is retried. +The audio-reopen check waits for the saved indicator and acknowledges the page's session-release request before reloading. Its result must not depend on an unload-time request reaching the server; keep the server's editing-lease checks intact. + 慢响应回归:在浏览器命令的环境变量中设置 `VOICE_REOPEN_DELAY_MS=17000`,让重开后的资源请求超过普通交互的 15 秒等待。冷启动导航、保存状态和重开媒体分别有明确的 45 秒上限;HTTP 错误仍立即报告状态并失败,不重试生成。 The bilingual browser workflow covers opening/outside click/Escape, keyboard tabs, four-column and narrow layouts, real previews and unavailable previews, metadata filters, scoped favorites, loading/error/retry, invalid and valid local audio, creation, selection, Drama3 transcript and Nano reference requirements, supported deletion and failure feedback, model/service switching, project reopening and persisted job input. `moss_speech.mjs` is retained as an alias for this integrated workflow. The pagination regression includes a 15,001-voice catalog. With a configured MOSS account, open the library, go to the next page, and return to the first. Verify the page counts and that returning to the first page restores its entries using read-only requests. Python tests verify provider fields, pagination, credentials, account boundaries, actual audio/container/duration validation, and equivalent Agent operations. Actual provider synthesis still requires a configured service, account permission and a recording authorized for use. + +The same browser workflow also covers MiniMax's mainland speech preset in both languages, localized rejection of unsupported model IDs, saved credentials remaining private, read-only system voices, IDs containing spaces, selection retention between HD and Turbo, the 9,999-character precheck, and generation/reopen/playback. Its generated audio is a local WAV fixture, not MiniMax output. `test_minimax_speech.py` uses mocked HTTP with real MP3 bytes to check `/t2a_v2` and `/get_voice`, HTTP-200 error envelopes, invalid or incomplete responses, public result downloads without credentials, UI/Agent acceptance and receipts, actual media probing, and recovery after model removal or replacement without another synthesis call. Recovery must still reject private destinations, DNS changes to private addresses, and redirects into private networks. Live MiniMax synthesis and perceptual voice quality remain separate real-account checks. diff --git a/docs/guides/images/minimax-speech-voices-en-US.png b/docs/guides/images/minimax-speech-voices-en-US.png new file mode 100644 index 00000000..8ac142ca Binary files /dev/null and b/docs/guides/images/minimax-speech-voices-en-US.png differ diff --git a/docs/guides/images/minimax-speech-voices-zh-CN.png b/docs/guides/images/minimax-speech-voices-zh-CN.png new file mode 100644 index 00000000..b6b08edf Binary files /dev/null and b/docs/guides/images/minimax-speech-voices-zh-CN.png differ diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index d8a7346b..0cebb147 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -87,10 +87,16 @@ For video generation, choose a mode supported by the model: - **First and last frames**: Connect up to two image sources and assign them to the first- and last-frame slots. Last-frame support depends on the model's capabilities and the guidance in the interface. - **Continuation**: Select a model that supports continuation and a source video. If the service restarts or the source is no longer available, you may need to regenerate the source video. -For audio generation, enter the text to be read aloud in the prompt. Supported vLLM-Omni speech models and MOSS Open Platform offer **Voice library** beside the model controls. The library opens above the composer with **All voices**, **My voices**, and **Favorites**. Click a compatible voice’s name to select it. When a preview is available, click the circular play button to listen. When a compatible voice has no preview, its icon and card background also select it. Metadata filters appear only when supplied by the service. +For audio generation, enter the text to be read aloud in the prompt. Supported vLLM-Omni speech models, MOSS Open Platform, and MiniMax Official Speech offer **Voice library** beside the model controls. The library opens above the composer with **All voices**, **My voices**, and **Favorites**. Click a compatible voice’s name to select it. When a preview is available, click the circular play button to listen. When a compatible voice has no preview, its icon and card background also select it. Metadata filters appear only when supplied by the service. On MOSS Open Platform, select the stable `moss-tts-1.5-flash` or `moss-tts-1.0-pro` model, or choose its dated snapshot to pin a version. All four use the same voice library on a given account connection. The library shows up to 150 MOSS voices per page. Use **Next page** and **Previous page** to browse. Filters and the Favorites tab show matches on the current page. +For MiniMax, choose **Settings → Model connections → Audio → Add service → MiniMax Official Speech**, enter your mainland China API key, and save `speech-2.8-hd` or `speech-2.8-turbo`. The preset uses `https://api.minimax.cn/v1`. Unsupported model IDs are rejected when saving the service; choose a supported model from the preset. In an audio node, select that model, choose a system voice from **Voice library**, and enter up to 9,999 characters. Reference assets, voice cloning, voice design, and deletion are not supported by this integration. The system voice API does not provide previews; selecting a voice does not start a paid synthesis request. Compatible MiniMax speech models on the same account connection retain the selection. + +MiniMax speech uses the [synchronous T2A API](https://platform.minimax.cn/docs/api-reference/speech-t2a-http) and saves an MP3 in the project. A saved result URL permits download recovery without another synthesis request, even after removing the model configuration. The provider's URLs expire after 24 hours; an expired link cannot be recovered locally. An unknown submission is not retried automatically. This integration covers the mainland China endpoint; international endpoints and real-account availability need separate verification. + +![MiniMax system voice selection with a mocked catalog](images/minimax-speech-voices-en-US.png) + Choose **Create voice** to upload a local recording, name it, and review its playback, format, size and duration before submitting. vLLM-Omni requires the consent recording ID; adapters such as Drama3 also require the recording transcript. MOSS-TTS-Nano needs a registered voice with reference audio: a listed `default` voice is insufficient. This upload registers the voice with the selected service without adding a canvas reference node. Selected voices are saved in the project; favorites are saved as browser preferences. Both are scoped to the configured service and account. Compatible models sharing a service can retain the selection; another service or account requires its own registered voice. Use a voice’s secondary menu to copy its provider ID or delete an uploaded voice where supported. Deletion requires confirmation; MOSS deletion is unavailable until its API supports it. Service errors are shown without credentials; an unsupported model keeps its existing voice controls. diff --git a/docs/guides/user-guide.zh-CN.md b/docs/guides/user-guide.zh-CN.md index 5328fcd7..b662426e 100644 --- a/docs/guides/user-guide.zh-CN.md +++ b/docs/guides/user-guide.zh-CN.md @@ -87,10 +87,16 @@ HTTP 400。 - **首尾帧**:最多连接两个图片来源,按槽位选择首帧和尾帧;能否使用尾帧以模型能力和界面提示为准。 - **接续**:选择支持该功能的模型和来源视频。服务重启或来源不再可用时,可能需要重新生成来源视频。 -音频生成时,在提示词中填写需要朗读的正文。受支持的 vLLM-Omni 语音模型和 MOSS 开放平台会在模型控件旁显示**音色库**。点击后在创作区上方打开**全部音色 / 我的音色 / 收藏**。点击兼容音色的名称即可选用。有试听音频时,点击圆形播放按钮即可试听。兼容音色没有试听音频时,点击图标或卡片空白处也可以选用。筛选项仅根据服务实际提供的元数据显示。 +音频生成时,在提示词中填写需要朗读的正文。受支持的 vLLM-Omni 语音模型、MOSS 开放平台和 MiniMax 官方语音会在模型控件旁显示**音色库**。点击后在创作区上方打开**全部音色 / 我的音色 / 收藏**。点击兼容音色的名称即可选用。有试听音频时,点击圆形播放按钮即可试听。兼容音色没有试听音频时,点击图标或卡片空白处也可以选用。筛选项仅根据服务实际提供的元数据显示。 在 MOSS 开放平台上,可选择稳定版 `moss-tts-1.5-flash` 或 `moss-tts-1.0-pro`,也可以选择带日期的快照来固定版本。同一账户连接下,这四个型号共用音色库。每页最多显示 150 个 MOSS 音色,点击**下一页**或**上一页**可以翻页。筛选和收藏标签显示当前页中符合条件的音色。 +使用 MiniMax 时,选择**设置 → 模型接入 → 音频 → 添加服务 → MiniMax 官方语音**,填写中国站 API Key,保存 `speech-2.8-hd` 或 `speech-2.8-turbo`。预设地址为 `https://api.minimax.cn/v1`。保存服务时会拒绝不支持的型号,请选择预设中的受支持型号。在音频节点中选择该模型,从**音色库**选择系统音色,再填写最多 9,999 个字符的朗读文本。本接入不支持参考素材、音色克隆、音色设计或删除。系统音色接口不提供试听文件;选择音色不会发起付费合成请求。同一账户连接下的兼容 MiniMax 语音模型可以保留选择。 + +MiniMax 配音使用[同步语音合成接口](https://platform.minimax.cn/docs/api-reference/speech-t2a-http),将 MP3 保存到工程。已有结果链接可用于恢复下载,不会重新提交合成;移除模型配置后仍可恢复。供应商链接有效期为 24 小时,过期后无法在本地恢复。提交结果不明时不会自动重试。本接入覆盖中国站地址,国际站和真实账户可用性需要单独验证。 + +![MiniMax 系统音色选择,使用模拟音色目录](images/minimax-speech-voices-zh-CN.png) + 选择**创建音色**,上传本地录音、填写名称,并在提交前检查试听、格式、大小和时长。vLLM-Omni 需要填写授权录音 ID;Drama3 等适配器还必须填写录音逐字稿。MOSS-TTS-Nano 需要带有参考音频的已注册音色,列表中的 `default` 不能代替录音。上传会直接在当前服务注册音色,不创建画布参考音频节点。 所选音色随工程保存,收藏作为浏览器偏好保存,均按服务连接和账户隔离。同一连接下的兼容模型可以保留选择;其他服务或账户必须单独注册音色。音色的更多操作菜单可复制服务端 ID,或在服务支持时删除上传音色。删除前需要确认;MOSS 尚未开放删除操作。服务错误不会显示凭据;不支持音色库的模型保留原有音色控件。 diff --git a/packages/core/src/opendirector/application/creation_generation.py b/packages/core/src/opendirector/application/creation_generation.py index 6f35659f..ec609903 100644 --- a/packages/core/src/opendirector/application/creation_generation.py +++ b/packages/core/src/opendirector/application/creation_generation.py @@ -215,6 +215,8 @@ def _parameters(self, spec, config, node, records): params.update(prompt_enhance=False, watermark=not self.settings.get()['removeWatermark']) if spec.kind == 'audio' and input_rules(config)['profile'] == 'moss-speech': params = dict(voice=intent['voice'], response_format='mp3', delivery_method='url', stream=False) + if spec.kind == 'audio' and input_rules(config)['profile'] == 'minimax-speech': + params = dict(voice=intent['voice'], format='mp3', output_format='url', stream=False) return params @staticmethod @@ -231,7 +233,7 @@ def _execution(kind, parameters, config): return dict(kind=kind, creationType='music', format='mp3', maxDurationSeconds=parameters['duration'], scoreMode=parameters.get('score_mode')) if kind == 'audio': - if input_rules(config)['profile'] == 'moss-speech': + if input_rules(config)['profile'] in {'moss-speech', 'minimax-speech'}: return dict(kind=kind, voice=parameters['voice'], format='mp3') return dict(kind=kind, voice=parameters.get('voice') or ('default' if config['connectionType'] == 'self_hosted' else 'alloy') if config['protocol'] == 'openai' else None, speaker=parameters.get('speaker') if config['protocol'] != 'openai' else None, @@ -600,10 +602,8 @@ async def recover(self, project_id, job_id, session=None, *, explicit=False): valid = False if not valid: if mode == 'download': - # The private locator belongs to the accepted result, not - # the current model selection. Never attach another model's - # credentials or turn this read into a new submission. - config = None + # Keep the accepted adapter and download policy without current credentials. + config = {**job.inputSnapshot.model.model_dump(mode='json'), 'apiKey': '', 'authMode': 'none'} else: task.update(status='interrupted', recovery='review', resumeAllowed=False) await asyncio.to_thread(self.checkpoint, project_id, task, pinned) diff --git a/packages/core/src/opendirector/application/voice_library.py b/packages/core/src/opendirector/application/voice_library.py index ed567186..53c7d42c 100644 --- a/packages/core/src/opendirector/application/voice_library.py +++ b/packages/core/src/opendirector/application/voice_library.py @@ -29,12 +29,12 @@ def _config(self, model_config_id): if capabilities is None: raise voice_error('voice_library_unsupported', 409) return config, capabilities - def _request(self, config, method, suffix='', **kwargs): + def _request(self, config, method, suffix='', path='/audio/voices', **kwargs): try: # Sync service runs in worker threads, including job preparation. with httpx.Client(transport=self.transport, trust_env=trust_environment(config['url'], config=config), timeout=httpx.Timeout(120, connect=15), follow_redirects=False) as client: - with client.stream(method, endpoint(config['url'], '/audio/voices') + suffix, + with client.stream(method, endpoint(config['url'], path) + suffix, headers=auth_headers(config), **kwargs) as response: data = bytearray() for chunk in response.iter_bytes(): @@ -134,6 +134,21 @@ def _vllm_page(self, config, capabilities): records[identity] = (record, locator) return list(records.values()), None + def _minimax_page(self, config, capabilities): + # https://platform.minimaxi.com/docs/api-reference/voice-management-get + # Errors arrive as HTTP 200 with a nonzero base_resp.status_code. + value = self._request(config, 'POST', path='/get_voice', json={'voice_type': 'system'}) + status = value.get('base_resp') + if not isinstance(status, dict) or type(status.get('status_code')) is not int: + raise voice_error('voice_provider_protocol', 502) + if status['status_code'] != 0: + raise voice_error('voice_provider_error', 502) + items = value.get('system_voice') + if not isinstance(items, list) or len(items) > 10000 or not all(isinstance(item, dict) for item in items): + raise voice_error('voice_provider_protocol', 502) + return [self._record({'id': item.get('voice_id'), 'name': item.get('voice_name')}, capabilities, + source='builtin', has_audio=False, config=config) for item in items], None + @staticmethod def _valid_cursor(cursor): return isinstance(cursor, str) and 0 < len(cursor) <= 2048 and cursor == cursor.strip() and cursor.isprintable() @@ -169,6 +184,8 @@ def _pages(self, config, capabilities, limit, after=None): self._assert_current(config, capabilities) if capabilities['provider'] == 'moss': records, cursor = self._moss_page(config, capabilities, limit, after) + elif capabilities['provider'] == 'minimax': + records, cursor = self._minimax_page(config, capabilities) else: records, cursor = self._vllm_page(config, capabilities) for record, _ in records: diff --git a/packages/core/src/opendirector/domain/creation/commands.py b/packages/core/src/opendirector/domain/creation/commands.py index d712a054..d0e6d3e2 100644 --- a/packages/core/src/opendirector/domain/creation/commands.py +++ b/packages/core/src/opendirector/domain/creation/commands.py @@ -402,6 +402,7 @@ def command_fingerprint(command: Command) -> str: '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', + 'minimax_speech_text', 'minimax_speech_voice', 'minimax_speech_reference', 'minimax_speech_model', '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/creation/generation.py b/packages/core/src/opendirector/domain/creation/generation.py index 4040b3b0..8f745d1a 100644 --- a/packages/core/src/opendirector/domain/creation/generation.py +++ b/packages/core/src/opendirector/domain/creation/generation.py @@ -368,10 +368,12 @@ def validate_generation(spec: GenerationSpec, model: ModelConfigPublic, referenc rules = model.inputRules if rules.referencesAllowed is False and references: raise ValueError('当前模型不支持参考素材,请移除参考后重试。') - if rules.profile == 'moss-speech': + if rules.profile in {'moss-speech', 'minimax-speech'}: + from opendirector.domain.minimax_speech import validate_minimax_speech_input from opendirector.domain.moss_speech import validate_moss_speech_input text = spec.prompt if spec.promptContent is None else ''.join(part.text for part in spec.promptContent if part.type == 'text') - validate_moss_speech_input(text, spec.parameters.voice) + validate = validate_moss_speech_input if rules.profile == 'moss-speech' else validate_minimax_speech_input + validate(text, spec.parameters.voice) elif spec.kind == 'audio' and rules.voiceRequired and not spec.parameters.voice: raise ValueError('请填写服务音色 ID。') if len(references) > MAX_REFERENCES: diff --git a/packages/core/src/opendirector/domain/creation/models.py b/packages/core/src/opendirector/domain/creation/models.py index 937a2d0f..b652064d 100644 --- a/packages/core/src/opendirector/domain/creation/models.py +++ b/packages/core/src/opendirector/domain/creation/models.py @@ -29,7 +29,7 @@ class ImageOutput(Contract): class VoiceLibraryCapabilities(Contract): - provider: Literal['vllm_omni', 'moss'] = Field(description='Voice registry protocol.') + provider: Literal['vllm_omni', 'moss', 'minimax'] = Field(description='Voice registry protocol.') connectionId: str = Field(min_length=1, max_length=200, description='Opaque service, endpoint and account identity.') requiresTranscript: bool = Field(description='Registered voice must have a reference transcript.') requiresReferenceAudio: bool = Field(description='Registered voice must have actual reference audio.') diff --git a/packages/core/src/opendirector/domain/input_rules.py b/packages/core/src/opendirector/domain/input_rules.py index 80532a38..8468b4d1 100644 --- a/packages/core/src/opendirector/domain/input_rules.py +++ b/packages/core/src/opendirector/domain/input_rules.py @@ -5,6 +5,8 @@ from opendirector.domain.video_capabilities import capability_fields, seedance_20_variant from .moss_image import MOSS_IMAGE_PROMPT_CHARACTERS, is_moss_image from .moss_speech import is_moss_speech +from .minimax_speech import MINIMAX_SPEECH_MODELS, MINIMAX_SPEECH_TEXT_CHARACTERS, is_minimax_speech +from .projects import ProjectError from .kling_models import KLING_MODELS, KLING_RATIOS, kling_capabilities VideoMode = Literal['first_frame', 'reference', 'continuation'] @@ -32,6 +34,12 @@ def _adapter_rules(config): rules.update(profile='moss-speech', allowedKinds=[], maxImages=0, voiceRequired=True, referencesAllowed=False) return rules + if is_minimax_speech(config): + if config['modelId'] not in MINIMAX_SPEECH_MODELS: + raise ProjectError('不支持该 MiniMax 语音型号,请选择预设中的型号。', 422, code='minimax_speech_model') + rules.update(profile='minimax-speech', allowedKinds=[], maxImages=0, voiceRequired=True, + referencesAllowed=False, maxPromptCharacters=MINIMAX_SPEECH_TEXT_CHARACTERS) + return rules if kind == 'vision' or config.get('protocol') == 'vllm_omni_segmentation': # Shared image segmentation / video tracking service, never a generator. rules.update(profile='vllm-omni-segmentation', allowedKinds=[], maxImages=0, videoModes=[]) diff --git a/packages/core/src/opendirector/domain/minimax_speech.py b/packages/core/src/opendirector/domain/minimax_speech.py new file mode 100644 index 00000000..48a90b6f --- /dev/null +++ b/packages/core/src/opendirector/domain/minimax_speech.py @@ -0,0 +1,29 @@ +"""Model identity and input rules for MiniMax synchronous speech synthesis (T2A v2).""" +from urllib.parse import urlsplit + +from .projects import ProjectError + +# https://platform.minimaxi.com/docs/api-reference/speech-t2a-http +MINIMAX_SPEECH_MODELS = frozenset({ + 'speech-2.8-hd', 'speech-2.8-turbo', 'speech-2.6-hd', 'speech-2.6-turbo', + 'speech-02-hd', 'speech-02-turbo', 'speech-01-hd', 'speech-01-turbo', +}) +# The synchronous API requires text shorter than 10,000 characters. +MINIMAX_SPEECH_TEXT_CHARACTERS = 9999 + + +def is_minimax_speech(config): + url = urlsplit(config.get('url', '')) + return (config.get('kind') == 'audio' and config.get('protocol') == 'openai' + and config.get('connectionType', 'api') == 'api' + and url.scheme == 'https' and url.hostname == 'api.minimax.cn' and url.port in {None, 443} + and url.path.rstrip('/') == '/v1') + + +def validate_minimax_speech_input(text, voice): + if not text.strip() or len(text) > MINIMAX_SPEECH_TEXT_CHARACTERS: + raise ProjectError('请填写需要朗读的文本,且不超过 9,999 个字符。', 422, code='minimax_speech_text') + # System voice IDs contain spaces and parentheses, and one ends with a + # space ("Santa_Claus "), so the ID is sent exactly as listed. + if not isinstance(voice, str) or not voice.strip() or len(voice) > 256 or not voice.isprintable(): + raise ProjectError('请从音色库选择 MiniMax 账户可用的音色。', 422, code='minimax_speech_voice') diff --git a/packages/core/src/opendirector/domain/voices.py b/packages/core/src/opendirector/domain/voices.py index 791bcd53..7a00bf5d 100644 --- a/packages/core/src/opendirector/domain/voices.py +++ b/packages/core/src/opendirector/domain/voices.py @@ -4,6 +4,7 @@ import re from urllib.parse import urlsplit, urlunsplit +from .minimax_speech import is_minimax_speech from .moss_speech import is_moss_speech from .projects import ProjectError @@ -43,16 +44,16 @@ def voice_library_capabilities(config): if config.get('kind') != 'audio' or config.get('protocol') != 'openai': return None name = config.get('modelId', '').rsplit('/', 1)[-1].lower().replace('_', '-') - moss = is_moss_speech(config) + moss, minimax = is_moss_speech(config), is_minimax_speech(config) cosyvoice = name.startswith(('cosyvoice3', 'fun-cosyvoice3')) fish_speech = name.startswith('fish-speech') or name in {'s2-pro', 'drama-3', 'drama-3-int4'} # Explicit served-model families; an arbitrary compatible endpoint is not # evidence that the server implements the vLLM voice registry. known = cosyvoice or fish_speech or name.startswith(('moss-tts', 'qwen3-tts', 'fishaudio', 'index-tts', 'indextts', 'omnivoice', 'higgs-audio', 'glm-tts', 'ming-', 'drama3')) - if not moss and not (config.get('connectionType') == 'self_hosted' and known): + if not moss and not minimax and not (config.get('connectionType') == 'self_hosted' and known): return None - provider = 'moss' if moss else 'vllm_omni' + provider = 'moss' if moss else 'minimax' if minimax else 'vllm_omni' parsed = urlsplit(config['url']) host = parsed.hostname.lower() if ':' in host: host = '[' + host + ']' @@ -62,6 +63,11 @@ def voice_library_capabilities(config): account = hashlib.sha256((config.get('apiKey', '') if config.get('authMode') != 'none' else '').encode()).hexdigest() identity = [provider, config.get('providerId') or config.get('id'), endpoint, config.get('authMode', 'none'), account] connection = hashlib.sha256(json.dumps(identity, separators=(',', ':')).encode()).hexdigest() + if minimax: + # Read-only system voices. MiniMax cloning and voice design are not adapted. + return dict(provider=provider, connectionId=connection, requiresTranscript=False, + requiresReferenceAudio=False, supportsUploadedVoices=False, referenceEmbeddingDimension=None, + canDelete=False, maxBytes=10 * 1024 * 1024, formats=[], minSeconds=None, maxSeconds=None) # Upstream vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py: # validate() requires reference audio for standard/realtime/local/Nano. # Local-Transformer ref_text is optional continuation conditioning; Drama3 diff --git a/packages/core/src/opendirector/integrations/generation_errors.py b/packages/core/src/opendirector/integrations/generation_errors.py index a5a73cb1..835c6bb6 100644 --- a/packages/core/src/opendirector/integrations/generation_errors.py +++ b/packages/core/src/opendirector/integrations/generation_errors.py @@ -92,6 +92,11 @@ def provider_failure(value, *, stage, headers=None, http_status=None, secrets=() if code in {'1300', '1301'}: message, suggestion = ('供应商判定提示词或参考图片违反内容政策,已拒绝生成。', '请调整提示词或参考素材后再生成;当前输入和已有结果保留。') + if provider == 'minimax': + # MiniMax open-platform base_resp.status_code values, delivered with HTTP 200. + category = {'1001': 'temporary', '1002': 'rate_limit', '1039': 'rate_limit', '1004': 'authentication', + '2049': 'authentication', '1008': 'quota', '1042': 'input', '2013': 'input'}.get(code, category) + message, suggestion = _REASONS[category] diagnostic = error.get('message', '') diagnostic = diagnostic[:8192].lower() if isinstance(diagnostic, str) else '' if http_status in {400, 422} and category == 'input': @@ -101,7 +106,7 @@ def provider_failure(value, *, stage, headers=None, http_status=None, secrets=() elif re.search(r'(?:not support|unsupported|not supported)', diagnostic) and re.search(r'(?:reference_(?:image|video|audio)|image_url|video_url|audio_url|input_audio)', diagnostic): category, message = 'reference', '供应商拒绝了参考类型或输入组合。' suggestion = '请核对参考类型和生成模式,或选择其他已适配模型;当前输入和已有结果保留。' - if name == 'voice_not_found': + if name == 'voice_not_found' or provider == 'minimax' and code == '2054': category, message = 'input', '所选音色不存在或不可用。' suggestion = '请检查音色 ID 是否属于当前账户可用的音色。' elif name == 'inputimagesensitivecontentdetected.privacyinformation': diff --git a/packages/core/src/opendirector/integrations/model_adapters.py b/packages/core/src/opendirector/integrations/model_adapters.py index a78204ab..37aa9d89 100644 --- a/packages/core/src/opendirector/integrations/model_adapters.py +++ b/packages/core/src/opendirector/integrations/model_adapters.py @@ -22,6 +22,7 @@ VideoRequest) from opendirector.domain.input_rules import input_rules from opendirector.domain.minimax_image import validate_minimax_image_reference +from opendirector.domain.minimax_speech import validate_minimax_speech_input 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 @@ -207,12 +208,57 @@ async def submit(): async def download(self, config, url): data = await _download_moss_result(self, config, url) - mpeg_frame = len(data) >= 4 and data[0] == 0xff and data[1] & 0xe0 == 0xe0 and data[1] & 0x06 - if not data.startswith(b'ID3') and not mpeg_frame: + if not _is_mp3(data): raise protocol_failure('download') return data +def _is_mp3(data): + mpeg_frame = len(data) >= 4 and data[0] == 0xff and data[1] & 0xe0 == 0xe0 and data[1] & 0x06 + return data.startswith(b'ID3') or bool(mpeg_frame) + + +class MinimaxSpeech(ModelAdapter): + """MiniMax synchronous T2A v2, non-streaming, answering with a result URL.""" + capabilities = frozenset({'audio.speech'}) + + async def speech(self, config, request: SpeechRequest, *, result_ready=None) -> MediaResult: + validate_minimax_speech_input(request.text, request.voice) + payload = {'model': config['modelId'], 'text': request.text, 'stream': False, + 'voice_setting': {'voice_id': request.voice}, 'audio_setting': {'format': 'mp3'}, + 'output_format': 'url'} + + async def submit(): + secrets = (config.get('apiKey', ''),) + async with _client(self, config) as client: + async with client.stream('POST', endpoint(config['url'], '/t2a_v2'), + headers=auth_headers(config), json=payload) as response: + value = json.loads(await bounded(response, stage='submit', secrets=secrets, limit=1024 * 1024)) + # Errors arrive as HTTP 200 with a nonzero base_resp.status_code. + status = value['base_resp']['status_code'] + if type(status) is not int: + raise protocol_failure('submit') + if status != 0: + raise provider_failure({'code': status, 'request_id': value.get('trace_id')}, stage='submit', + http_status=200, secrets=secrets, provider='minimax') + if type(value['data']['status']) is not int or value['data']['status'] != 2: + raise protocol_failure('submit') + return _result_url(value['data']['audio'], 'submit') + + url = await _call('submit', submit) + if result_ready: + await result_ready('mp3', url) + return await self.download(config, url) + + async def download(self, config, url): + # The signed result URL is public storage: it never receives the API key. + _, data = await VolcengineGeneration(None, transport=self.transport).resume_result( + config, 'mp3', url, public_only=True) + if not _is_mp3(data): + raise protocol_failure('download') + return MediaResult(data, 'mp3') + + class MinimaxH3Images(ModelAdapter): capabilities = frozenset({'image.generate', 'image.edit'}) max_images = 16 @@ -739,6 +785,8 @@ def adapter_for(config, transport=None, poll_interval=3) -> ModelAdapter | None: return MossImages(transport, poll_interval) if profile == 'moss-speech': return MossSpeech(transport, poll_interval) + if profile == 'minimax-speech': + return MinimaxSpeech(transport, poll_interval) if kind == 'image' and profile == 'vllm-omni-image': return OpenAIImages(transport, poll_interval, free_size=True, strength=True) if kind == 'image' and profile == 'vllm-omni-qwen-image': diff --git a/packages/core/src/opendirector/integrations/providers.py b/packages/core/src/opendirector/integrations/providers.py index 3c96dd02..d1afe3f5 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 MinimaxH3Images, MossImages, MossSpeech, OpenAISpeech, VllmOmniMusic, VllmOmniScore +from opendirector.integrations.model_adapters import MinimaxH3Images, MinimaxSpeech, 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 @@ -37,6 +37,7 @@ def __init__(self, store, transport=None, poll_interval=3, retry_delays=(0.5, 1. self.minimax_image = MinimaxH3Images(transport, poll_interval) self.moss_image = MossImages(transport, poll_interval) self.moss_speech = MossSpeech(transport, poll_interval) + self.minimax_speech = MinimaxSpeech(transport, poll_interval) def _profile_adapter(self, config): if config and config.get('protocol') == 'kling_video': @@ -93,6 +94,9 @@ async def resume_result(self, config, suffix, url): return suffix, await self.moss_image.download(config, url) if config and input_rules(config)['profile'] == 'moss-speech': return suffix, await self.moss_speech.download(config, url) + if config and input_rules(config)['profile'] == 'minimax-speech': + result = await self.minimax_speech.download(config, url) + return result.suffix, result.data if not config or config.get('protocol') in {None, 'volcengine'}: return await super().resume_result(config, suffix, url) # Only send a service token back to that exact configured origin. @@ -131,6 +135,11 @@ async def generate(self, config, task, submitted, *, result_ready=None): result = await self.moss_speech.speech(config, SpeechRequest(prepared['prompt'], task['parameters'].get('voice')), result_ready=result_ready) return result.suffix, result.data + if input_rules(config)['profile'] == 'minimax-speech': + prepared = await asyncio.to_thread(generation_references, self.store, task, config) + result = await self.minimax_speech.speech(config, SpeechRequest(prepared['prompt'], task['parameters'].get('voice')), + result_ready=result_ready) + return result.suffix, result.data if config.get('protocol') in {None, 'volcengine'} or task['kind'] == 'text': return await super().generate(config, task, submitted, result_ready=result_ready) kind, stage = task['kind'], 'submit' diff --git a/packages/core/src/opendirector/integrations/reference_inputs.py b/packages/core/src/opendirector/integrations/reference_inputs.py index 9914a035..261fa1a8 100644 --- a/packages/core/src/opendirector/integrations/reference_inputs.py +++ b/packages/core/src/opendirector/integrations/reference_inputs.py @@ -108,6 +108,8 @@ def generation_references(store, task, config, *, complete=True, overrides=None) return result if kind == 'audio': if rules.get('referencesAllowed') is False and task['referenceIds']: + if rules['profile'] == 'minimax-speech': + raise ProjectError('MiniMax 语音合成不支持参考素材,请直接填写朗读文本并选择音色。', 422, code='minimax_speech_reference') raise ProjectError('MOSS 语音生成不支持参考素材,请直接填写朗读文本和音色 ID。', 422, code='moss_speech_reference') # Historical context remains recorded; it is never appended to speech. result['manifest'] = [{'assetId': meta['id'], 'fingerprint': meta['fingerprint'], 'kind': source_kind, 'transmission': 'context_only'} diff --git a/src/canvas/generationFailureText.ts b/src/canvas/generationFailureText.ts index 23246b64..24def45f 100644 --- a/src/canvas/generationFailureText.ts +++ b/src/canvas/generationFailureText.ts @@ -39,6 +39,7 @@ const BY_MESSAGE: Record = { "生成服务连接超时或中断。": "errors.generationFailure.temporary", "生成服务返回了无法识别或不完整的结果。": "errors.generationFailure.protocol", + "所选音色不存在或不可用。": "errors.generationFailure.voiceNotFound", }; const BY_CATEGORY: Record = { diff --git a/src/canvas/modelBrands.ts b/src/canvas/modelBrands.ts index 4cce35b4..18c7864e 100644 --- a/src/canvas/modelBrands.ts +++ b/src/canvas/modelBrands.ts @@ -12,6 +12,7 @@ export function modelBrand(modelId: string, explicitOrganization?: string) { if (exact) return exact; const aliases: [RegExp, string][] = [ [/^(minimax|fasth3|fastvideo-fasth3)/i, "MiniMaxAI"], + [/^speech-(?:2\.[68]|0[12])-(?:hd|turbo)$/i, "MiniMaxAI"], [/^kling(?:-|$)/i, "KlingAI"], [/^(deepseek)/i, "deepseek-ai"], [/^(qwen|qwq)/i, "Qwen"], diff --git a/src/canvas/providerPresets.ts b/src/canvas/providerPresets.ts index 08bea610..67f738c1 100644 --- a/src/canvas/providerPresets.ts +++ b/src/canvas/providerPresets.ts @@ -9,6 +9,7 @@ const EN_NAMES: Record = { "智谱 BigModel": "Zhipu BigModel", "可灵官方视频": "Kling official video", "MiniMax 官方图片": "MiniMax Official Images", + "MiniMax 官方语音": "MiniMax Official Speech", "MOSS 开放平台": "MOSS Open Platform", "火山方舟 / Agent Plan": "Volcano Ark / Agent Plan", "vLLM-Omni 图片接口": "vLLM-Omni image API", @@ -97,6 +98,17 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ authMode: "bearer", modelsByKind: { image: ["MiniMax-H3-Image", "MiniMax-H3-Image-Highspeed"] }, }, + { + id: "minimax-speech", + logoOrganization: "MiniMaxAI", + name: "MiniMax 官方语音", + kinds: ["audio"], + url: "https://api.minimax.cn/v1", + protocol: "openai", + connectionType: "api", + authMode: "bearer", + modelsByKind: { audio: ["speech-2.8-hd", "speech-2.8-turbo"] }, + }, { id: "minimax-video", logoOrganization: "MiniMaxAI", diff --git a/src/contracts/creation.schema.json b/src/contracts/creation.schema.json index 5ff5a4d7..2f716b05 100644 --- a/src/contracts/creation.schema.json +++ b/src/contracts/creation.schema.json @@ -3487,6 +3487,10 @@ "minimax_image_reference", "minimax_image_count", "minimax_image_bytes", + "minimax_speech_text", + "minimax_speech_voice", + "minimax_speech_reference", + "minimax_speech_model", "voice_library_unsupported", "voice_connection_changed", "voice_reference_required", @@ -14387,7 +14391,8 @@ "description": "Voice registry protocol.", "enum": [ "vllm_omni", - "moss" + "moss", + "minimax" ], "title": "Provider", "type": "string" @@ -18653,6 +18658,10 @@ "minimax_image_reference", "minimax_image_count", "minimax_image_bytes", + "minimax_speech_text", + "minimax_speech_voice", + "minimax_speech_reference", + "minimax_speech_model", "voice_library_unsupported", "voice_connection_changed", "voice_reference_required", @@ -30007,7 +30016,8 @@ "description": "Voice registry protocol.", "enum": [ "vllm_omni", - "moss" + "moss", + "minimax" ], "title": "Provider", "type": "string" diff --git a/src/contracts/generated.ts b/src/contracts/generated.ts index ebf5b9c8..fb740098 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" | "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 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" | "minimax_speech_text" | "minimax_speech_voice" | "minimax_speech_reference" | "minimax_speech_model" | "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; @@ -1529,7 +1529,7 @@ export interface ViewportInput extends PointInput { } export interface VoiceLibraryCapabilitiesInput extends ContractInput { - readonly provider: "vllm_omni" | "moss"; + readonly provider: "vllm_omni" | "moss" | "minimax"; readonly connectionId: string; readonly requiresTranscript: boolean; readonly requiresReferenceAudio: boolean; @@ -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" | "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 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" | "minimax_speech_text" | "minimax_speech_voice" | "minimax_speech_reference" | "minimax_speech_model" | "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; @@ -3100,7 +3100,7 @@ export interface Viewport extends Point { } export interface VoiceLibraryCapabilities extends Contract { - readonly provider: "vllm_omni" | "moss"; + readonly provider: "vllm_omni" | "moss" | "minimax"; readonly connectionId: string; readonly requiresTranscript: boolean; readonly requiresReferenceAudio: boolean; diff --git a/src/i18n/messages/en-US/errors.ts b/src/i18n/messages/en-US/errors.ts index 9b43de92..d658aed2 100644 --- a/src/i18n/messages/en-US/errors.ts +++ b/src/i18n/messages/en-US/errors.ts @@ -139,7 +139,7 @@ export const errors = { "Enter a voice id supported by the service (letters, numbers, dots, underscores, or hyphens).", "errors.rules.voiceRequired": "Enter a valid Voice ID from the service's voice library before generating.", "errors.rules.referencesUnsupported": "This model does not support references. Disconnect the references before generating.", - "errors.generationFailure.voiceNotFound": "The voice was not found or is unavailable to this account. Check the Voice ID in the MOSS voice library.", + "errors.generationFailure.voiceNotFound": "The voice was not found or is unavailable to this account. Check the Voice ID in the voice library.", "errors.rules.audioDisable": "The video adapter does not support disabling generated audio.", "errors.rules.duration": diff --git a/src/i18n/messages/zh-CN/errors.ts b/src/i18n/messages/zh-CN/errors.ts index 77a9cc13..f689b6d7 100644 --- a/src/i18n/messages/zh-CN/errors.ts +++ b/src/i18n/messages/zh-CN/errors.ts @@ -99,7 +99,7 @@ export const errors = { "errors.rules.voiceId": "请输入服务支持的音色标识(字母、数字、点、下划线或连字符)。", "errors.rules.voiceRequired": "请先填写服务音色库中有效的音色 ID,再开始生成。", "errors.rules.referencesUnsupported": "当前模型不支持参考素材,请断开参考后再生成。", - "errors.generationFailure.voiceNotFound": "该音色不存在或当前账户无权使用,请在 MOSS 音色库核对音色 ID。", + "errors.generationFailure.voiceNotFound": "该音色不存在或当前账户无权使用,请在音色库核对音色 ID。", "errors.rules.audioDisable": "当前视频适配器不支持关闭生成音频。", "errors.rules.duration": "当前模型不支持所选时长,请重新选择。", "errors.rules.resolution": "当前模型或参考模式不支持所选分辨率,请重新选择。", diff --git a/tests/browser/voice_library.mjs b/tests/browser/voice_library.mjs index 82fcfd49..a3c0c534 100644 --- a/tests/browser/voice_library.mjs +++ b/tests/browser/voice_library.mjs @@ -40,6 +40,12 @@ async function until(check) { throw Error("Expected persisted voice state was not observed"); } async function reopenAudio(page, assetPath, savedLabel) { + await waitForSaved(page, savedLabel); + // Acknowledge this page's lease release before reopening its persisted audio. + const released = page.waitForResponse(response => response.request().method() === "DELETE" && + new URL(response.url()).pathname.startsWith("/api/creation/sessions/")); + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent("pagehide"))); + assert.ok((await released).ok()); const started = Date.now(); const statuses = []; let receive, timer; @@ -141,6 +147,11 @@ const validWebm = liveWebm(2); const longWebm = liveWebm(31); const copy = { "en-US": { + settings: "Settings", models: "Model connections", audioOverview: "Audio model", + addService: "Add service", provider: "Provider", speech: "MiniMax Official Speech", + url: "Service URL (Base URL)", saveService: "Save service", manage: "Manage", + addModel: "Add manually", unsupportedModel: "Unsupported MiniMax speech model. Choose a model from the preset.", + cancel: "Cancel", closeSettings: "Close settings", tools: "Canvas creation tools", add: "Add Audio", model: "Generation model", @@ -175,6 +186,11 @@ const copy = { "The selected voice is no longer in this library. Choose another voice before generating.", }, "zh-CN": { + settings: "设置", models: "模型接入", audioOverview: "音频模型", + addService: "添加服务", provider: "提供商", speech: "MiniMax 官方语音", + url: "服务地址(Base URL)", saveService: "保存服务", manage: "管理", + addModel: "手动添加", unsupportedModel: "不支持该 MiniMax 语音型号,请选择预设中的型号。", + cancel: "取消", closeSettings: "关闭设置", tools: "画布创作工具", add: "添加音频", model: "生成模型", @@ -220,7 +236,7 @@ try { page.on("pageerror", (error) => errors.push(error.message)); page.on("request", (request) => { if ( - /^https?:\/\/(voice\.test|voice-b\.test|api\.mosi\.cn)/.test( + /^https?:\/\/(voice\.test|voice-b\.test|api\.mosi\.cn|api\.minimax\.cn)/.test( request.url(), ) ) @@ -921,6 +937,77 @@ try { (await call("/api/creation-test-health")).calls.length, calls.length, ); + + await page.getByRole("button", { name: L.settings, exact: true }).click(); + await page.getByRole("button", { name: L.models, exact: true }).click(); + await page.locator(".cw-model-setup-row") + .filter({ has: page.locator("strong", { hasText: new RegExp(`^${L.audioOverview}$`) }) }) + .getByRole("button").click(); + const service = page.locator(".cw-model-service").filter({ hasText: "https://api.minimax.cn/v1" }); + if (await service.count()) { + await service.getByRole("button", { name: L.manage, exact: true }).click(); + assert.equal(await page.getByLabel("API Key", { exact: true }).inputValue(), ""); + } else { + await page.getByRole("button", { name: L.addService, exact: true }).click(); + await page.getByRole("combobox", { name: L.provider, exact: true }).click(); + await page.getByRole("option", { name: L.speech, exact: true }).click(); + await page.getByLabel("API Key", { exact: true }).fill("minimax-browser-fixture-only"); + } + assert.equal(await page.getByRole("combobox", { name: L.provider, exact: true }).innerText(), L.speech); + assert.equal(await page.getByLabel(L.url, { exact: true }).inputValue(), "https://api.minimax.cn/v1"); + for (const model of ["speech-2.8-hd", "speech-2.8-turbo"]) + assert.equal(await page.getByRole("checkbox", { name: model, exact: true }).isChecked(), true); + for (const logo of await page.locator('.cw-model-option img[title="MiniMax"]').all()) { + await logo.evaluate(element => element.decode()); + assert.ok(await logo.evaluate(element => element.naturalWidth > 0)); + } + assert.equal(await page.locator('.cw-model-option img[title="MiniMax"]').count(), 2); + await page.screenshot({ path: `${output}/${locale}-minimax-provider.png`, fullPage: true }); + await page.getByRole("button", { name: L.addModel, exact: true }).click(); + await page.locator("#manual-model-id").fill("speech-2.8-unknown"); + await page.locator("#manual-model-id").press("Enter"); + await page.getByRole("button", { name: L.saveService, exact: true }).click(); + await page.getByRole("alert").filter({ hasText: L.unsupportedModel }).waitFor(); + await page.screenshot({ path: `${output}/${locale}-minimax-unsupported.png`, fullPage: true }); + await page.getByRole("checkbox", { name: "speech-2.8-unknown", exact: true }).uncheck(); + await page.getByRole("button", { name: L.saveService, exact: true }).click(); + await page.getByRole("button", { name: L.addService, exact: true }).waitFor(); + const minimax = (await call("/api/model-configs")).filter(model => model.inputRules.profile === "minimax-speech"); + assert.equal(minimax.length, 2); + assert.equal(new Set(minimax.map(model => model.inputRules.voiceLibrary.connectionId)).size, 1); + assert.ok(!JSON.stringify(minimax).includes("minimax-browser-fixture-only")); + await page.getByRole("button", { name: L.closeSettings, exact: true }).click(); + await selectModel(minimax[0]); + assert.equal((await params()).voiceSelection, undefined); + assert.equal(await page.getByRole("button", { name: L.generate, exact: true }).isDisabled(), true); + await open(); + await dialog().getByRole("button", { name: "Santa Claus", exact: true }).waitFor(); + assert.equal(await dialog().getByRole("button", { name: L.create, exact: true }).isDisabled(), true); + assert.equal(await dialog().getByRole("button", { name: L.delete, exact: true }).count(), 0); + await page.screenshot({ path: `${output}/${locale}-minimax-voices.png`, fullPage: true }); + await dialog().getByRole("button", { name: "Santa Claus", exact: true }).click(); + await until(async () => (await params()).voice === "Santa_Claus "); + await selectModel(minimax[1]); + assert.equal((await params()).voiceSelection.voiceId, "Santa_Claus "); + await page.getByRole("textbox", { name: L.prompt, exact: true }).fill("🎙".repeat(10000)); + assert.equal(await page.getByRole("button", { name: L.generate, exact: true }).isDisabled(), true); + await page.getByRole("textbox", { name: L.prompt, exact: true }).fill("Hello from MiniMax speech."); + const beforeMinimax = (await state()).jobs.map(job => job.id); + await page.getByRole("button", { name: L.generate, exact: true }).click(); + const speechJob = await until(async () => (await state()).jobs.find(job => + !beforeMinimax.includes(job.id) && job.status === "succeeded")); + assert.equal(speechJob.inputSnapshot.spec.parameters.voiceSelection.voiceId, "Santa_Claus "); + assert.equal(speechJob.execution.voice, "Santa_Claus "); + const speechAsset = (await state()).assets.find(asset => asset.id === speechJob.asset.assetId); + const speechPath = `/api/creation/projects/${id}/assets/${speechAsset.id}/content`; + assert.ok((await reopenAudio(page, speechPath, L.saved)).ok()); + await page.locator(".react-flow__node").first().click(); + assert.match(await page.getByRole("button", { name: L.library, exact: true }).innerText(), /Santa Claus/); + await page.waitForFunction(() => document.querySelector(".cw-audio-preview audio")?.readyState > 0); + await player.evaluate(audio => audio.play()); + await page.waitForFunction(() => document.querySelector(".cw-audio-preview audio")?.ended); + assert.ok(await player.evaluate(audio => audio.currentTime > 0 && audio.error === null)); + await page.screenshot({ path: `${output}/${locale}-minimax-played.png`, fullPage: true }); } catch (error) { await page .screenshot({ path: `${output}/${locale}-failure.png`, fullPage: true }) diff --git a/tests/browser/voice_library_server.py b/tests/browser/voice_library_server.py index 8cb8a543..94c95b5e 100644 --- a/tests/browser/voice_library_server.py +++ b/tests/browser/voice_library_server.py @@ -4,6 +4,7 @@ pass real ffprobe validation, and generation still validates the voice registry. """ import io +import json from email.parser import BytesParser from email.policy import default from pathlib import Path @@ -25,6 +26,13 @@ def provider(request): calls.append(dict(method=request.method, url=str(request.url))) + if request.url.host == 'api.minimax.cn': + assert request.method == 'POST' and request.url.path == '/v1/get_voice' + assert json.loads(request.content) == {'voice_type': 'system'} + return httpx.Response(200, json={'base_resp': {'status_code': 0}, 'system_voice': [ + {'voice_id': 'Chinese (Mandarin)_Reliable_Executive', 'voice_name': 'Narrator'}, + {'voice_id': 'Santa_Claus ', 'voice_name': 'Santa Claus'}, + ]}) cloud = request.url.host == 'api.mosi.cn' boundary = request.url.host records = uploads.setdefault(boundary, {}) diff --git a/tests/generationFailureText.test.ts b/tests/generationFailureText.test.ts index 362c9b85..e71fa00b 100644 --- a/tests/generationFailureText.test.ts +++ b/tests/generationFailureText.test.ts @@ -126,10 +126,10 @@ test("voice_not_found identifies the voice before generic 404 or input failures" try { setActiveLocale("en-US"); assert.equal(generationFailureMessage(failure({ providerCode: "voice_not_found", httpStatus: 404 })), - "The voice was not found or is unavailable to this account. Check the Voice ID in the MOSS voice library."); + "The voice was not found or is unavailable to this account. Check the Voice ID in the voice library."); setActiveLocale("zh-CN"); assert.equal(generationFailureMessage(failure({ providerCode: "voice_not_found", httpStatus: 404 })), - "该音色不存在或当前账户无权使用,请在 MOSS 音色库核对音色 ID。"); + "该音色不存在或当前账户无权使用,请在音色库核对音色 ID。"); } finally { setActiveLocale("zh-CN"); } @@ -153,3 +153,15 @@ test("provider moderation verdicts are explained in both languages", () => { /生成内容涉及敏感信息/, ); }); + +test("MiniMax missing voices use the shared localized message", () => { + const value = failure({ providerCode: "2054", httpStatus: 200, message: "所选音色不存在或不可用。" }); + try { + setActiveLocale("en-US"); + assert.match(generationFailureMessage(value), /voice library/); + assert.doesNotMatch(generationFailureMessage(value), /MOSS|[\u3400-\u9fff]/); + setActiveLocale("zh-CN"); + assert.match(generationFailureMessage(value), /音色库/); + assert.doesNotMatch(generationFailureMessage(value), /MOSS/); + } finally { setActiveLocale("zh-CN"); } +}); diff --git a/tests/modelBrands.test.ts b/tests/modelBrands.test.ts index b82ce537..9edfab38 100644 --- a/tests/modelBrands.test.ts +++ b/tests/modelBrands.test.ts @@ -8,6 +8,9 @@ import { modelBrand } from "../src/canvas/modelBrands.ts"; test("model brands recognize organizations and known bare names without assigning unknown aliases", () => { assert.equal(modelBrand("MiniMaxAI/MiniMax-H3")?.org, "MiniMaxAI"); assert.equal(modelBrand("MiniMax-H3")?.org, "MiniMaxAI"); + assert.equal(modelBrand("speech-2.8-hd")?.org, "MiniMaxAI"); + assert.equal(modelBrand("speech-02-turbo")?.org, "MiniMaxAI"); + assert.equal(modelBrand("speech-custom"), undefined); assert.equal(modelBrand("kling-4.0")?.file, "kling.svg"); assert.equal(modelBrand("kling-4.0-flash")?.file, "kling.svg"); assert.equal(modelBrand("deepseek-ai/DeepSeek-V3")?.name, "DeepSeek"); diff --git a/tests/providerPresets.test.ts b/tests/providerPresets.test.ts index 6c66bf57..1209d564 100644 --- a/tests/providerPresets.test.ts +++ b/tests/providerPresets.test.ts @@ -175,3 +175,21 @@ test("MiniMax H3 image preset exposes both models in both display languages", () setActiveLocale(previous); } }); + +test("MiniMax speech preset and saved identity follow the display language", () => { + const previous = getActiveLocale(); + try { + const preset = PROVIDER_PRESETS.find(item => item.id === "minimax-speech")!; + assert.equal(preset.url, "https://api.minimax.cn/v1"); + assert.deepEqual(preset.kinds, ["audio"]); + assert.deepEqual(preset.modelsByKind?.audio, ["speech-2.8-hd", "speech-2.8-turbo"]); + for (const [locale, name] of [["zh-CN", "MiniMax 官方语音"], ["en-US", "MiniMax Official Speech"]] as const) { + setActiveLocale(locale); + assert.equal(providerOptions("audio").find(item => item.id === preset.id)?.name, name); + const model: ModelConfig = { ...preset, id: "saved-minimax", kind: "audio", modelId: "speech-2.8-hd", + inputRules: { profile: "minimax-speech", allowedKinds: [], maxImages: 0, maxVideos: 0, maxAudios: 0, videoModes: [] } }; + assert.equal(providerPresetFor(model), preset.id); + assert.equal(providerPresetFor({ ...model, url: "https://api.minimax.io/v1" }), "custom"); + } + } finally { setActiveLocale(previous); } +}); diff --git a/tests/test_minimax_speech.py b/tests/test_minimax_speech.py new file mode 100644 index 00000000..437b6864 --- /dev/null +++ b/tests/test_minimax_speech.py @@ -0,0 +1,457 @@ +"""MiniMax T2A wire contract and real MP3 persistence with mocked provider HTTP.""" +import asyncio +import json +import socket +from uuid import uuid4 + +import httpx +import pytest +from fastapi.testclient import TestClient + +from generation_helpers import command, prepared, settled +from opendirector.agent.tools import AgentTools +from opendirector.application.creation_assets import CreationAssets +from opendirector.application.creation_canvas import asset_ref +from opendirector.application.creation_generation import CreationGeneration +from opendirector.application.creation_tool_assets import CreationToolAssets +from opendirector.application.voice_library import VoiceLibrary +from opendirector.domain.capabilities import SpeechRequest +from opendirector.domain.creation.generation import ResolvedReference, validate_generation +from opendirector.domain.creation.models import ModelConfigPublic +from opendirector.domain.generation import GenerationError +from opendirector.domain.input_rules import input_rules +from opendirector.domain.projects import ProjectError +from opendirector.infrastructure.creation_projects import CreationProjects +from opendirector.infrastructure.local_projects import LocalProjects +from opendirector.infrastructure.model_configs import ModelConfigs +from opendirector.integrations.model_adapters import MinimaxSpeech, OpenAISpeech, adapter_for +from opendirector.integrations.providers import GenerationProviders +from opendirector.integrations.reference_inputs import generation_references +from opendirector_api.main import create_app +from test_creation_canvas_service import project +from test_creation_generation_service import anyio_backend +from test_moss import mp3 + +URL = 'https://api.minimax.cn/v1' +RESULT = 'https://93.184.215.14/speech.mp3?signature=fixture-result-signature' +SECRET = 'minimax-test-key' +VOICE = 'Chinese (Mandarin)_Reliable_Executive' +CONFIG = dict(kind='audio', url=URL, modelId='speech-2.8-hd', apiKey=SECRET, + protocol='openai', connectionType='api', authMode='bearer') +SUCCESS = dict(base_resp={'status_code': 0, 'status_msg': 'success'}, data={'audio': RESULT, 'status': 2}) +VOICES = dict(base_resp={'status_code': 0}, system_voice=[ + {'voice_id': VOICE, 'voice_name': 'Narrator'}, {'voice_id': 'Santa_Claus ', 'voice_name': 'Santa Claus'}, +]) + + +@pytest.fixture +def context(project, tmp_path): + configs = ModelConfigs(tmp_path) + saved = configs.save('audio', URL, CONFIG['modelId'], SECRET, protocol='openai') + return project, configs, saved + + +def runner_for(context, handle): + project, configs, _ = context + runner = CreationGeneration(project[0], configs) + transport = httpx.MockTransport(handle) + runner.provider = GenerationProviders(runner.store, transport, retry_delays=(0, 0)) + runner.voices = VoiceLibrary(configs, transport) + return runner + + +def selection(saved, voice=VOICE): + return dict(voice=voice, voiceSelection=dict(voiceId=voice, name='Narrator', source='builtin', + hasReferenceAudio=False, connectionId=saved['inputRules']['voiceLibrary']['connectionId'])) + + +@pytest.mark.parametrize('model', ['speech-2.8-hd', 'speech-2.8-turbo', 'speech-2.6-hd', + 'speech-2.6-turbo', 'speech-02-hd', 'speech-02-turbo', 'speech-01-hd', 'speech-01-turbo']) +def test_official_models_have_read_only_voice_library_and_shared_rules(model): + config = {**CONFIG, 'modelId': model, 'url': URL + '/'} + rules = input_rules(config) + assert isinstance(adapter_for(config), MinimaxSpeech) + assert rules['profile'] == 'minimax-speech' + assert rules['voiceRequired'] and rules['referencesAllowed'] is False + assert rules['maxPromptCharacters'] == 9999 and rules['allowedKinds'] == [] + library = rules['voiceLibrary'] + assert library['provider'] == 'minimax' + assert not library['supportsUploadedVoices'] and not library['canDelete'] + assert not library['requiresReferenceAudio'] and not library['requiresTranscript'] + + +@pytest.mark.parametrize('patch', [ + {'url': 'https://api.minimax.cn.evil.test/v1'}, {'url': 'https://api.minimax.io/v1'}, + {'url': 'http://api.minimax.cn/v1'}, {'url': 'https://api.minimax.cn:8443/v1'}, + {'url': 'https://api.minimax.cn/v2'}, + {'connectionType': 'self_hosted'}, +]) +def test_other_services_and_lookalikes_keep_the_generic_adapter(patch): + config = {**CONFIG, **patch} + assert isinstance(adapter_for(config), OpenAISpeech) + assert input_rules(config)['profile'] != 'minimax-speech' + + +@pytest.mark.parametrize('entry', [input_rules, adapter_for]) +def test_unknown_official_model_is_rejected(entry): + with pytest.raises(ProjectError) as failure: + entry({**CONFIG, 'modelId': 'speech-2.8-unknown'}) + assert failure.value.code == 'minimax_speech_model' + + +@pytest.mark.parametrize('batch', [False, True]) +@pytest.mark.parametrize('locale, message', [ + ('zh-CN', '不支持该 MiniMax 语音型号,请选择预设中的型号。'), + ('en-US', 'Unsupported MiniMax speech model. Choose a model from the preset.'), +]) +def test_unknown_model_save_is_localized_and_atomic(context, batch, locale, message): + project, configs, _ = context + before = configs.path.read_bytes() + payload = {**CONFIG, 'modelId': 'speech-2.8-unknown'} + if batch: + payload['modelIds'] = ['speech-2.8-hd', payload.pop('modelId')] + with TestClient(create_app(project[0].local), headers={ + 'X-OpenDirector-Request': '1', 'Accept-Language': locale, + }) as client: + response = client.post('/api/model-configs' + ('/batch' if batch else ''), json=payload) + assert response.status_code == 422 + assert response.json()['detail'] == message + assert configs.path.read_bytes() == before + + +def test_system_voices_preserve_ids_and_account_scope(context): + project, configs, saved = context + calls = [] + def handle(request): + calls.append(request) + assert request.method == 'POST' and str(request.url) == URL + '/get_voice' + assert json.loads(request.content) == {'voice_type': 'system'} + assert request.headers['authorization'] == 'Bearer ' + SECRET + return httpx.Response(200, json=VOICES) + library = VoiceLibrary(configs, httpx.MockTransport(handle)) + listed = library.list(saved['id']) + assert [voice['voiceId'] for voice in listed['voices']] == [VOICE, 'Santa_Claus '] + assert listed['nextCursor'] is None + assert all(voice['compatible'] and voice['source'] == 'builtin' and not voice['canDelete'] + and voice['previewUrl'] is None for voice in listed['voices']) + private = configs.resolve(saved['id'], 'audio') + library.validate_selection(private, 'Santa_Claus ', listed['voices'][1]) + with pytest.raises(ProjectError) as failure: + library.validate_selection(private, VOICE, {**listed['voices'][0], 'connectionId': 'stale'}) + assert failure.value.code == 'voice_connection_changed' + with pytest.raises(ProjectError) as failure: + library.create(saved['id'], b'', filename='voice.wav', name='Test') + assert failure.value.code == 'voice_model_unsupported' + with pytest.raises(ProjectError) as failure: + library.delete(saved['id'], VOICE, listed['connectionId']) + assert failure.value.code == 'voice_delete_unsupported' + assert len(calls) == 2 + assert SECRET not in json.dumps(saved) + json.dumps(listed) + business = AgentTools(project[0], None, configs) + business.voices = library + assert asyncio.run(business.execute(project[1], uuid4(), project[2], 'voice-list', 'voice_list', + {'modelConfigId': saved['id']}, [], None)) == listed + + +@pytest.mark.parametrize('body', [{}, {'base_resp': {'status_code': False}, 'system_voice': []}, + {'base_resp': {'status_code': 0}, 'system_voice': None}, + {'base_resp': {'status_code': 0}, 'system_voice': [{'voice_id': '../voice'}]}, + {'base_resp': {'status_code': 0}, 'system_voice': [VOICES['system_voice'][0]] * 2}]) +def test_voice_library_rejects_malformed_catalog(context, body): + _, configs, saved = context + library = VoiceLibrary(configs, httpx.MockTransport(lambda _: httpx.Response(200, json=body))) + with pytest.raises(ProjectError) as failure: + library.list(saved['id']) + assert failure.value.code == 'voice_provider_protocol' + + +def test_voice_library_error_does_not_echo_provider_secrets(context): + _, configs, saved = context + body = {'base_resp': {'status_code': 1004, 'status_msg': SECRET + RESULT}} + library = VoiceLibrary(configs, httpx.MockTransport(lambda _: httpx.Response(200, json=body))) + with pytest.raises(ProjectError) as failure: + library.list(saved['id']) + assert failure.value.code == 'voice_provider_error' + assert SECRET not in str(failure.value) and 'fixture-result-signature' not in str(failure.value) + + +@pytest.mark.anyio +@pytest.mark.parametrize('voice', [VOICE, 'Santa_Claus ']) +async def test_exact_wire_request_checkpoint_and_credential_free_download(mp3, voice): + calls, checkpoints = [], [] + async def ready(suffix, url): + checkpoints.append((suffix, url)) + def handle(request): + calls.append(request) + if request.method == 'POST': + assert str(request.url) == URL + '/t2a_v2' + assert request.headers['authorization'] == 'Bearer ' + SECRET + assert json.loads(request.content) == dict(model='speech-2.8-hd', text='🎙' * 9999, + stream=False, voice_setting={'voice_id': voice}, audio_setting={'format': 'mp3'}, output_format='url') + return httpx.Response(200, json=SUCCESS) + assert checkpoints == [('mp3', RESULT)] + assert str(request.url) == RESULT and 'authorization' not in request.headers + return httpx.Response(200, content=mp3) + adapter = adapter_for(CONFIG, httpx.MockTransport(handle)) + result = await adapter.speech(CONFIG, SpeechRequest('🎙' * 9999, voice), result_ready=ready) + assert result.data == mp3 and result.suffix == 'mp3' + assert [call.method for call in calls] == ['POST', 'GET'] + + +@pytest.mark.anyio +@pytest.mark.parametrize('body', [{}, {'base_resp': {'status_code': False}, 'data': SUCCESS['data']}, + {'base_resp': {'status_code': 0}, 'data': None}, + {'base_resp': {'status_code': 0}, 'data': {'audio': RESULT, 'status': 1}}, + {'base_resp': {'status_code': 0}, 'data': {'audio': 'file:///tmp/voice.mp3', 'status': 2}}, + {'base_resp': {'status_code': 0}, 'data': {'audio': '494433', 'status': 2}}]) +async def test_malformed_or_incomplete_submission_is_unknown_and_never_reposted(body): + calls = [] + def handle(request): + calls.append(request) + return httpx.Response(200, json=body) + with pytest.raises(GenerationError) as failure: + await MinimaxSpeech(httpx.MockTransport(handle)).speech(CONFIG, SpeechRequest('Read this', VOICE)) + assert failure.value.details.category == 'submission_unknown' + assert len(calls) == 1 and calls[0].method == 'POST' + + +@pytest.mark.anyio +@pytest.mark.parametrize(('code', 'category'), [(1004, 'authentication'), (1008, 'quota'), + (1002, 'rate_limit'), (2013, 'input'), (2054, 'input'), (1026, 'input'), (1001, 'temporary')]) +async def test_http_200_provider_errors_are_safe_and_not_retried(code, category): + calls = [] + def handle(request): + calls.append(request) + return httpx.Response(200, json={'base_resp': {'status_code': code, 'status_msg': SECRET + RESULT}, + 'trace_id': 'trace-fixture-1'}) + with pytest.raises(GenerationError) as failure: + await MinimaxSpeech(httpx.MockTransport(handle)).speech(CONFIG, SpeechRequest('Read this', VOICE)) + details = failure.value.details + assert details.category == category and details.providerCode == str(code) + assert details.requestId == 'trace-fixture-1' and len(calls) == 1 + assert SECRET not in details.model_dump_json() and 'fixture-result-signature' not in details.model_dump_json() + + +@pytest.mark.anyio +async def test_submission_timeout_never_reposts(): + calls = [] + def handle(request): + calls.append(request) + raise httpx.ReadTimeout('unknown acceptance', request=request) + with pytest.raises(GenerationError) as failure: + await MinimaxSpeech(httpx.MockTransport(handle)).speech(CONFIG, SpeechRequest('Read this', VOICE)) + assert failure.value.details.category == 'submission_unknown' and len(calls) == 1 + + +@pytest.mark.anyio +@pytest.mark.parametrize(('text', 'voice'), [('', VOICE), (' \n', VOICE), ('Read this', None), + ('Read this', 'bad\nvoice'), ('Read this', ' '), ('x' * 10000, VOICE)], + ids=['empty', 'blank', 'missing-voice', 'control-character', 'blank-voice', 'text-limit']) +async def test_direct_adapter_rejects_invalid_inputs_without_http(text, voice): + adapter = MinimaxSpeech(httpx.MockTransport(lambda _: pytest.fail('invalid input reached HTTP'))) + with pytest.raises(ProjectError): + await adapter.speech(CONFIG, SpeechRequest(text, voice)) + + +@pytest.mark.anyio +@pytest.mark.parametrize('url', ['http://127.0.0.1/voice.mp3', 'http://169.254.169.254/latest/meta-data/', + 'http://[::1]/voice.mp3']) +async def test_result_download_rejects_private_addresses(url): + adapter = MinimaxSpeech(httpx.MockTransport(lambda _: pytest.fail('private destination reached HTTP'))) + with pytest.raises(GenerationError): + await adapter.download(CONFIG, url) + + +@pytest.mark.parametrize('source', ['ui', 'agent']) +@pytest.mark.parametrize(('prompt', 'voice', 'code'), [('Read this', None, 'minimax_speech_voice'), + ('🎙' * 10000, VOICE, 'minimax_speech_text')], ids=['missing-voice', 'text-limit']) +def test_preflight_rejects_input_before_http_or_job(context, source, prompt, voice, code): + project, _, saved = context + runner = runner_for(context, lambda _: pytest.fail('invalid input reached HTTP')) + request = command(project, saved, prompt=prompt, parameters=selection(saved, voice) if voice else {}) + before = project[0].snapshot(project[1]) + receipt = runner.prepare(request, session=project[2], source=source) + assert receipt.status == 'rejected' and receipt.issues[0].code == code + assert not receipt.jobIds and project[0].snapshot(project[1]) == before + assert runner.prepare(request, session=project[2], source=source) == receipt + + +@pytest.mark.parametrize('transmission', ['content', 'context_only']) +def test_reference_boundaries_reject_even_context_only_inputs(context, transmission): + project, _, saved = context + store, pid, session = project + asset = CreationToolAssets(store).import_file(str(pid), str(uuid4()), 'note.md', b'Reference words', session) + runner = runner_for(context, lambda _: pytest.fail('references reached HTTP')) + request = command(project, saved, parameters=selection(saved)) + snapshot = store.snapshot(pid) + spec = next(item for item in snapshot.generationSpecs if item.id == request.payload.specId) + reference = ResolvedReference(referenceId=uuid4(), role='reference', asset=asset_ref(snapshot.assets[0]), + metadata=snapshot.assets[0].metadata, transmission=transmission) + with pytest.raises(ValueError, match='参考'): + validate_generation(spec, ModelConfigPublic.model_validate(saved), (reference,)) + with pytest.raises(ProjectError) as failure: + generation_references(runner.store, dict(kind='audio', projectId=str(pid), prompt='Read this', + referenceIds=[asset['id']], parameters={}), CONFIG) + assert failure.value.code == 'minimax_speech_reference' and not store.snapshot(pid).jobs + + +def test_http_receipt_replay_switches_language_without_changing_storage(context): + project, _, saved = context + store, pid, session = project + request = command(project, saved, prompt='Read this') + headers = {'X-OpenDirector-Request': '1', 'X-OpenDirector-Session': session} + path = f'/api/creation/projects/{pid}' + with TestClient(create_app(store.local), headers=headers) as client: + response = client.post(path + '/commands', json=request.model_dump(mode='json'), headers={'Accept-Language': 'en-US'}) + assert response.status_code == 200 + receipt = response.json() + assert receipt['issues'][0]['code'] == 'minimax_speech_voice' + assert receipt['issues'][0]['message'] == 'Choose a voice available to your MiniMax account from the voice library.' + assert client.get(path + f'/receipts/{request.commandId}', headers={'Accept-Language': 'en-US'}).json() == receipt + chinese = client.get(path + f'/receipts/{request.commandId}', headers={'Accept-Language': 'zh-CN'}).json() + assert '音色' in chinese['issues'][0]['message'] + assert chinese == store.receipt(pid, request.commandId).model_dump(mode='json') + assert not store.snapshot(pid).jobs + + +@pytest.mark.anyio +@pytest.mark.parametrize('source', ['ui', 'agent']) +async def test_real_mp3_snapshot_adoption_replay_and_reopen(context, mp3, source): + project, _, saved = context + store, pid, session = project + calls = [] + def handle(request): + calls.append(request) + if request.url.path == '/v1/get_voice': return httpx.Response(200, json=VOICES) + if request.method == 'POST': return httpx.Response(200, json=SUCCESS) + return httpx.Response(200, content=mp3) + runner = runner_for(context, handle) + try: + request = command(project, saved, prompt='Read this', parameters=selection(saved, 'Santa_Claus ')) + receipt = await runner.execute(request, session=session, source=source) + assert receipt.status == 'accepted' + await settled(runner) + snapshot = store.snapshot(pid) + [job] = snapshot.jobs + assert job.status == 'succeeded' and job.adopted + assert job.inputSnapshot.spec.parameters.voice == job.execution.voice == 'Santa_Claus ' + assert job.execution.format == 'mp3' + assert job.adapterParameters == dict(voice='Santa_Claus ', format='mp3', output_format='url', stream=False) + [asset] = snapshot.assets + assert asset.metadata.mimeType == 'audio/mpeg' and asset.metadata.durationSeconds > 0 + stream, _ = CreationAssets(store).open_media(pid, asset.id) + with stream: assert stream.read() == mp3 + assert await runner.execute(request, session=session, source=source) == receipt + assert sum(call.url.path == '/v1/t2a_v2' for call in calls) == 1 + assert SECRET not in snapshot.model_dump_json() and 'fixture-result-signature' not in snapshot.model_dump_json() + finally: + await runner.close() + assert CreationProjects(LocalProjects(store.local.repository)).snapshot(pid) == snapshot + + +@pytest.mark.anyio +@pytest.mark.parametrize('configuration_change', ['deleted', 'changed']) +async def test_failed_download_recovers_after_model_removal_without_resubmitting(context, mp3, configuration_change): + project, configs, saved = context + store, pid, session = project + calls, healthy = [], False + def handle(request): + calls.append(request) + if request.url.path == '/v1/get_voice': return httpx.Response(200, json=VOICES) + if request.method == 'POST': return httpx.Response(200, json=SUCCESS) + assert str(request.url) == RESULT and 'authorization' not in request.headers + if not healthy: raise httpx.ReadTimeout('download outage', request=request) + return httpx.Response(200, content=mp3) + first = runner_for(context, handle) + try: + first._launch(*prepared(first, project, saved, prompt='Read this', parameters=selection(saved))) + await settled(first) + [failed] = store.snapshot(pid).jobs + assert failed.recovery == 'download' and failed.resumeAllowed + assert not store.snapshot(pid).assets + finally: + await first.close() + if configuration_change == 'deleted': + configs.delete(saved['id']) + else: + configs.save('audio', 'https://changed.test/v1', 'tts-1', 'different-key', saved['id'], protocol='openai') + healthy = True + reopened = CreationProjects(LocalProjects(store.local.repository)) + restarted = runner_for(((reopened, pid, session), configs, saved), handle) + try: + await restarted.start() + await settled(restarted) + snapshot = reopened.snapshot(pid) + assert snapshot.jobs[0].id == failed.id and snapshot.jobs[0].status == 'succeeded' + assert len(snapshot.assets) == len(snapshot.results) == 1 + assert sum(call.url.path == '/v1/t2a_v2' for call in calls) == 1 + finally: + await restarted.close() + + +@pytest.mark.anyio +@pytest.mark.parametrize('configuration_change', ['deleted', 'changed']) +@pytest.mark.parametrize('destination', ['private', 'dns', 'redirect']) +async def test_recovery_keeps_public_download_checks(context, mp3, monkeypatch, configuration_change, destination): + project, configs, saved = context + store, pid, _ = project + calls, recovering = [], False + url = 'http://127.0.0.1/private' if destination == 'private' else 'https://cdn.test/speech.mp3' + monkeypatch.setattr(socket, 'getaddrinfo', lambda host, port, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, '', + ('127.0.0.1' if recovering and destination == 'dns' else '93.184.215.14', port)), + ]) + def handle(request): + calls.append(request) + if request.url.path == '/v1/get_voice': return httpx.Response(200, json=VOICES) + if request.method == 'POST': + return httpx.Response(200, json={**SUCCESS, 'data': {'audio': url, 'status': 2}}) + if not recovering: raise httpx.ReadTimeout('download outage', request=request) + if destination == 'redirect' and request.url.path != '/private': + return httpx.Response(302, headers={'Location': 'http://127.0.0.1/private'}) + return httpx.Response(200, content=mp3) + first = runner_for(context, handle) + try: + first._launch(*prepared(first, project, saved, prompt='Read this', parameters=selection(saved))) + await settled(first) + [job] = store.snapshot(pid).jobs + assert job.recovery == 'download' + finally: + await first.close() + if configuration_change == 'deleted': + configs.delete(saved['id']) + else: + configs.save('audio', 'https://changed.test/v1', 'tts-1', 'different-key', saved['id'], protocol='openai') + recovering = True + before = len(calls) + restarted = runner_for(context, handle) + try: + await restarted.start() + await settled(restarted) + snapshot = store.snapshot(pid) + assert snapshot.jobs[0].errorDetails.category == 'protocol' + assert snapshot.jobs[0].errorDetails.stage == 'download' + assert len(calls) - before == (1 if destination == 'redirect' else 0) + assert all(call.url.host == '93.184.215.14' and 'authorization' not in call.headers + for call in calls if call.method == 'GET') + assert not snapshot.assets and not snapshot.results + assert sum(call.url.path == '/v1/t2a_v2' for call in calls) == 1 + finally: + await restarted.close() + + +@pytest.mark.anyio +async def test_invalid_mp3_never_publishes_an_asset(context): + project, _, saved = context + def handle(request): + if request.url.path == '/v1/get_voice': return httpx.Response(200, json=VOICES) + if request.method == 'POST': return httpx.Response(200, json=SUCCESS) + return httpx.Response(200, content=b'ID3not-decodable-mp3') + runner = runner_for(context, handle) + try: + await runner.execute(command(project, saved, parameters=selection(saved)), session=project[2]) + await settled(runner) + snapshot = project[0].snapshot(project[1]) + assert snapshot.jobs[0].status == 'failed' and not snapshot.assets and not snapshot.results + finally: + await runner.close()