mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-03-17 10:27:38 +08:00
feat: 独立角色创建模型,完善角色创建结果信息;改进错误重试逻辑;集成POW服务
This commit is contained in:
@@ -55,6 +55,8 @@ async def list_models(api_key: str = Depends(verify_api_key_header)):
|
|||||||
description += f" - {config['width']}x{config['height']}"
|
description += f" - {config['width']}x{config['height']}"
|
||||||
elif config['type'] == 'video':
|
elif config['type'] == 'video':
|
||||||
description += f" - {config['orientation']}"
|
description += f" - {config['orientation']}"
|
||||||
|
elif config['type'] == 'avatar_create':
|
||||||
|
description += " - create avatar from video"
|
||||||
elif config['type'] == 'prompt_enhance':
|
elif config['type'] == 'prompt_enhance':
|
||||||
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
|
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
|
||||||
|
|
||||||
@@ -105,18 +107,22 @@ async def create_chat_completion(
|
|||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
# Simple string format
|
# Simple string format
|
||||||
prompt = content
|
prompt = content
|
||||||
# Extract remix_target_id from prompt if not already provided
|
# Extract sora id from prompt if not already provided
|
||||||
if not remix_target_id:
|
extracted_id = _extract_remix_id(prompt)
|
||||||
remix_target_id = _extract_remix_id(prompt)
|
if extracted_id:
|
||||||
|
if not remix_target_id:
|
||||||
|
remix_target_id = extracted_id
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
# Array format (OpenAI multimodal)
|
# Array format (OpenAI multimodal)
|
||||||
for item in content:
|
for item in content:
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
if item.get("type") == "text":
|
if item.get("type") == "text":
|
||||||
prompt = item.get("text", "")
|
prompt = item.get("text", "")
|
||||||
# Extract remix_target_id from prompt if not already provided
|
# Extract sora id from prompt if not already provided
|
||||||
if not remix_target_id:
|
extracted_id = _extract_remix_id(prompt)
|
||||||
remix_target_id = _extract_remix_id(prompt)
|
if extracted_id:
|
||||||
|
if not remix_target_id:
|
||||||
|
remix_target_id = extracted_id
|
||||||
elif item.get("type") == "image_url":
|
elif item.get("type") == "image_url":
|
||||||
# Extract base64 image from data URI
|
# Extract base64 image from data URI
|
||||||
image_url = item.get("image_url", {})
|
image_url = item.get("image_url", {})
|
||||||
@@ -149,7 +155,7 @@ async def create_chat_completion(
|
|||||||
|
|
||||||
# Check if this is a video model
|
# Check if this is a video model
|
||||||
model_config = MODEL_CONFIG[request.model]
|
model_config = MODEL_CONFIG[request.model]
|
||||||
is_video_model = model_config["type"] == "video"
|
is_video_model = model_config["type"] in ["video", "avatar_create"]
|
||||||
|
|
||||||
# For video models with video parameter, we need streaming
|
# For video models with video parameter, we need streaming
|
||||||
if is_video_model and (video_data or remix_target_id):
|
if is_video_model and (video_data or remix_target_id):
|
||||||
|
|||||||
@@ -207,6 +207,12 @@ MODEL_CONFIG = {
|
|||||||
"type": "prompt_enhance",
|
"type": "prompt_enhance",
|
||||||
"expansion_level": "long",
|
"expansion_level": "long",
|
||||||
"duration_s": 20
|
"duration_s": 20
|
||||||
|
},
|
||||||
|
# Avatar creation model (character creation only)
|
||||||
|
"avatar-create": {
|
||||||
|
"type": "avatar_create",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"n_frames": 300
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +271,13 @@ class GenerationHandler:
|
|||||||
return False
|
return False
|
||||||
if "429" in error_str or "rate limit" in error_str:
|
if "429" in error_str or "rate limit" in error_str:
|
||||||
return False
|
return False
|
||||||
|
# 参数/模型使用错误无需重试
|
||||||
|
if "invalid model" in error_str:
|
||||||
|
return False
|
||||||
|
if "avatar-create" in error_str:
|
||||||
|
return False
|
||||||
|
if "参数错误" in error_str:
|
||||||
|
return False
|
||||||
|
|
||||||
# 其他所有错误都可以重试
|
# 其他所有错误都可以重试
|
||||||
return True
|
return True
|
||||||
@@ -299,6 +312,20 @@ class GenerationHandler:
|
|||||||
|
|
||||||
return final_username
|
return final_username
|
||||||
|
|
||||||
|
def _extract_generation_id(self, text: str) -> str:
|
||||||
|
"""Extract generation ID from text.
|
||||||
|
|
||||||
|
Supported format: gen_[a-zA-Z0-9]+
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
match = re.search(r'gen_[a-zA-Z0-9]+', text)
|
||||||
|
if match:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
def _clean_remix_link_from_prompt(self, prompt: str) -> str:
|
def _clean_remix_link_from_prompt(self, prompt: str) -> str:
|
||||||
"""Remove remix link from prompt
|
"""Remove remix link from prompt
|
||||||
|
|
||||||
@@ -429,9 +456,10 @@ class GenerationHandler:
|
|||||||
raise ValueError(f"Invalid model: {model}")
|
raise ValueError(f"Invalid model: {model}")
|
||||||
|
|
||||||
model_config = MODEL_CONFIG[model]
|
model_config = MODEL_CONFIG[model]
|
||||||
is_video = model_config["type"] == "video"
|
is_video = model_config["type"] in ["video", "avatar_create"]
|
||||||
is_image = model_config["type"] == "image"
|
is_image = model_config["type"] == "image"
|
||||||
is_prompt_enhance = model_config["type"] == "prompt_enhance"
|
is_prompt_enhance = model_config["type"] == "prompt_enhance"
|
||||||
|
is_avatar_create = model_config["type"] == "avatar_create"
|
||||||
|
|
||||||
# Handle prompt enhancement
|
# Handle prompt enhancement
|
||||||
if is_prompt_enhance:
|
if is_prompt_enhance:
|
||||||
@@ -445,40 +473,50 @@ class GenerationHandler:
|
|||||||
if available:
|
if available:
|
||||||
if is_image:
|
if is_image:
|
||||||
message = "All tokens available for image generation. Please enable streaming to use the generation feature."
|
message = "All tokens available for image generation. Please enable streaming to use the generation feature."
|
||||||
|
elif is_avatar_create:
|
||||||
|
message = "All tokens available for avatar creation. Please enable streaming to create avatar."
|
||||||
else:
|
else:
|
||||||
message = "All tokens available for video generation. Please enable streaming to use the generation feature."
|
message = "All tokens available for video generation. Please enable streaming to use the generation feature."
|
||||||
else:
|
else:
|
||||||
if is_image:
|
if is_image:
|
||||||
message = "No available models for image generation"
|
message = "No available models for image generation"
|
||||||
|
elif is_avatar_create:
|
||||||
|
message = "No available tokens for avatar creation"
|
||||||
else:
|
else:
|
||||||
message = "No available models for video generation"
|
message = "No available models for video generation"
|
||||||
|
|
||||||
yield self._format_non_stream_response(message, is_availability_check=True)
|
yield self._format_non_stream_response(message, is_availability_check=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Handle character creation and remix flows for video models
|
# Handle avatar creation model (character creation only)
|
||||||
if is_video:
|
if is_avatar_create:
|
||||||
|
# Priority: video > prompt内generation_id(gen_xxx)
|
||||||
|
if video:
|
||||||
|
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
|
||||||
|
async for chunk in self._handle_character_creation_only(video_data, model_config):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
|
||||||
|
# generation_id 仅从提示词解析
|
||||||
|
source_generation_id = self._extract_generation_id(prompt) if prompt else None
|
||||||
|
if source_generation_id:
|
||||||
|
async for chunk in self._handle_character_creation_from_generation_id(source_generation_id, model_config):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
|
||||||
|
raise Exception("avatar-create 模型需要传入视频文件,或在提示词中包含 generation_id(gen_xxx)。")
|
||||||
|
|
||||||
|
# Handle remix flow for regular video models
|
||||||
|
if model_config["type"] == "video":
|
||||||
# Remix flow: remix_target_id provided
|
# Remix flow: remix_target_id provided
|
||||||
if remix_target_id:
|
if remix_target_id:
|
||||||
async for chunk in self._handle_remix(remix_target_id, prompt, model_config):
|
async for chunk in self._handle_remix(remix_target_id, prompt, model_config):
|
||||||
yield chunk
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
# Character creation flow: video provided
|
# Character creation has been isolated into avatar-create model
|
||||||
if video:
|
if video:
|
||||||
# Decode video if it's base64
|
raise Exception("角色创建已独立为 avatar-create 模型,请切换模型后重试。")
|
||||||
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
|
|
||||||
|
|
||||||
# If no prompt, just create character and return
|
|
||||||
if not prompt:
|
|
||||||
async for chunk in self._handle_character_creation_only(video_data, model_config):
|
|
||||||
yield chunk
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# If prompt provided, create character and generate video
|
|
||||||
async for chunk in self._handle_character_and_video_generation(video_data, prompt, model_config):
|
|
||||||
yield chunk
|
|
||||||
return
|
|
||||||
|
|
||||||
# Streaming mode: proceed with actual generation
|
# Streaming mode: proceed with actual generation
|
||||||
# Check if model requires Pro subscription
|
# Check if model requires Pro subscription
|
||||||
@@ -797,7 +835,15 @@ class GenerationHandler:
|
|||||||
# Try generation
|
# Try generation
|
||||||
# Only show init message on first attempt (not on retries)
|
# Only show init message on first attempt (not on retries)
|
||||||
show_init = (retry_count == 0)
|
show_init = (retry_count == 0)
|
||||||
async for chunk in self.handle_generation(model, prompt, image, video, remix_target_id, stream, show_init_message=show_init):
|
async for chunk in self.handle_generation(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
image,
|
||||||
|
video,
|
||||||
|
remix_target_id,
|
||||||
|
stream,
|
||||||
|
show_init_message=show_init
|
||||||
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
# If successful, return
|
# If successful, return
|
||||||
return
|
return
|
||||||
@@ -1669,6 +1715,17 @@ class GenerationHandler:
|
|||||||
|
|
||||||
# Log successful character creation
|
# Log successful character creation
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
character_card = {
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"character_id": character_id,
|
||||||
|
"cameo_id": cameo_id,
|
||||||
|
"profile_asset_url": profile_asset_url,
|
||||||
|
"instruction_set": instruction_set,
|
||||||
|
"public": True,
|
||||||
|
"source_model": "avatar-create",
|
||||||
|
"created_at": int(datetime.now().timestamp())
|
||||||
|
}
|
||||||
await self._log_request(
|
await self._log_request(
|
||||||
token_id=token_obj.id,
|
token_id=token_obj.id,
|
||||||
operation="character_only",
|
operation="character_only",
|
||||||
@@ -1678,18 +1735,28 @@ class GenerationHandler:
|
|||||||
},
|
},
|
||||||
response_data={
|
response_data={
|
||||||
"success": True,
|
"success": True,
|
||||||
"username": username,
|
"card": character_card
|
||||||
"display_name": display_name,
|
|
||||||
"character_id": character_id,
|
|
||||||
"cameo_id": cameo_id
|
|
||||||
},
|
},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 7: Return success message
|
# Step 7: Return structured character card
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
content=f"角色创建成功,角色名@{username}",
|
content=json.dumps({
|
||||||
|
"event": "character_card",
|
||||||
|
"card": character_card
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 8: Return summary message
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
content=(
|
||||||
|
f"角色创建成功,角色名@{username}\n"
|
||||||
|
f"显示名:{display_name}\n"
|
||||||
|
f"Character ID:{character_id}\n"
|
||||||
|
f"Cameo ID:{cameo_id}"
|
||||||
|
),
|
||||||
finish_reason="STOP"
|
finish_reason="STOP"
|
||||||
)
|
)
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
@@ -1741,6 +1808,182 @@ class GenerationHandler:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _handle_character_creation_from_generation_id(self, generation_id: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
||||||
|
"""Handle character creation from generation id (gen_xxx)."""
|
||||||
|
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
||||||
|
if not token_obj:
|
||||||
|
raise Exception("No available tokens for character creation")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
normalized_generation_id = self._extract_generation_id((generation_id or "").strip())
|
||||||
|
try:
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="**Character Creation Begins**\n\nInitializing character creation from generation id...\n",
|
||||||
|
is_first=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not normalized_generation_id:
|
||||||
|
raise Exception("无效 generation_id,请传入 gen_xxx。")
|
||||||
|
|
||||||
|
# Step 1: Create cameo from generation
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=f"Creating character from generation: {normalized_generation_id} ...\n"
|
||||||
|
)
|
||||||
|
cameo_id = await self.sora_client.create_character_from_generation(
|
||||||
|
generation_id=normalized_generation_id,
|
||||||
|
token=token_obj.token,
|
||||||
|
timestamps=[0, 3]
|
||||||
|
)
|
||||||
|
debug_logger.log_info(f"Character-from-generation submitted, cameo_id: {cameo_id}")
|
||||||
|
|
||||||
|
# Step 2: Poll cameo processing
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Processing generation to extract character...\n"
|
||||||
|
)
|
||||||
|
cameo_status = await self._poll_cameo_status(cameo_id, token_obj.token)
|
||||||
|
debug_logger.log_info(f"Cameo status: {cameo_status}")
|
||||||
|
|
||||||
|
# Extract character info
|
||||||
|
username_hint = cameo_status.get("username_hint", "character")
|
||||||
|
display_name = cameo_status.get("display_name_hint", "Character")
|
||||||
|
username = self._process_character_username(username_hint)
|
||||||
|
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=f"✨ 角色已识别: {display_name} (@{username})\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Download avatar
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Downloading character avatar...\n"
|
||||||
|
)
|
||||||
|
profile_asset_url = cameo_status.get("profile_asset_url")
|
||||||
|
if not profile_asset_url:
|
||||||
|
raise Exception("Profile asset URL not found in cameo status")
|
||||||
|
|
||||||
|
avatar_data = await self.sora_client.download_character_image(profile_asset_url)
|
||||||
|
debug_logger.log_info(f"Avatar downloaded, size: {len(avatar_data)} bytes")
|
||||||
|
|
||||||
|
# Step 4: Upload avatar
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Uploading character avatar...\n"
|
||||||
|
)
|
||||||
|
asset_pointer = await self.sora_client.upload_character_image(avatar_data, token_obj.token)
|
||||||
|
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
||||||
|
|
||||||
|
# Step 5: Finalize character
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Finalizing character creation...\n"
|
||||||
|
)
|
||||||
|
instruction_set = cameo_status.get("instruction_set_hint") or cameo_status.get("instruction_set")
|
||||||
|
character_id = await self.sora_client.finalize_character(
|
||||||
|
cameo_id=cameo_id,
|
||||||
|
username=username,
|
||||||
|
display_name=display_name,
|
||||||
|
profile_asset_pointer=asset_pointer,
|
||||||
|
instruction_set=instruction_set,
|
||||||
|
token=token_obj.token
|
||||||
|
)
|
||||||
|
debug_logger.log_info(f"Character finalized, character_id: {character_id}")
|
||||||
|
|
||||||
|
# Step 6: Set public
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Setting character as public...\n"
|
||||||
|
)
|
||||||
|
await self.sora_client.set_character_public(cameo_id, token_obj.token)
|
||||||
|
debug_logger.log_info("Character set as public")
|
||||||
|
|
||||||
|
# Log success
|
||||||
|
duration = time.time() - start_time
|
||||||
|
character_card = {
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"character_id": character_id,
|
||||||
|
"cameo_id": cameo_id,
|
||||||
|
"profile_asset_url": profile_asset_url,
|
||||||
|
"instruction_set": instruction_set,
|
||||||
|
"public": True,
|
||||||
|
"source_model": "avatar-create",
|
||||||
|
"source_generation_id": normalized_generation_id,
|
||||||
|
"created_at": int(datetime.now().timestamp())
|
||||||
|
}
|
||||||
|
await self._log_request(
|
||||||
|
token_id=token_obj.id,
|
||||||
|
operation="character_only",
|
||||||
|
request_data={
|
||||||
|
"type": "character_creation",
|
||||||
|
"has_video": False,
|
||||||
|
"has_generation_id": True,
|
||||||
|
"generation_id": normalized_generation_id
|
||||||
|
},
|
||||||
|
response_data={
|
||||||
|
"success": True,
|
||||||
|
"card": character_card
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
content=json.dumps({
|
||||||
|
"event": "character_card",
|
||||||
|
"card": character_card
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
content=(
|
||||||
|
f"角色创建成功,角色名@{username}\n"
|
||||||
|
f"显示名:{display_name}\n"
|
||||||
|
f"Character ID:{character_id}\n"
|
||||||
|
f"Cameo ID:{cameo_id}"
|
||||||
|
),
|
||||||
|
finish_reason="STOP"
|
||||||
|
)
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_response = None
|
||||||
|
try:
|
||||||
|
error_response = json.loads(str(e))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_cf_or_429 = False
|
||||||
|
if error_response and isinstance(error_response, dict):
|
||||||
|
error_info = error_response.get("error", {})
|
||||||
|
if error_info.get("code") == "cf_shield_429":
|
||||||
|
is_cf_or_429 = True
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await self._log_request(
|
||||||
|
token_id=token_obj.id if token_obj else None,
|
||||||
|
operation="character_only",
|
||||||
|
request_data={
|
||||||
|
"type": "character_creation",
|
||||||
|
"has_video": False,
|
||||||
|
"has_generation_id": bool(normalized_generation_id),
|
||||||
|
"generation_id": normalized_generation_id
|
||||||
|
},
|
||||||
|
response_data={
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
},
|
||||||
|
status_code=429 if is_cf_or_429 else 500,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_obj:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
|
||||||
|
if not is_cf_or_429:
|
||||||
|
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
|
||||||
|
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Character creation from generation id failed: {str(e)}",
|
||||||
|
status_code=429 if is_cf_or_429 else 500,
|
||||||
|
response_text=str(e)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def _handle_character_and_video_generation(self, video_data, prompt: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
async def _handle_character_and_video_generation(self, video_data, prompt: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
||||||
"""Handle character creation and video generation
|
"""Handle character creation and video generation
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ from ..core.logger import debug_logger
|
|||||||
class POWServiceClient:
|
class POWServiceClient:
|
||||||
"""Client for external POW service API"""
|
"""Client for external POW service API"""
|
||||||
|
|
||||||
async def get_sentinel_token(self) -> Optional[Tuple[str, str, str]]:
|
async def get_sentinel_token(self, access_token: Optional[str] = None) -> Optional[Tuple[str, str, str]]:
|
||||||
"""Get sentinel token from external POW service
|
"""Get sentinel token from external POW service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Optional access token to send to POW service
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (sentinel_token, device_id, user_agent) or None on failure
|
Tuple of (sentinel_token, device_id, user_agent) or None on failure
|
||||||
"""
|
"""
|
||||||
@@ -39,6 +42,10 @@ class POWServiceClient:
|
|||||||
"Accept": "application/json"
|
"Accept": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add access_token to headers if provided
|
||||||
|
if access_token:
|
||||||
|
headers["X-Access-Token"] = access_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
debug_logger.log_info(f"[POW Service] Requesting token from {api_url}")
|
debug_logger.log_info(f"[POW Service] Requesting token from {api_url}")
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import random
|
|||||||
import string
|
import string
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, Dict, Any, Tuple
|
from typing import Optional, Dict, Any, Tuple, List
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from urllib.request import Request, urlopen, build_opener, ProxyHandler
|
from urllib.request import Request, urlopen, build_opener, ProxyHandler
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
@@ -231,12 +231,13 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
|
|||||||
await context.close()
|
await context.close()
|
||||||
|
|
||||||
|
|
||||||
async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool = False) -> str:
|
async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool = False, access_token: Optional[str] = None) -> str:
|
||||||
"""Get sentinel token with caching support
|
"""Get sentinel token with caching support
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
proxy_url: Optional proxy URL
|
proxy_url: Optional proxy URL
|
||||||
force_refresh: Force refresh token (e.g., after 400 error)
|
force_refresh: Force refresh token (e.g., after 400 error)
|
||||||
|
access_token: Optional access token to send to external POW service
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sentinel token string or None
|
Sentinel token string or None
|
||||||
@@ -250,7 +251,7 @@ async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool
|
|||||||
if config.pow_service_mode == "external":
|
if config.pow_service_mode == "external":
|
||||||
debug_logger.log_info("[POW] Using external POW service (cached sentinel)")
|
debug_logger.log_info("[POW] Using external POW service (cached sentinel)")
|
||||||
from .pow_service_client import pow_service_client
|
from .pow_service_client import pow_service_client
|
||||||
result = await pow_service_client.get_sentinel_token()
|
result = await pow_service_client.get_sentinel_token(access_token=access_token)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
sentinel_token, device_id, service_user_agent = result
|
sentinel_token, device_id, service_user_agent = result
|
||||||
@@ -754,7 +755,7 @@ class SoraClient:
|
|||||||
# Check if external POW service is configured
|
# Check if external POW service is configured
|
||||||
if config.pow_service_mode == "external":
|
if config.pow_service_mode == "external":
|
||||||
debug_logger.log_info("[Sentinel] Using external POW service...")
|
debug_logger.log_info("[Sentinel] Using external POW service...")
|
||||||
result = await pow_service_client.get_sentinel_token()
|
result = await pow_service_client.get_sentinel_token(access_token=token)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
sentinel_token, device_id, service_user_agent = result
|
sentinel_token, device_id, service_user_agent = result
|
||||||
@@ -1141,7 +1142,7 @@ class SoraClient:
|
|||||||
|
|
||||||
# Try to get cached sentinel token first (using lightweight Playwright approach)
|
# Try to get cached sentinel token first (using lightweight Playwright approach)
|
||||||
try:
|
try:
|
||||||
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=False)
|
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=False, access_token=token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 403/429 errors from oai-did fetch - don't retry, just fail
|
# 403/429 errors from oai-did fetch - don't retry, just fail
|
||||||
error_str = str(e)
|
error_str = str(e)
|
||||||
@@ -1175,7 +1176,7 @@ class SoraClient:
|
|||||||
_invalidate_sentinel_cache()
|
_invalidate_sentinel_cache()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True)
|
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True, access_token=token)
|
||||||
except Exception as refresh_e:
|
except Exception as refresh_e:
|
||||||
# 403/429 errors - don't continue
|
# 403/429 errors - don't continue
|
||||||
error_str = str(refresh_e)
|
error_str = str(refresh_e)
|
||||||
@@ -1432,6 +1433,33 @@ class SoraClient:
|
|||||||
result = await self._make_request("POST", "/characters/upload", token, multipart=mp)
|
result = await self._make_request("POST", "/characters/upload", token, multipart=mp)
|
||||||
return result.get("id")
|
return result.get("id")
|
||||||
|
|
||||||
|
async def create_character_from_generation(self, generation_id: str, token: str,
|
||||||
|
timestamps: Optional[List[int]] = None) -> str:
|
||||||
|
"""Create character cameo from generation id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
generation_id: Generation ID (gen_xxx)
|
||||||
|
token: Access token
|
||||||
|
timestamps: Optional frame timestamps, defaults to [0, 3]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
cameo_id
|
||||||
|
"""
|
||||||
|
if timestamps is None:
|
||||||
|
timestamps = [0, 3]
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"generation_id": generation_id,
|
||||||
|
"character_id": None,
|
||||||
|
"timestamps": timestamps
|
||||||
|
}
|
||||||
|
result = await self._make_request("POST", "/characters/from-generation", token, json_data=json_data)
|
||||||
|
return result.get("id")
|
||||||
|
|
||||||
|
async def get_post_detail(self, post_id: str, token: str) -> Dict[str, Any]:
|
||||||
|
"""Get Sora post detail by post id (s_xxx)."""
|
||||||
|
return await self._make_request("GET", f"/project_y/post/{post_id}", token)
|
||||||
|
|
||||||
async def get_cameo_status(self, cameo_id: str, token: str) -> Dict[str, Any]:
|
async def get_cameo_status(self, cameo_id: str, token: str) -> Dict[str, Any]:
|
||||||
"""Get character (cameo) processing status
|
"""Get character (cameo) processing status
|
||||||
|
|
||||||
|
|||||||
@@ -1559,6 +1559,9 @@
|
|||||||
<option value="sora2pro-hd-portrait-15s">竖屏视频 15 秒 (Pro HD)</option>
|
<option value="sora2pro-hd-portrait-15s">竖屏视频 15 秒 (Pro HD)</option>
|
||||||
<option value="sora2pro-hd-portrait-10s">竖屏视频 10 秒 (Pro HD)</option>
|
<option value="sora2pro-hd-portrait-10s">竖屏视频 10 秒 (Pro HD)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
<optgroup label="角色创建">
|
||||||
|
<option value="avatar-create">角色创建(视频优先 / 支持提示词generation_id)</option>
|
||||||
|
</optgroup>
|
||||||
<optgroup label="图片">
|
<optgroup label="图片">
|
||||||
<option value="gpt-image">方图 360×360</option>
|
<option value="gpt-image">方图 360×360</option>
|
||||||
<option value="gpt-image-landscape">横图 540×360</option>
|
<option value="gpt-image-landscape">横图 540×360</option>
|
||||||
|
|||||||
@@ -4771,9 +4771,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (batchType === 'character') {
|
} else if (batchType === 'character') {
|
||||||
// 角色卡模式:只需要视频文件,不需要提示词
|
if (model !== 'avatar-create') {
|
||||||
|
showToast('角色卡模式请先切换模型为“角色创建(视频优先 / 支持提示词generation_id)/avatar-create”', 'warn', { title: '模型不匹配', duration: 4200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 角色卡模式:只使用视频文件(提示词内 generation_id 请走普通模式)
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
showToast('角色卡模式:请上传视频文件', 'warn', { title: '缺少视频', duration: 3600 });
|
showToast('角色卡模式:请上传视频文件(提示词generation_id请用普通模式)', 'warn', { title: '缺少视频', duration: 3600 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const videoFile = files.find((f) => (f.type || '').startsWith('video'));
|
const videoFile = files.find((f) => (f.type || '').startsWith('video'));
|
||||||
|
|||||||
Reference in New Issue
Block a user