Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/src/opendirector_api/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/opendirector/domain/image_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/opendirector/domain/input_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/opendirector/domain/minimax_image.py
Original file line number Diff line number Diff line change
@@ -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')
75 changes: 70 additions & 5 deletions packages/core/src/opendirector/integrations/model_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand Down Expand Up @@ -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':
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/opendirector/integrations/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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':
Expand All @@ -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'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/canvas/providerPresets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const EN_NAMES: Record<string, string> = {
"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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/contracts/creation.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading