diff --git a/_public/static/admin/js/config.js b/_public/static/admin/js/config.js index 84c7111e..ecf60ae0 100644 --- a/_public/static/admin/js/config.js +++ b/_public/static/admin/js/config.js @@ -92,6 +92,7 @@ const LOCALE_MAP = { "video": { "label": "视频配置", + "enable_public_asset": { title: "公开资产链接", desc: "是否开启生成结束后创建 Public 资产。" }, "concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" }, "timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" }, "stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" }, diff --git a/_public/static/i18n/locales/en.json b/_public/static/i18n/locales/en.json index d417fc3f..3e66c6f4 100644 --- a/_public/static/i18n/locales/en.json +++ b/_public/static/i18n/locales/en.json @@ -171,6 +171,7 @@ "stream_timeout": {"title": "Stream Idle Timeout", "desc": "Streaming idle timeout (seconds)."} }, "video": { + "enable_public_asset": {"title": "Public Asset Link", "desc": "Whether to create a public asset link after video generation finishes."}, "concurrent": {"title": "Concurrency Limit", "desc": "Reverse API concurrency limit."}, "timeout": {"title": "Request Timeout", "desc": "Reverse API timeout (seconds)."}, "stream_timeout": {"title": "Stream Idle Timeout", "desc": "Streaming idle timeout (seconds)."}, diff --git a/_public/static/i18n/locales/zh.json b/_public/static/i18n/locales/zh.json index 1cd4df3e..206e70e3 100644 --- a/_public/static/i18n/locales/zh.json +++ b/_public/static/i18n/locales/zh.json @@ -171,6 +171,7 @@ "stream_timeout": {"title": "流空闲超时", "desc": "流式空闲超时时间(秒)。"} }, "video": { + "enable_public_asset": {"title": "公开资产链接", "desc": "是否开启生成结束后创建 Public 资产。"}, "concurrent": {"title": "并发上限", "desc": "Reverse 接口并发上限。"}, "timeout": {"title": "请求超时", "desc": "Reverse 接口超时时间(秒)。"}, "stream_timeout": {"title": "流空闲超时", "desc": "流式空闲超时时间(秒)。"}, diff --git a/app/services/grok/services/video.py b/app/services/grok/services/video.py index 39da2763..30121fd8 100644 --- a/app/services/grok/services/video.py +++ b/app/services/grok/services/video.py @@ -29,6 +29,7 @@ from app.services.grok.utils.stream import wrap_stream_with_usage from app.services.reverse.app_chat import AppChatReverse from app.services.reverse.media_post import MediaPostReverse +from app.services.reverse.media_post_link import MediaPostLinkReverse from app.services.reverse.utils.session import ResettableSession from app.services.reverse.video_upscale import VideoUpscaleReverse from app.services.token import EffortType, get_token_manager @@ -1092,6 +1093,7 @@ def __init__( self.token = token self.show_think = bool(show_think) self.upscale_on_finish = bool(upscale_on_finish) + self.enable_public_asset = bool(get_config("video.enable_public_asset")) self.round_index = max(1, int(round_index or 1)) self.round_total = max(self.round_index, int(round_total or self.round_index)) @@ -1124,6 +1126,28 @@ async def close(self): await self._dl_service.close() self._dl_service = None + async def _create_public_link(self, video_url: str) -> str: + if not video_url or not self.enable_public_asset: + return video_url + video_id = _extract_video_id(video_url) + if not video_id: + logger.warning("Video public link skipped: unable to extract video id") + return video_url + try: + async with _new_session() as session: + response = await MediaPostLinkReverse.request( + session, self.token, video_id + ) + payload = response.json() if response is not None else {} + share_link = payload.get("shareLink") if isinstance(payload, dict) else None + if share_link: + public_url = f"https://imagine-public.x.ai/imagine-public/share-videos/{video_id}.mp4?cache=1" + logger.info(f"Video public link created: {public_url}") + return public_url + except Exception as e: + logger.warning(f"Video public link failed: {e}") + return video_url + async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, None]: result = VideoRoundResult() try: @@ -1157,6 +1181,11 @@ async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, N if not upscaled: logger.warning("Video upscale failed, fallback to 480p result") + if self.enable_public_asset: + for chunk in self.writer.emit_note("正在生成可公开访问链接\n"): + yield chunk + final_video_url = await self._create_public_link(final_video_url) + rendered = await self._get_dl().render_video( final_video_url, self.token, @@ -1187,6 +1216,7 @@ def __init__( self.model = model self.token = token self.upscale_on_finish = bool(upscale_on_finish) + self.enable_public_asset = bool(get_config("video.enable_public_asset")) self.round_index = max(1, int(round_index or 1)) self.round_total = max(self.round_index, int(round_total or self.round_index)) self._dl_service: Optional[DownloadService] = None @@ -1201,6 +1231,28 @@ async def close(self): await self._dl_service.close() self._dl_service = None + async def _create_public_link(self, video_url: str) -> str: + if not video_url or not self.enable_public_asset: + return video_url + video_id = _extract_video_id(video_url) + if not video_id: + logger.warning("Video public link skipped: unable to extract video id") + return video_url + try: + async with _new_session() as session: + response = await MediaPostLinkReverse.request( + session, self.token, video_id + ) + payload = response.json() if response is not None else {} + share_link = payload.get("shareLink") if isinstance(payload, dict) else None + if share_link: + public_url = f"https://imagine-public.x.ai/imagine-public/share-videos/{video_id}.mp4?cache=1" + logger.info(f"Video public link created: {public_url}") + return public_url + except Exception as e: + logger.warning(f"Video public link failed: {e}") + return video_url + async def process(self, response: AsyncIterable[bytes]) -> Dict[str, Any]: try: result = await _collect_round_result( @@ -1222,6 +1274,9 @@ async def process(self, response: AsyncIterable[bytes]) -> Dict[str, Any]: if not upscaled: logger.warning("Video upscale failed, fallback to 480p result") + if self.enable_public_asset: + final_video_url = await self._create_public_link(final_video_url) + content = await self._get_dl().render_video( final_video_url, self.token, diff --git a/app/services/reverse/media_post_link.py b/app/services/reverse/media_post_link.py new file mode 100644 index 00000000..abd5b417 --- /dev/null +++ b/app/services/reverse/media_post_link.py @@ -0,0 +1,107 @@ +""" +Reverse interface: media post create link. +""" + +import orjson +from typing import Any +from curl_cffi.requests import AsyncSession + +from app.core.logger import logger +from app.core.config import get_config +from app.core.exceptions import UpstreamException +from app.services.token.service import TokenService +from app.services.reverse.utils.headers import build_headers +from app.services.reverse.utils.retry import retry_on_status + +MEDIA_POST_LINK_API = "https://grok.com/rest/media/post/create-link" + + +class MediaPostLinkReverse: + """/rest/media/post/create-link reverse interface.""" + + @staticmethod + async def request( + session: AsyncSession, + token: str, + post_id: str, + ) -> Any: + try: + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + proxies = {"http": base_proxy, "https": base_proxy} if base_proxy else None + + # Build headers + headers = build_headers( + cookie_token=token, + content_type="application/json", + origin="https://grok.com", + referer="https://grok.com", + ) + + # Build payload + payload = { + "postId": post_id, + "source": "post-page", + "platform": "web" + } + + # Curl Config + timeout = get_config("video.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + MEDIA_POST_LINK_API, + headers=headers, + data=orjson.dumps(payload), + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + content = "" + try: + content = await response.text() + except Exception: + pass + logger.error( + f"MediaPostLinkReverse: Media post create link failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"MediaPostLinkReverse: Media post create link failed, {response.status_code}", + details={"status": response.status_code, "body": content}, + ) + + return response + + return await retry_on_status(_do_request) + + except Exception as e: + # Handle upstream exception + if isinstance(e, UpstreamException): + status = None + if e.details and "status" in e.details: + status = e.details["status"] + else: + status = getattr(e, "status_code", None) + if status == 401: + try: + await TokenService.record_fail(token, status, "media_post_link_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"MediaPostLinkReverse: Media post create link failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"MediaPostLinkReverse: Media post create link failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["MediaPostLinkReverse"] diff --git a/config.defaults.toml b/config.defaults.toml index c9e31859..b2588e51 100644 --- a/config.defaults.toml +++ b/config.defaults.toml @@ -142,6 +142,8 @@ response_format = "url" # ==================== 视频配置 ==================== [video] +# 是否开启生成结束后 Public 资产 +enable_public_asset = true # Reverse 接口并发上限 concurrent = 100 # Reverse 接口超时时间(秒)