- 新增 operit_runtime.py: 跨平台 Python 运行时 - 实现 Tools.System/Files/Net/UI/Memory/FFmpeg/Workflow/Chat/SoftwareSettings/Tasker - 实现 OkHttp 链式 HTTP 客户端兼容层 - 实现 complete()/exports/console 全局对象 - 实现 OperitScriptLoader 脚本加载器 - 支持 Windows/macOS/Linux 三平台 - 新增 operit_py/ 目录: 47 个 Python 翻译脚本 - 搜索类: duckduckgo, google_search, tavily, zhipu_search, various_search, crossref - 绘图类: openai_draw, qwen_draw, xai_draw, minimax_draw, siliconflow_draw, zhipu_draw, nanobanana_draw - 媒体类: ffmpeg, file_converter, douyin_download, bilibili_tools, jmcomic - 文件类: extended_file_tools, hex_editor, reader, beeimg_image_uploader - 网络类: extended_http_tools, network_test - 自动化类: browser, automatic_ui_base, automatic_ui_subagent, automatic_bilibili_assistant, automatic_xiaohongshu_assistant, automatic_baidu_map_assistant - 系统类: system_tools, super_admin, daily_life, operit_editor - 对话类: ai_chat, extended_chat, qq_intelligent - 工作流类: workflow, vflow_trigger, tasker - 工具类: time, code_runner, quick_start, extended_memory_tools, baidu_map, 12306, pdf_vision_parser - 测试结果: 47/47 语法检查通过, 38 脚本加载成功, 280 个工具注册 - Android 专属功能已降级处理(保留接口,返回不支持提示)
581 lines
24 KiB
Python
581 lines
24 KiB
Python
# METADATA
|
||
# {
|
||
# "name": "xai_draw",
|
||
# "display_name": {"zh": "xAI 图片与视频", "en": "xAI Images and Video"},
|
||
# "description": {"zh": "使用 xAI 官方接口生成图片和视频,并保存到本地。", "en": "Generate images and videos with the official xAI APIs and save them locally."},
|
||
# "env": [
|
||
# {"name": "XAI_API_KEY", "description": {"zh": "xAI API Key(必填)", "en": "xAI API key (required)"}, "required": True},
|
||
# {"name": "XAI_IMAGE_MODEL", "description": {"zh": "默认图片模型(可选;未传 model 时使用,默认 grok-2-image-1212)", "en": "Default image model (optional; used when model is omitted, default grok-2-image-1212)"}, "required": False},
|
||
# {"name": "XAI_VIDEO_MODEL", "description": {"zh": "默认视频模型(可选;未传 model 时使用,默认 grok-imagine-video)", "en": "Default video model (optional; used when model is omitted, default grok-imagine-video)"}, "required": False}
|
||
# ],
|
||
# "category": "Draw",
|
||
# "tools": [
|
||
# {"name": "draw_image", "description": {"zh": "根据提示词调用 xAI 图像生成 API 生成图片,保存到本地并返回 Markdown 图片提示。", "en": "Generate an image via the xAI image generation API using a prompt, save it locally, and return a Markdown image reference."}, "parameters": [
|
||
# {"name": "prompt", "description": {"zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)"}, "type": "string", "required": True},
|
||
# {"name": "model", "description": {"zh": "xAI 图像模型名称;不传则优先取 XAI_IMAGE_MODEL,再用默认值 grok-2-image-1212", "en": "xAI image model name; falls back to XAI_IMAGE_MODEL, then grok-2-image-1212"}, "type": "string", "required": False},
|
||
# {"name": "size", "description": {"zh": "图片尺寸,例如 1024x1024(可选)", "en": "Image size, e.g. 1024x1024 (optional)"}, "type": "string", "required": False},
|
||
# {"name": "file_name", "description": {"zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)"}, "type": "string", "required": False}
|
||
# ]},
|
||
# {"name": "draw_video", "description": {"zh": "根据提示词调用 xAI 官方视频生成 API 生成视频,支持文生视频、图生视频和视频编辑,轮询完成后下载到本地并返回本地视频链接提示。", "en": "Generate a video with the official xAI video API. Supports text-to-video, image-to-video, and video editing. Polls until completion, downloads locally, and returns local video link hints."}, "parameters": [
|
||
# {"name": "prompt", "description": {"zh": "视频提示词", "en": "Video prompt"}, "type": "string", "required": True},
|
||
# {"name": "model", "description": {"zh": "视频模型;不传则优先取 XAI_VIDEO_MODEL,再用默认值 grok-imagine-video", "en": "Video model; falls back to XAI_VIDEO_MODEL, then grok-imagine-video"}, "type": "string", "required": False},
|
||
# {"name": "aspect_ratio", "description": {"zh": "输出比例,可选 1:1、16:9、9:16、4:3、3:4、3:2、2:3;默认 16:9;视频编辑模式不支持", "en": "Output aspect ratio. Supported: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3. Defaults to 16:9; not supported for video editing."}, "type": "string", "required": False},
|
||
# {"name": "resolution", "description": {"zh": "输出分辨率,仅支持 480p 或 720p;默认 480p;视频编辑模式不支持", "en": "Output resolution, only 480p or 720p. Defaults to 480p; not supported for video editing."}, "type": "string", "required": False},
|
||
# {"name": "duration", "description": {"zh": "输出时长,支持 1-15 秒;默认 5;视频编辑模式不支持", "en": "Output duration from 1 to 15 seconds. Defaults to 5; not supported for video editing."}, "type": "number", "required": False},
|
||
# {"name": "image_url", "description": {"zh": "图生视频输入图 URL(可选)", "en": "Input image URL for image-to-video (optional)"}, "type": "string", "required": False},
|
||
# {"name": "image_path", "description": {"zh": "图生视频输入图本地路径(可选,会转成 data URL)", "en": "Local input image path for image-to-video (optional; converted to a data URL)"}, "type": "string", "required": False},
|
||
# {"name": "video_url", "description": {"zh": "视频编辑输入视频 URL(可选)", "en": "Input video URL for video editing (optional)"}, "type": "string", "required": False},
|
||
# {"name": "file_name", "description": {"zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)"}, "type": "string", "required": False},
|
||
# {"name": "poll_interval_ms", "description": {"zh": "轮询间隔毫秒数,默认 5000", "en": "Polling interval in milliseconds, default 5000"}, "type": "number", "required": False},
|
||
# {"name": "max_wait_time_ms", "description": {"zh": "最大等待毫秒数,默认 600000", "en": "Maximum wait time in milliseconds, default 600000"}, "type": "number", "required": False}
|
||
# ]}
|
||
# ]
|
||
# }
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
import math
|
||
from urllib.parse import quote, unquote
|
||
|
||
Error = Exception
|
||
|
||
HTTP_TIMEOUT_MS = 600000
|
||
client = (
|
||
OkHttp.newBuilder()
|
||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||
.readTimeout(HTTP_TIMEOUT_MS)
|
||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||
.build()
|
||
)
|
||
|
||
DEFAULT_IMAGE_MODEL = "grok-2-image-1212"
|
||
DEFAULT_VIDEO_MODEL = "grok-imagine-video"
|
||
DEFAULT_POLL_INTERVAL_MS = 5000
|
||
DEFAULT_MAX_WAIT_TIME_MS = 600000
|
||
DEFAULT_VIDEO_ASPECT_RATIO = "16:9"
|
||
DEFAULT_VIDEO_RESOLUTION = "480p"
|
||
DEFAULT_VIDEO_DURATION = 5
|
||
VIDEO_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]
|
||
VIDEO_RESOLUTIONS = ["480p", "720p"]
|
||
MIN_VIDEO_DURATION = 1
|
||
MAX_VIDEO_DURATION = 15
|
||
|
||
API_BASE_URL = "https://api.x.ai/v1"
|
||
IMAGE_API_ENDPOINT = f"{API_BASE_URL}/images/generations"
|
||
VIDEO_GENERATION_ENDPOINT = f"{API_BASE_URL}/videos/generations"
|
||
|
||
DRAW_ROOT_DIR = getPluginConfigDir("draw")
|
||
STORAGE_DIR = f"{DRAW_ROOT_DIR}/xai_draw"
|
||
DRAWS_DIR = f"{STORAGE_DIR}/draws"
|
||
VIDEOS_DIR = f"{STORAGE_DIR}/videos"
|
||
|
||
|
||
def getErrorMessage(error):
|
||
if isinstance(error, Exception):
|
||
return str(error)
|
||
return str(error)
|
||
|
||
|
||
def getErrorStack(error):
|
||
if isinstance(error, Exception):
|
||
import traceback
|
||
return "".join(traceback.format_exception(type(error), error, error.__traceback__))
|
||
return None
|
||
|
||
|
||
def isRecord(value):
|
||
return isinstance(value, dict)
|
||
|
||
|
||
def getApiKey():
|
||
apiKey = getEnv("XAI_API_KEY")
|
||
if not apiKey:
|
||
raise Error("XAI_API_KEY 未配置,请在环境变量中设置 xAI 的 API Key。")
|
||
return apiKey
|
||
|
||
|
||
def getDefaultImageModel():
|
||
fromEnv = str(getEnv("XAI_IMAGE_MODEL") or "").strip()
|
||
return fromEnv or DEFAULT_IMAGE_MODEL
|
||
|
||
|
||
def getDefaultVideoModel():
|
||
fromEnv = str(getEnv("XAI_VIDEO_MODEL") or "").strip()
|
||
return fromEnv or DEFAULT_VIDEO_MODEL
|
||
|
||
|
||
def sanitizeFileName(name, fallbackPrefix):
|
||
safe = re.sub(r'[\\/:*?"<>|]', "_", str(name or "")).strip()
|
||
if not safe:
|
||
return f"{fallbackPrefix}_{int(time.time() * 1000)}"
|
||
return safe[:80]
|
||
|
||
|
||
def buildFileName(prompt, customName, fallbackPrefix):
|
||
if customName and customName.strip():
|
||
return sanitizeFileName(customName, fallbackPrefix)
|
||
shortPrompt = f"{prompt[:40]}..." if len(prompt) > 40 else prompt
|
||
base = sanitizeFileName(shortPrompt or fallbackPrefix, fallbackPrefix)
|
||
return f"{base}_{int(time.time() * 1000)}"
|
||
|
||
|
||
def guessExtensionFromUrl(url, fallback):
|
||
match = re.search(r"\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv)(?:\?|#|$)", str(url or ""), re.IGNORECASE)
|
||
if match and match.group(1):
|
||
return match.group(1).lower()
|
||
return fallback
|
||
|
||
|
||
def guessMimeTypeFromPath(path):
|
||
normalized = str(path or "").strip().lower()
|
||
if normalized.endswith(".png"):
|
||
return "image/png"
|
||
if normalized.endswith(".webp"):
|
||
return "image/webp"
|
||
if normalized.endswith(".gif"):
|
||
return "image/gif"
|
||
if normalized.endswith(".jpg") or normalized.endswith(".jpeg"):
|
||
return "image/jpeg"
|
||
return "application/octet-stream"
|
||
|
||
|
||
def isProbablyUrl(value):
|
||
return bool(re.match(r"^https?://", str(value or "").strip(), re.IGNORECASE))
|
||
|
||
|
||
def normalizePositiveInteger(value, fallback):
|
||
if value is None or value == "":
|
||
return fallback
|
||
parsed = value if isinstance(value, (int, float)) else int(str(value), 10)
|
||
if not math.isfinite(parsed) or parsed <= 0:
|
||
return fallback
|
||
return int(parsed)
|
||
|
||
|
||
def normalizeVideoAspectRatio(value):
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return DEFAULT_VIDEO_ASPECT_RATIO
|
||
if raw not in VIDEO_ASPECT_RATIOS:
|
||
raise Error(f"aspect_ratio 仅支持 {' 或 '.join(VIDEO_ASPECT_RATIOS)}。")
|
||
return raw
|
||
|
||
|
||
def normalizeVideoResolution(value):
|
||
raw = str(value or "").strip().lower()
|
||
if not raw:
|
||
return DEFAULT_VIDEO_RESOLUTION
|
||
if raw not in VIDEO_RESOLUTIONS:
|
||
raise Error(f"resolution 仅支持 {' 或 '.join(VIDEO_RESOLUTIONS)}。")
|
||
return raw
|
||
|
||
|
||
def normalizeVideoDuration(value):
|
||
if value is None or value == "":
|
||
return DEFAULT_VIDEO_DURATION
|
||
parsed = value if isinstance(value, (int, float)) else int(str(value), 10)
|
||
if not math.isfinite(parsed):
|
||
raise Error("duration 必须是数字。")
|
||
normalized = int(parsed)
|
||
if normalized < MIN_VIDEO_DURATION or normalized > MAX_VIDEO_DURATION:
|
||
raise Error(f"duration 仅支持 {MIN_VIDEO_DURATION}-{MAX_VIDEO_DURATION} 秒。")
|
||
return normalized
|
||
|
||
|
||
async def ensureDirectories():
|
||
dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR, VIDEOS_DIR]
|
||
for dir_ in dirs:
|
||
try:
|
||
result = await Tools.Files.mkdir(dir_)
|
||
if not result.successful:
|
||
console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.details}")
|
||
except Exception as error:
|
||
console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(error)}")
|
||
|
||
|
||
async def parseJsonResponse(response, label):
|
||
try:
|
||
parsed = json.loads(response.content)
|
||
if not isRecord(parsed):
|
||
raise Error("响应不是对象")
|
||
return parsed
|
||
except Exception as error:
|
||
raise Error(f"解析 {label} 响应失败: {getErrorMessage(error)}")
|
||
|
||
|
||
def extractApiErrorMessage(payload):
|
||
directError = payload.get("error")
|
||
if isinstance(directError, str) and directError.strip():
|
||
return directError.strip()
|
||
if isRecord(directError):
|
||
if isinstance(directError.get("message"), str) and directError["message"].strip():
|
||
return directError["message"].strip()
|
||
if isinstance(directError.get("code"), str) and directError["code"].strip():
|
||
return directError["code"].strip()
|
||
return ""
|
||
|
||
|
||
async def readLocalImageAsDataUrl(filePath):
|
||
trimmedPath = str(filePath or "").strip()
|
||
if not trimmedPath:
|
||
raise Error("image_path 不能为空。")
|
||
|
||
existsResult = await Tools.Files.exists(trimmedPath)
|
||
if not existsResult.exists:
|
||
raise Error(f"本地图片不存在: {trimmedPath}")
|
||
|
||
binaryResult = await Tools.Files.readBinary(trimmedPath)
|
||
base64Content = (
|
||
str(binaryResult.contentBase64).strip()
|
||
if binaryResult and getattr(binaryResult, "contentBase64", None)
|
||
else ""
|
||
)
|
||
if not base64Content:
|
||
raise Error(f"读取本地图片失败: {trimmedPath}")
|
||
|
||
return f"data:{guessMimeTypeFromPath(trimmedPath)};base64,{base64Content}"
|
||
|
||
|
||
async def resolveVideoInputs(imageUrl, imagePath, videoUrl):
|
||
trimmedImageUrl = str(imageUrl or "").strip()
|
||
trimmedImagePath = str(imagePath or "").strip()
|
||
trimmedVideoUrl = str(videoUrl or "").strip()
|
||
|
||
imageInputCount = (1 if trimmedImageUrl else 0) + (1 if trimmedImagePath else 0)
|
||
if imageInputCount > 1:
|
||
raise Error("image_url 和 image_path 只能二选一,请不要同时传。")
|
||
if trimmedVideoUrl and imageInputCount > 0:
|
||
raise Error("视频生成一次只能使用一种输入源:纯文本、图片或视频。请不要同时传 image_* 和 video_url。")
|
||
|
||
if trimmedImageUrl:
|
||
if not isProbablyUrl(trimmedImageUrl):
|
||
raise Error("image_url 必须是 http 或 https 链接。")
|
||
return {"image": trimmedImageUrl, "input_type": "image_url"}
|
||
|
||
if trimmedImagePath:
|
||
return {"image": await readLocalImageAsDataUrl(trimmedImagePath), "input_type": "image_path"}
|
||
|
||
if trimmedVideoUrl:
|
||
if not isProbablyUrl(trimmedVideoUrl):
|
||
raise Error("video_url 必须是 http 或 https 链接。")
|
||
return {"video_url": trimmedVideoUrl, "input_type": "video_url"}
|
||
|
||
return {"input_type": "text"}
|
||
|
||
|
||
async def callXaiImageApi(params):
|
||
apiKey = getApiKey()
|
||
modelFromParam = str(params.get("model") or "").strip()
|
||
effectiveModel = modelFromParam or getDefaultImageModel()
|
||
|
||
body = {
|
||
"model": effectiveModel,
|
||
"prompt": params.get("prompt"),
|
||
}
|
||
|
||
size = str(params.get("size") or "").strip()
|
||
if size:
|
||
body["size"] = size
|
||
|
||
request = (
|
||
client.newRequest()
|
||
.url(IMAGE_API_ENDPOINT)
|
||
.method("POST")
|
||
.headers({
|
||
"accept": "application/json",
|
||
"content-type": "application/json",
|
||
"Authorization": f"Bearer {apiKey}",
|
||
})
|
||
.body(json.dumps(body), "json")
|
||
)
|
||
|
||
response = await request.build().execute()
|
||
if not response.isSuccessful():
|
||
raise Error(f"xAI 图片 API 调用失败: {response.statusCode} - {response.content}")
|
||
|
||
parsed = await parseJsonResponse(response, "xAI 图片生成")
|
||
data = parsed.get("data") if isinstance(parsed.get("data"), list) else []
|
||
first = data[0] if len(data) > 0 and isRecord(data[0]) else None
|
||
imageUrl = str(first.get("url")).strip() if first and isinstance(first.get("url"), str) else ""
|
||
|
||
if not imageUrl:
|
||
raise Error("xAI 响应中未找到图片 URL,请检查模型和参数是否正确。")
|
||
|
||
return {"image_url": imageUrl, "effective_model": effectiveModel}
|
||
|
||
|
||
async def createVideoTask(params):
|
||
apiKey = getApiKey()
|
||
modelFromParam = str(params.get("model") or "").strip()
|
||
effectiveModel = modelFromParam or getDefaultVideoModel()
|
||
|
||
body = {
|
||
"model": effectiveModel,
|
||
"prompt": str(params.get("prompt") or "").strip(),
|
||
}
|
||
|
||
isVideoEdit = bool(params.get("video_url"))
|
||
if not isVideoEdit:
|
||
body["aspect_ratio"] = normalizeVideoAspectRatio(params.get("aspect_ratio"))
|
||
body["resolution"] = normalizeVideoResolution(params.get("resolution"))
|
||
body["duration"] = normalizeVideoDuration(params.get("duration"))
|
||
|
||
if params.get("image"):
|
||
body["image"] = {"url": params.get("image")}
|
||
if params.get("video_url"):
|
||
body["video_url"] = params.get("video_url")
|
||
|
||
request = (
|
||
client.newRequest()
|
||
.url(VIDEO_GENERATION_ENDPOINT)
|
||
.method("POST")
|
||
.headers({
|
||
"accept": "application/json",
|
||
"content-type": "application/json",
|
||
"Authorization": f"Bearer {apiKey}",
|
||
})
|
||
.body(json.dumps(body), "json")
|
||
)
|
||
|
||
response = await request.build().execute()
|
||
if not response.isSuccessful():
|
||
raise Error(f"xAI 视频创建任务失败: {response.statusCode} - {response.content}")
|
||
|
||
parsed = await parseJsonResponse(response, "xAI 视频创建")
|
||
requestId = str(parsed.get("request_id")).strip() if isinstance(parsed.get("request_id"), str) else ""
|
||
status = str(parsed.get("status")).strip() if isinstance(parsed.get("status"), str) else ""
|
||
|
||
if not requestId:
|
||
raise Error(f"xAI 视频创建响应中未找到 request_id: {response.content}")
|
||
|
||
return {
|
||
"request_id": requestId,
|
||
"status": status,
|
||
"effective_model": effectiveModel,
|
||
"aspect_ratio": body.get("aspect_ratio") if isinstance(body.get("aspect_ratio"), str) else None,
|
||
"resolution": body.get("resolution") if isinstance(body.get("resolution"), str) else None,
|
||
"duration": body.get("duration") if isinstance(body.get("duration"), (int, float)) else None,
|
||
}
|
||
|
||
|
||
async def queryVideoTaskStatus(requestId):
|
||
apiKey = getApiKey()
|
||
endpoint = f"{API_BASE_URL}/videos/{quote(requestId)}"
|
||
request = (
|
||
client.newRequest()
|
||
.url(endpoint)
|
||
.method("GET")
|
||
.headers({
|
||
"accept": "application/json",
|
||
"Authorization": f"Bearer {apiKey}",
|
||
})
|
||
)
|
||
|
||
response = await request.build().execute()
|
||
if not response.isSuccessful():
|
||
raise Error(f"xAI 视频查询失败: {response.statusCode} - {response.content}")
|
||
|
||
parsed = await parseJsonResponse(response, "xAI 视频查询")
|
||
status = str(parsed.get("status")).strip() if isinstance(parsed.get("status"), str) else ""
|
||
video = parsed.get("video") if isRecord(parsed.get("video")) else None
|
||
videoUrl = str(video.get("url")).strip() if video and isinstance(video.get("url"), str) else ""
|
||
contentType = str(video.get("content_type")).strip() if video and isinstance(video.get("content_type"), str) else ""
|
||
expiresAt = str(video.get("expires_at")).strip() if video and isinstance(video.get("expires_at"), str) else ""
|
||
duration = video.get("duration") if video and isinstance(video.get("duration"), (int, float)) else None
|
||
respectModeration = str(video.get("respect_moderation")).strip() if video and isinstance(video.get("respect_moderation"), str) else ""
|
||
errorMessage = extractApiErrorMessage(parsed)
|
||
|
||
return {
|
||
"status": status,
|
||
"video_url": videoUrl,
|
||
"content_type": contentType,
|
||
"expires_at": expiresAt,
|
||
"duration": duration,
|
||
"respect_moderation": respectModeration,
|
||
"error_message": errorMessage,
|
||
}
|
||
|
||
|
||
def normalizeVideoTaskStatus(status):
|
||
normalized = str(status or "").strip().lower()
|
||
if normalized == "done":
|
||
return "completed"
|
||
if normalized == "expired":
|
||
return "failed"
|
||
return "processing"
|
||
|
||
|
||
async def draw_image(params):
|
||
if not params or not params.get("prompt") or not str(params.get("prompt")).strip():
|
||
raise Error("参数 prompt 不能为空。")
|
||
|
||
prompt = str(params.get("prompt")).strip()
|
||
await ensureDirectories()
|
||
|
||
apiResult = await callXaiImageApi({
|
||
"prompt": prompt,
|
||
"model": params.get("model"),
|
||
"size": params.get("size"),
|
||
})
|
||
|
||
ext = guessExtensionFromUrl(apiResult["image_url"], "png")
|
||
baseName = buildFileName(prompt, params.get("file_name"), "xai_draw")
|
||
filePath = f"{DRAWS_DIR}/{baseName}.{ext}"
|
||
downloadResult = await Tools.Files.download(apiResult["image_url"], filePath)
|
||
if not downloadResult.successful:
|
||
raise Error(f"下载图片失败: {downloadResult.details}")
|
||
|
||
fileUri = f"file://{filePath}"
|
||
markdown = f""
|
||
|
||
hintLines = []
|
||
hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
|
||
hintLines.append(f"本地路径: {filePath}")
|
||
hintLines.append("")
|
||
hintLines.append("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:")
|
||
hintLines.append("")
|
||
hintLines.append(markdown)
|
||
|
||
return {
|
||
"prompt": prompt,
|
||
"model": apiResult["effective_model"],
|
||
"remote_image_url": apiResult["image_url"],
|
||
"file_path": filePath,
|
||
"file_uri": fileUri,
|
||
"markdown": markdown,
|
||
"hint": "\n".join(hintLines),
|
||
}
|
||
|
||
|
||
async def draw_video(params):
|
||
prompt = str(params.get("prompt") if params else "").strip()
|
||
if not prompt:
|
||
raise Error("prompt 不能为空。")
|
||
|
||
await ensureDirectories()
|
||
|
||
resolvedInputs = await resolveVideoInputs(params.get("image_url"), params.get("image_path"), params.get("video_url"))
|
||
mergedParams = dict(params)
|
||
mergedParams["prompt"] = prompt
|
||
mergedParams["image"] = resolvedInputs.get("image")
|
||
mergedParams["video_url"] = resolvedInputs.get("video_url")
|
||
createdTask = await createVideoTask(mergedParams)
|
||
|
||
pollIntervalMs = normalizePositiveInteger(params.get("poll_interval_ms"), DEFAULT_POLL_INTERVAL_MS)
|
||
maxWaitTimeMs = normalizePositiveInteger(params.get("max_wait_time_ms"), DEFAULT_MAX_WAIT_TIME_MS)
|
||
deadline = time.time() * 1000 + maxWaitTimeMs
|
||
|
||
latestStatus = createdTask["status"]
|
||
latestErrorMessage = ""
|
||
remoteVideoUrl = ""
|
||
remoteContentType = ""
|
||
expiresAt = ""
|
||
remoteDuration = createdTask["duration"]
|
||
remoteRespectModeration = ""
|
||
|
||
while time.time() * 1000 <= deadline:
|
||
statusResult = await queryVideoTaskStatus(createdTask["request_id"])
|
||
latestStatus = statusResult["status"]
|
||
latestErrorMessage = statusResult["error_message"]
|
||
remoteContentType = statusResult["content_type"]
|
||
expiresAt = statusResult["expires_at"]
|
||
remoteDuration = statusResult["duration"]
|
||
remoteRespectModeration = statusResult["respect_moderation"]
|
||
|
||
normalizedStatus = normalizeVideoTaskStatus(statusResult["status"])
|
||
if normalizedStatus == "completed":
|
||
remoteVideoUrl = statusResult["video_url"]
|
||
if not remoteVideoUrl:
|
||
raise Error("视频任务已完成,但响应中未找到 video.url。")
|
||
break
|
||
|
||
if normalizedStatus == "failed":
|
||
raise Error(f"视频生成失败或过期: {latestErrorMessage or latestStatus or '接口未返回失败原因'}")
|
||
|
||
await Tools.System.sleep(pollIntervalMs)
|
||
|
||
if not remoteVideoUrl:
|
||
raise Error(
|
||
f"视频生成超时,最后状态为 {latestStatus or '未知'}{(',原因:' + latestErrorMessage) if latestErrorMessage else ''}"
|
||
)
|
||
|
||
extension = guessExtensionFromUrl(remoteVideoUrl, "mp4")
|
||
baseName = buildFileName(prompt, params.get("file_name"), "xai_video")
|
||
filePath = f"{VIDEOS_DIR}/{baseName}.{extension}"
|
||
downloadResult = await Tools.Files.download(remoteVideoUrl, filePath)
|
||
if not downloadResult.successful:
|
||
raise Error(f"下载视频失败: {downloadResult.details}")
|
||
|
||
fileUri = f"file://{filePath}"
|
||
markdownLink = f"[点击查看生成的视频]({fileUri})"
|
||
htmlVideo = f'<video controls src="{fileUri}"></video>'
|
||
|
||
hintLines = []
|
||
hintLines.append(f"视频已生成并保存在本地 {VIDEOS_DIR}。")
|
||
hintLines.append(f"本地路径: {filePath}")
|
||
hintLines.append("")
|
||
hintLines.append("后续回答如果要给出视频,请优先返回这个本地链接:")
|
||
hintLines.append(markdownLink)
|
||
hintLines.append("")
|
||
hintLines.append("如果当前渲染环境支持 HTML 视频标签,也可以使用:")
|
||
hintLines.append(htmlVideo)
|
||
|
||
return {
|
||
"prompt": prompt,
|
||
"model": createdTask["effective_model"],
|
||
"request_id": createdTask["request_id"],
|
||
"status": latestStatus,
|
||
"input_type": resolvedInputs["input_type"],
|
||
"aspect_ratio": createdTask["aspect_ratio"],
|
||
"resolution": createdTask["resolution"],
|
||
"duration": remoteDuration,
|
||
"remote_video_url": remoteVideoUrl,
|
||
"remote_content_type": remoteContentType,
|
||
"remote_expires_at": expiresAt,
|
||
"remote_respect_moderation": remoteRespectModeration,
|
||
"file_path": filePath,
|
||
"file_uri": fileUri,
|
||
"markdown_link": markdownLink,
|
||
"html_video": htmlVideo,
|
||
"hint": "\n".join(hintLines),
|
||
}
|
||
|
||
|
||
async def draw_image_wrapper(params):
|
||
try:
|
||
result = await draw_image(params)
|
||
complete({
|
||
"success": True,
|
||
"message": "xAI 图片生成成功,已下载到本地。",
|
||
"data": result,
|
||
})
|
||
except Exception as error:
|
||
console.error("draw_image 执行失败:", error)
|
||
complete({
|
||
"success": False,
|
||
"message": f"xAI 图片生成失败: {getErrorMessage(error)}",
|
||
"error_stack": getErrorStack(error),
|
||
})
|
||
|
||
|
||
async def draw_video_wrapper(params):
|
||
try:
|
||
result = await draw_video(params if params else {})
|
||
complete({
|
||
"success": True,
|
||
"message": "xAI 视频生成成功,已下载到本地。",
|
||
"data": result,
|
||
})
|
||
except Exception as error:
|
||
console.error("draw_video 执行失败:", error)
|
||
complete({
|
||
"success": False,
|
||
"message": f"xAI 视频生成失败: {getErrorMessage(error)}",
|
||
"error_stack": getErrorStack(error),
|
||
})
|
||
|
||
|
||
exports.draw_image = draw_image_wrapper
|
||
exports.draw_video = draw_video_wrapper
|