From dbf746ca4848ee44f54f72f746962b1ee49d10bf Mon Sep 17 00:00:00 2001 From: "Fern.N" Date: Sat, 7 Mar 2026 16:05:45 +0700 Subject: [PATCH] feat: Add option to generate public asset links for videos --- _public/static/admin/js/config.js | 1 + _public/static/i18n/locales/en.json | 1 + _public/static/i18n/locales/zh.json | 1 + app/services/grok/services/video.py | 53 ++++++++++++ app/services/reverse/media_post_link.py | 107 ++++++++++++++++++++++++ config.defaults.toml | 2 + 6 files changed, 165 insertions(+) create mode 100644 app/services/reverse/media_post_link.py diff --git a/_public/static/admin/js/config.js b/_public/static/admin/js/config.js index 907dc8e3..c2a2d921 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 7bcd1526..fd43789d 100644 --- a/_public/static/i18n/locales/en.json +++ b/_public/static/i18n/locales/en.json @@ -170,6 +170,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 95157910..ebcdf853 100644 --- a/_public/static/i18n/locales/zh.json +++ b/_public/static/i18n/locales/zh.json @@ -170,6 +170,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 ed7dbd0c..585d67dc 100644 --- a/app/services/grok/services/video.py +++ b/app/services/grok/services/video.py @@ -31,6 +31,7 @@ from app.services.grok.utils.retry import rate_limited 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.video_upscale import VideoUpscaleReverse from app.services.reverse.utils.session import ResettableSession from app.services.token.manager import BASIC_POOL_NAME @@ -683,6 +684,7 @@ def __init__( 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")) @staticmethod def _extract_video_id(video_url: str) -> str: @@ -717,6 +719,28 @@ async def _upscale_video_url(self, video_url: str) -> str: logger.warning(f"Video upscale failed: {e}") return video_url + 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 = self._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 + def _sse(self, content: str = "", role: str = None, finish: str = None) -> str: """Build SSE response.""" delta = {} @@ -812,6 +836,10 @@ async def process( if self.upscale_on_finish: yield self._sse("正在对视频进行超分辨率\n") video_url = await self._upscale_video_url(video_url) + + if self.enable_public_asset: + yield self._sse("正在生成可公开访问链接\n") + video_url = await self._create_public_link(video_url) dl_service = self._get_dl() rendered = await dl_service.render_video( video_url, self.token, thumbnail_url @@ -873,6 +901,7 @@ class VideoCollectProcessor(BaseProcessor): def __init__(self, model: str, token: str = "", upscale_on_finish: bool = False): super().__init__(model, token) self.upscale_on_finish = bool(upscale_on_finish) + self.enable_public_asset = bool(get_config("video.enable_public_asset")) @staticmethod def _extract_video_id(video_url: str) -> str: @@ -907,6 +936,28 @@ async def _upscale_video_url(self, video_url: str) -> str: logger.warning(f"Video upscale failed: {e}") return video_url + 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 = self._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]: """Process and collect video response.""" response_id = "" @@ -934,6 +985,8 @@ async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]: if video_url: if self.upscale_on_finish: video_url = await self._upscale_video_url(video_url) + if self.enable_public_asset: + video_url = await self._create_public_link(video_url) dl_service = self._get_dl() content = await dl_service.render_video( video_url, self.token, thumbnail_url 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 2f3dbcca..08074479 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 接口超时时间(秒)