feat: 改用官网拼接延长效果更佳
This commit is contained in:
1 parent
755fa30afa
commit
2ae236ace3
9 files changed
+890
-1217
No files matched your search
@@ -139,6 +139,13 @@ async def _new_session(
|
||||
parent_post_id: Optional[str],
|
||||
source_image_url: Optional[str],
|
||||
reasoning_effort: Optional[str],
|
||||
# 视频延长相关
|
||||
is_video_extension: bool = False,
|
||||
extend_post_id: Optional[str] = None,
|
||||
video_extension_start_time: Optional[float] = None,
|
||||
original_post_id: Optional[str] = None,
|
||||
file_attachment_id: Optional[str] = None,
|
||||
stitch_with_extend: bool = True,
|
||||
) -> str:
|
||||
task_id = uuid.uuid4().hex
|
||||
now = time.time()
|
||||
@@ -154,6 +161,12 @@ async def _new_session(
|
||||
"parent_post_id": parent_post_id,
|
||||
"source_image_url": source_image_url,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"is_video_extension": is_video_extension,
|
||||
"extend_post_id": extend_post_id,
|
||||
"video_extension_start_time": video_extension_start_time,
|
||||
"original_post_id": original_post_id,
|
||||
"file_attachment_id": file_attachment_id,
|
||||
"stitch_with_extend": stitch_with_extend,
|
||||
"created_at": now,
|
||||
}
|
||||
return task_id
|
||||
@@ -234,6 +247,13 @@ class VideoStartRequest(BaseModel):
|
||||
source_image_url: Optional[str] = None
|
||||
reasoning_effort: Optional[str] = None
|
||||
edit_context: Optional[Dict[str, Any]] = None
|
||||
# 视频延长相关字段
|
||||
is_video_extension: Optional[bool] = False
|
||||
extend_post_id: Optional[str] = None
|
||||
video_extension_start_time: Optional[float] = None
|
||||
original_post_id: Optional[str] = None
|
||||
file_attachment_id: Optional[str] = None
|
||||
stitch_with_extend: Optional[bool] = True
|
||||
|
||||
|
||||
@router.post("/video/start", dependencies=[Depends(verify_public_key)])
|
||||
@@ -281,15 +301,47 @@ async def public_video_start(data: VideoStartRequest):
|
||||
elif source_image_url:
|
||||
_validate_image_url(source_image_url)
|
||||
|
||||
if parent_post_id and image_url:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="image_url and parent_post_id cannot be used together"
|
||||
)
|
||||
if not prompt and not image_url and not parent_post_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Prompt cannot be empty when no image_url/parent_post_id is provided",
|
||||
# 视频延长参数解析
|
||||
is_video_extension = bool(data.is_video_extension)
|
||||
extend_post_id = _validate_parent_post_id(data.extend_post_id or "")
|
||||
video_extension_start_time = data.video_extension_start_time
|
||||
original_post_id = _validate_parent_post_id(data.original_post_id or "")
|
||||
file_attachment_id = _validate_parent_post_id(data.file_attachment_id or "")
|
||||
stitch_with_extend = bool(data.stitch_with_extend if data.stitch_with_extend is not None else True)
|
||||
|
||||
if is_video_extension:
|
||||
# 视频延长模式校验
|
||||
if not extend_post_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="extend_post_id is required for video extension",
|
||||
)
|
||||
if video_extension_start_time is None or video_extension_start_time < 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="video_extension_start_time must be a non-negative number",
|
||||
)
|
||||
|
||||
# 官方服务端对延长视频容易触发风控,在此强制固定并发为1
|
||||
concurrent = 1
|
||||
|
||||
logger.info(
|
||||
"Public video extension request: "
|
||||
f"extend_post_id={extend_post_id}, "
|
||||
f"start_time={video_extension_start_time}, "
|
||||
f"original_post_id={original_post_id}, "
|
||||
f"file_attachment_id={file_attachment_id}"
|
||||
)
|
||||
else:
|
||||
if parent_post_id and image_url:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="image_url and parent_post_id cannot be used together"
|
||||
)
|
||||
if not prompt and not image_url and not parent_post_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Prompt cannot be empty when no image_url/parent_post_id is provided",
|
||||
)
|
||||
|
||||
reasoning_effort = (data.reasoning_effort or "").strip() or None
|
||||
if reasoning_effort:
|
||||
@@ -326,6 +378,12 @@ async def public_video_start(data: VideoStartRequest):
|
||||
parent_post_id,
|
||||
source_image_url,
|
||||
reasoning_effort,
|
||||
is_video_extension=is_video_extension,
|
||||
extend_post_id=extend_post_id,
|
||||
video_extension_start_time=video_extension_start_time,
|
||||
original_post_id=original_post_id,
|
||||
file_attachment_id=file_attachment_id,
|
||||
stitch_with_extend=stitch_with_extend,
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
|
||||
@@ -335,6 +393,8 @@ async def public_video_start(data: VideoStartRequest):
|
||||
"concurrent": concurrent,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"parent_post_id": parent_post_id,
|
||||
"extend_post_id": extend_post_id,
|
||||
"file_attachment_id": file_attachment_id or "",
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +461,14 @@ async def public_video_sse(request: Request, task_id: str = Query("")):
|
||||
else:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
# 从 session 取得视频延长参数
|
||||
is_video_extension = bool(session.get("is_video_extension"))
|
||||
extend_post_id = str(session.get("extend_post_id") or "").strip() or None
|
||||
video_extension_start_time = session.get("video_extension_start_time")
|
||||
original_post_id = str(session.get("original_post_id") or "").strip() or None
|
||||
file_attachment_id = str(session.get("file_attachment_id") or "").strip() or None
|
||||
stitch_with_extend = bool(session.get("stitch_with_extend", True))
|
||||
|
||||
stream = await VideoService.completions(
|
||||
model_id,
|
||||
messages,
|
||||
@@ -411,12 +479,21 @@ async def public_video_sse(request: Request, task_id: str = Query("")):
|
||||
resolution=resolution_name,
|
||||
preset=preset,
|
||||
parent_post_id=parent_post_id or None,
|
||||
extend_post_id=extend_post_id if is_video_extension else None,
|
||||
video_extension_start_time=video_extension_start_time if is_video_extension else None,
|
||||
original_post_id=original_post_id if is_video_extension else None,
|
||||
file_attachment_id=file_attachment_id if is_video_extension else None,
|
||||
stitch_with_extend=stitch_with_extend,
|
||||
source_image_url=source_image_url,
|
||||
preferred_token=preferred_token,
|
||||
)
|
||||
|
||||
async for chunk in stream:
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"Public video client disconnected: {task_id}")
|
||||
break
|
||||
if task_id not in _VIDEO_SESSIONS:
|
||||
logger.info(f"Public video task stopped by user: {task_id}")
|
||||
break
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
|
||||
@@ -425,13 +425,30 @@ class ImageWSBaseProcessor(BaseProcessor):
|
||||
async def _to_output(self, image_id: str, item: Dict) -> str:
|
||||
try:
|
||||
if self.response_format == "url":
|
||||
return await self._save_blob(
|
||||
res = await self._save_blob(
|
||||
image_id,
|
||||
item.get("blob", ""),
|
||||
item.get("is_final", False),
|
||||
ext=item.get("ext"),
|
||||
)
|
||||
return self._strip_base64(item.get("blob", ""))
|
||||
|
||||
# [NEW] 当图像生成存盘时,保存其 tokenId 用以后续延长关联
|
||||
if res and image_id and getattr(self, "token", None):
|
||||
from app.services.grok.utils.asset_token_map import AssetTokenMap
|
||||
token_map = await AssetTokenMap.get_instance()
|
||||
await token_map.save_mapping(image_id, self.token)
|
||||
|
||||
return res
|
||||
|
||||
res = self._strip_base64(item.get("blob", ""))
|
||||
|
||||
# [NEW] Base64 响应下同样强绑定
|
||||
if res and image_id and getattr(self, "token", None):
|
||||
from app.services.grok.utils.asset_token_map import AssetTokenMap
|
||||
token_map = await AssetTokenMap.get_instance()
|
||||
await token_map.save_mapping(image_id, self.token)
|
||||
|
||||
return res
|
||||
except Exception as e:
|
||||
logger.warning(f"Image output failed: {e}")
|
||||
return ""
|
||||
|
||||
@@ -167,6 +167,17 @@ class VideoService:
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _map_preset_to_mode(preset: str) -> str:
|
||||
"""将前端预设名映射为 Grok 官方 mode 参数。"""
|
||||
mapping = {
|
||||
"spicy": "extremely-spicy-or-crazy",
|
||||
"fun": "extremely-crazy",
|
||||
"normal": "normal",
|
||||
}
|
||||
# 如果预设没发送,默认 --mode=extremely-spicy-or-crazy (即 spicy)
|
||||
return mapping.get(preset, "extremely-spicy-or-crazy")
|
||||
|
||||
@staticmethod
|
||||
def _build_video_message(
|
||||
prompt: str,
|
||||
@@ -175,18 +186,22 @@ class VideoService:
|
||||
) -> str:
|
||||
"""构造视频请求 message:
|
||||
- 有提示词:统一走 custom,并发送 image_url + prompt + mode
|
||||
- 无提示词:统一走 spicy(忽略 preset)
|
||||
- 无提示词:根据所选 preset 转换 mode
|
||||
"""
|
||||
prompt_text = (prompt or "").strip()
|
||||
if not VideoService.is_meaningful_video_prompt(prompt_text):
|
||||
prompt_text = ""
|
||||
|
||||
image_core = (source_image_url or "").strip()
|
||||
if prompt_text:
|
||||
mode_flag = "--mode=custom"
|
||||
if image_core:
|
||||
return f"{image_core} {prompt_text} --mode=custom"
|
||||
return f"{prompt_text} --mode=custom"
|
||||
return f"{image_core} {prompt_text} {mode_flag}"
|
||||
return f"{prompt_text} {mode_flag}"
|
||||
|
||||
mode_flag = "--mode=extremely-spicy-or-crazy"
|
||||
# 无提示词(或泛化指令)
|
||||
official_mode = VideoService._map_preset_to_mode(preset)
|
||||
mode_flag = f"--mode={official_mode}"
|
||||
if image_core:
|
||||
return f"{image_core} {mode_flag}"
|
||||
return mode_flag
|
||||
@@ -269,13 +284,12 @@ class VideoService:
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Generate video."""
|
||||
token_tag = _token_tag(token)
|
||||
mode = (
|
||||
"custom"
|
||||
if VideoService.is_meaningful_video_prompt(prompt)
|
||||
else "extremely-spicy-or-crazy"
|
||||
)
|
||||
# 确定逻辑上的 mode
|
||||
is_custom = VideoService.is_meaningful_video_prompt(prompt)
|
||||
official_mode = "custom" if is_custom else VideoService._map_preset_to_mode(preset)
|
||||
|
||||
logger.info(
|
||||
f"Video generation: token={token_tag}, prompt='{prompt[:50]}...', ratio={aspect_ratio}, length={video_length}s, mode={mode}"
|
||||
f"Video generation: token={token_tag}, prompt='{prompt[:50]}...', ratio={aspect_ratio}, length={video_length}s, mode={official_mode}"
|
||||
)
|
||||
post_id = await self.create_post(token, prompt)
|
||||
message = self._build_video_message(prompt=prompt, preset=preset)
|
||||
@@ -303,6 +317,7 @@ class VideoService:
|
||||
token,
|
||||
message=message,
|
||||
model="grok-3",
|
||||
mode=official_mode,
|
||||
tool_overrides={"videoGen": True},
|
||||
model_config_override=model_config_override,
|
||||
)
|
||||
@@ -359,13 +374,12 @@ class VideoService:
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Generate video from image."""
|
||||
token_tag = _token_tag(token)
|
||||
mode = (
|
||||
"custom"
|
||||
if VideoService.is_meaningful_video_prompt(prompt)
|
||||
else "extremely-spicy-or-crazy"
|
||||
)
|
||||
# 确定逻辑上的 mode
|
||||
is_custom = VideoService.is_meaningful_video_prompt(prompt)
|
||||
official_mode = "custom" if is_custom else VideoService._map_preset_to_mode(preset)
|
||||
|
||||
logger.info(
|
||||
f"Image to video: token={token_tag}, prompt='{prompt[:50]}...', image={image_url[:80]}, mode={mode}"
|
||||
f"Image to video: token={token_tag}, prompt='{prompt[:50]}...', image={image_url[:80]}, mode={official_mode}"
|
||||
)
|
||||
post_id = await self.create_image_post(token, image_url)
|
||||
message = self._build_video_message(
|
||||
@@ -397,6 +411,7 @@ class VideoService:
|
||||
token,
|
||||
message=message,
|
||||
model="grok-3",
|
||||
mode=official_mode,
|
||||
tool_overrides={"videoGen": True},
|
||||
model_config_override=model_config_override,
|
||||
)
|
||||
@@ -454,11 +469,7 @@ class VideoService:
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Generate video by existing parent post ID (preferred path)."""
|
||||
token_tag = _token_tag(token)
|
||||
mode = (
|
||||
"custom"
|
||||
if VideoService.is_meaningful_video_prompt(prompt)
|
||||
else "extremely-spicy-or-crazy"
|
||||
)
|
||||
is_custom = VideoService.is_meaningful_video_prompt(prompt)
|
||||
logger.info(
|
||||
f"ParentPost to video: token={token_tag}, prompt='{prompt[:50]}...', parent_post_id={parent_post_id}"
|
||||
)
|
||||
@@ -504,11 +515,15 @@ class VideoService:
|
||||
}
|
||||
moderated_max_retry = max(1, int(get_config("video.moderated_max_retry", 5)))
|
||||
|
||||
# 确定逻辑上的 mode
|
||||
is_custom = VideoService.is_meaningful_video_prompt(prompt)
|
||||
official_mode = "custom" if is_custom else VideoService._map_preset_to_mode(preset)
|
||||
|
||||
logger.info(
|
||||
"ParentPost video request prepared: "
|
||||
f"token={token_tag}, parent_post_id={parent_post_id}, "
|
||||
f"message_len={len(message)}, has_prompt={bool((prompt or '').strip())}, "
|
||||
f"resolution={resolution}, video_length={video_length}, ratio={aspect_ratio}, mode={mode}"
|
||||
f"message_len={len(message)}, has_prompt={is_custom}, "
|
||||
f"resolution={resolution}, video_length={video_length}, ratio={aspect_ratio}, mode={official_mode}"
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
@@ -522,6 +537,7 @@ class VideoService:
|
||||
token,
|
||||
message=message,
|
||||
model="grok-3",
|
||||
mode=official_mode,
|
||||
tool_overrides={"videoGen": True},
|
||||
model_config_override=model_config_override,
|
||||
)
|
||||
@@ -567,6 +583,144 @@ class VideoService:
|
||||
|
||||
return _stream()
|
||||
|
||||
async def generate_extend_video(
|
||||
self,
|
||||
token: str,
|
||||
prompt: str,
|
||||
extend_post_id: str,
|
||||
video_extension_start_time: float,
|
||||
original_post_id: str = "",
|
||||
file_attachment_id: str = "",
|
||||
aspect_ratio: str = "16:9",
|
||||
video_length: int = 6,
|
||||
resolution: str = "480p",
|
||||
preset: str = "normal",
|
||||
stitch_with_extend: bool = True,
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""通过 Grok 官方视频延长 API 延长视频。"""
|
||||
token_tag = _token_tag(token)
|
||||
# 确定 mode
|
||||
prompt_text = (prompt or "").strip()
|
||||
is_custom = VideoService.is_meaningful_video_prompt(prompt_text)
|
||||
if is_custom:
|
||||
mode = "custom"
|
||||
else:
|
||||
mode = VideoService._map_preset_to_mode(preset)
|
||||
prompt_text = ""
|
||||
|
||||
effective_original = (original_post_id or "").strip() or extend_post_id
|
||||
effective_file_attachment = (file_attachment_id or "").strip() or effective_original
|
||||
|
||||
logger.info(
|
||||
"Video extension request: "
|
||||
f"token={token_tag}, extend_post_id={extend_post_id}, "
|
||||
f"start_time={video_extension_start_time}, original_post_id={effective_original}, "
|
||||
f"prompt='{(prompt_text or '')[:50]}', mode={mode}"
|
||||
)
|
||||
|
||||
# 构造 message
|
||||
if prompt_text:
|
||||
message = f"{prompt_text} --mode={mode}"
|
||||
else:
|
||||
message = f"--mode={mode}"
|
||||
|
||||
# 构造 videoGenModelConfig —— 对齐官网抓包格式
|
||||
video_gen_config = {
|
||||
"isVideoExtension": True,
|
||||
"videoExtensionStartTime": video_extension_start_time,
|
||||
"extendPostId": extend_post_id,
|
||||
"stitchWithExtendPostId": stitch_with_extend,
|
||||
"originalPostId": effective_original,
|
||||
"originalRefType": "ORIGINAL_REF_TYPE_VIDEO_EXTENSION",
|
||||
"mode": mode,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"videoLength": video_length,
|
||||
"resolutionName": resolution,
|
||||
"parentPostId": extend_post_id,
|
||||
"isVideoEdit": False,
|
||||
}
|
||||
if prompt_text:
|
||||
video_gen_config["originalPrompt"] = prompt_text
|
||||
|
||||
model_config_override = {
|
||||
"modelMap": {
|
||||
"videoGenModelConfig": video_gen_config,
|
||||
}
|
||||
}
|
||||
|
||||
# fileAttachments 对齐官网:始终传最初图转视频时的 parentPostId
|
||||
file_attachments = [effective_file_attachment]
|
||||
|
||||
moderated_max_retry = max(1, int(get_config("video.moderated_max_retry", 5)))
|
||||
|
||||
logger.info(
|
||||
"Video extension request prepared: "
|
||||
f"token={token_tag}, extend_post_id={extend_post_id}, "
|
||||
f"file_attachments={file_attachments}, "
|
||||
f"start_time={video_extension_start_time}, mode={mode}, "
|
||||
f"resolution={resolution}, video_length={video_length}, ratio={aspect_ratio}"
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
for attempt in range(1, moderated_max_retry + 1):
|
||||
session = AsyncSession()
|
||||
moderated_hit = False
|
||||
try:
|
||||
async with _get_video_semaphore():
|
||||
stream_response = await AppChatReverse.request(
|
||||
session,
|
||||
token,
|
||||
message=message,
|
||||
model="grok-3",
|
||||
mode=mode,
|
||||
file_attachments=file_attachments,
|
||||
tool_overrides={"videoGen": True},
|
||||
model_config_override=model_config_override,
|
||||
)
|
||||
logger.info(
|
||||
"Video extension started: "
|
||||
f"token={token_tag}, extend_post_id={extend_post_id}, "
|
||||
f"attempt={attempt}/{moderated_max_retry}"
|
||||
)
|
||||
async for line in stream_response:
|
||||
if self._is_moderated_line(line):
|
||||
moderated_hit = True
|
||||
logger.warning(
|
||||
f"Video extension moderated: token={token_tag}, "
|
||||
f"retry {attempt}/{moderated_max_retry}"
|
||||
)
|
||||
break
|
||||
yield line
|
||||
|
||||
if not moderated_hit:
|
||||
return
|
||||
if attempt < moderated_max_retry:
|
||||
await asyncio.sleep(1.2)
|
||||
continue
|
||||
raise UpstreamException(
|
||||
"Video extension blocked by moderation",
|
||||
status_code=400,
|
||||
details={"moderated": True, "attempts": moderated_max_retry},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Video extension error: {e}")
|
||||
if isinstance(e, AppException):
|
||||
raise
|
||||
msg, code, status = _classify_video_error(e)
|
||||
raise AppException(
|
||||
message=msg,
|
||||
error_type=ErrorType.SERVER.value if status >= 500 else ErrorType.INVALID_REQUEST.value,
|
||||
code=code,
|
||||
status_code=status,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _stream()
|
||||
|
||||
@staticmethod
|
||||
async def completions(
|
||||
model: str,
|
||||
@@ -578,6 +732,11 @@ class VideoService:
|
||||
resolution: str = "480p",
|
||||
preset: str = "normal",
|
||||
parent_post_id: str | None = None,
|
||||
extend_post_id: str | None = None,
|
||||
video_extension_start_time: float | None = None,
|
||||
original_post_id: str | None = None,
|
||||
file_attachment_id: str | None = None,
|
||||
stitch_with_extend: bool = True,
|
||||
source_image_url: str | None = None,
|
||||
preferred_token: str | None = None,
|
||||
):
|
||||
@@ -603,6 +762,19 @@ class VideoService:
|
||||
parent_post_id = (parent_post_id or "").strip() or None
|
||||
source_image_url = (source_image_url or "").strip()
|
||||
preferred_token = (preferred_token or "").strip()
|
||||
|
||||
# [NEW] 尝试通过 extend_post_id 或者 parent_post_id 获取强绑定的 token
|
||||
from app.services.grok.utils.asset_token_map import AssetTokenMap
|
||||
token_map = await AssetTokenMap.get_instance()
|
||||
bound_token = None
|
||||
if extend_post_id:
|
||||
bound_token = await token_map.get_token(extend_post_id)
|
||||
elif parent_post_id:
|
||||
bound_token = await token_map.get_token(parent_post_id)
|
||||
|
||||
if bound_token:
|
||||
preferred_token = bound_token
|
||||
|
||||
if preferred_token.startswith("sso="):
|
||||
preferred_token = preferred_token[4:]
|
||||
used_tokens: set[str] = set()
|
||||
@@ -619,7 +791,7 @@ class VideoService:
|
||||
else:
|
||||
used_tokens.add(preferred_token)
|
||||
logger.warning(
|
||||
f"Video token routing: preferred token not in pool, fallback to normal routing "
|
||||
f"Video token routing: preferred bound token not in pool, fallback to normal routing "
|
||||
f"(token={_token_tag(preferred_token)})"
|
||||
)
|
||||
|
||||
@@ -668,7 +840,22 @@ class VideoService:
|
||||
|
||||
# Generate video.
|
||||
service = VideoService()
|
||||
if parent_post_id:
|
||||
if extend_post_id and video_extension_start_time is not None:
|
||||
# 视频延长路径
|
||||
response = await service.generate_extend_video(
|
||||
token=token,
|
||||
prompt=prompt,
|
||||
extend_post_id=extend_post_id,
|
||||
video_extension_start_time=video_extension_start_time,
|
||||
original_post_id=original_post_id or "",
|
||||
file_attachment_id=file_attachment_id or "",
|
||||
aspect_ratio=aspect_ratio,
|
||||
video_length=video_length,
|
||||
resolution=resolution,
|
||||
preset=preset,
|
||||
stitch_with_extend=stitch_with_extend,
|
||||
)
|
||||
elif parent_post_id:
|
||||
response = await service.generate_from_parent_post(
|
||||
token=token,
|
||||
prompt=prompt,
|
||||
@@ -886,6 +1073,13 @@ class VideoStreamProcessor(BaseProcessor):
|
||||
if progress == 100:
|
||||
video_url = video_resp.get("videoUrl", "")
|
||||
thumbnail_url = video_resp.get("thumbnailImageUrl", "")
|
||||
|
||||
# [NEW] 记录生成的视频对应的 postId 与 token 以备延长
|
||||
video_post_id = video_resp.get("videoPostId") or self._extract_video_id(video_url)
|
||||
if video_post_id and self.token:
|
||||
from app.services.grok.utils.asset_token_map import AssetTokenMap
|
||||
token_map = await AssetTokenMap.get_instance()
|
||||
await token_map.save_mapping(video_post_id, self.token)
|
||||
|
||||
if self.think_opened:
|
||||
yield self._sse("\n</think>\n")
|
||||
@@ -1103,6 +1297,12 @@ class VideoCollectProcessor(BaseProcessor):
|
||||
response_id = resp.get("responseId", "")
|
||||
video_url = video_resp.get("videoUrl", "")
|
||||
thumbnail_url = video_resp.get("thumbnailImageUrl", "")
|
||||
|
||||
# [NEW] 记录生成的视频对应的 postId 与 token 以备延长
|
||||
if fallback_video_id and self.token:
|
||||
from app.services.grok.utils.asset_token_map import AssetTokenMap
|
||||
token_map = await AssetTokenMap.get_instance()
|
||||
await token_map.save_mapping(fallback_video_id, self.token)
|
||||
|
||||
if video_url:
|
||||
if self.upscale_on_finish:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
from app.core.logger import logger
|
||||
from app.core.storage import DATA_DIR
|
||||
from app.services.grok.utils.locks import _file_lock
|
||||
|
||||
|
||||
class AssetTokenMap:
|
||||
"""管理 postId 与生成时使用 token 的映射关系"""
|
||||
|
||||
_instance: Optional["AssetTokenMap"] = None
|
||||
_lock = asyncio.Lock()
|
||||
_file_path = DATA_DIR / "asset_tokens.json"
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, str] = {}
|
||||
self._initialized = False
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "AssetTokenMap":
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
await cls._instance._load()
|
||||
return cls._instance
|
||||
|
||||
async def _load(self):
|
||||
"""从本地加载映射关系"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
if self._file_path.exists():
|
||||
async with _file_lock("asset_token_map", timeout=5):
|
||||
content = self._file_path.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
self._cache = json.loads(content)
|
||||
self._initialized = True
|
||||
logger.info(f"Loaded {len(self._cache)} asset-token mappings from file.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load asset tokens: {e}")
|
||||
self._cache = {}
|
||||
|
||||
async def _save(self):
|
||||
"""持久化映射关系"""
|
||||
try:
|
||||
async with _file_lock("asset_token_map", timeout=5):
|
||||
self._file_path.write_text(json.dumps(self._cache, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save asset tokens: {e}")
|
||||
|
||||
async def save_mapping(self, post_id: str, token: str) -> None:
|
||||
"""保存 postId -> token 的映射"""
|
||||
if not post_id or not token:
|
||||
return
|
||||
|
||||
# 标准化 token 格式
|
||||
raw_token = token[4:] if token.startswith("sso=") else token
|
||||
|
||||
# 只保存需要的键值,防止频繁修改
|
||||
if self._cache.get(post_id) != raw_token:
|
||||
self._cache[post_id] = raw_token
|
||||
asyncio.create_task(self._save())
|
||||
logger.debug(f"Saved token mapping for post_id: {post_id}")
|
||||
|
||||
async def get_token(self, post_id: str) -> Optional[str]:
|
||||
"""找回当时生成该 post_id 的 token"""
|
||||
if not post_id:
|
||||
return None
|
||||
|
||||
token = self._cache.get(post_id)
|
||||
if token:
|
||||
logger.info(f"Retrieved mapped token for extension post_id: {post_id}")
|
||||
return token
|
||||
return None
|
||||
@@ -38,7 +38,7 @@ class VideoUpscaleReverse:
|
||||
cookie_token=token,
|
||||
content_type="application/json",
|
||||
origin="https://grok.com",
|
||||
referer="https://grok.com",
|
||||
referer=f"https://grok.com/imagine/post/{video_id}"
|
||||
)
|
||||
|
||||
# Build payload
|
||||
|
||||
@@ -998,7 +998,10 @@
|
||||
const timestamp = Date.now();
|
||||
const seq = meta && meta.sequence ? meta.sequence : 'unknown';
|
||||
const ext = mime === 'image/png' ? 'png' : 'jpg';
|
||||
const filename = `imagine_${timestamp}_${seq}.${ext}`;
|
||||
const autoNameParts = ['imagine', String(timestamp)];
|
||||
if (parentPostId) autoNameParts.push(parentPostId.slice(0, 16));
|
||||
autoNameParts.push(String(seq));
|
||||
const filename = `${autoNameParts.join('_')}.${ext}`;
|
||||
|
||||
if (useFileSystemAPI && directoryHandle) {
|
||||
saveToFileSystem(base64, filename).catch(() => {
|
||||
@@ -1752,7 +1755,11 @@
|
||||
if (!blob) {
|
||||
throw new Error('empty blob');
|
||||
}
|
||||
const filename = `${prompt.substring(0, 30).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_')}_${processed + 1}.png`;
|
||||
const pId = String(item.dataset.parentPostId || '').trim();
|
||||
const nameParts = [prompt.substring(0, 20).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_')];
|
||||
if (pId) nameParts.push(pId.slice(0, 16));
|
||||
nameParts.push(String(processed + 1));
|
||||
const filename = `${nameParts.join('_')}.png`;
|
||||
imgFolder.file(filename, blob);
|
||||
processed++;
|
||||
|
||||
|
||||
+366
-1051
File diff suppressed because it is too large.
Load diff
+105
-128
@@ -3,7 +3,8 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content">
|
||||
<title>Grok2API - Video 视频工作台</title>
|
||||
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
@@ -42,80 +43,81 @@
|
||||
<div class="video-card">
|
||||
<div class="card-title">生成设置</div>
|
||||
<div class="settings-grid">
|
||||
<div class="settings-block prompt-block">
|
||||
<label class="field-label" for="promptInput">提示词</label>
|
||||
<textarea id="promptInput" class="geist-input video-textarea" placeholder="例如:街头霓虹雨夜,慢镜头,胶片质感"></textarea>
|
||||
</div>
|
||||
<div id="refDropZone" class="settings-block ref-block">
|
||||
<label class="field-label" for="imageUrlInput">参考图</label>
|
||||
<div class="ref-controls">
|
||||
<input id="imageUrlInput" class="geist-input" placeholder="https://... 或 data:image/...">
|
||||
<div class="settings-block prompt-block">
|
||||
<label class="field-label" for="promptInput">提示词</label>
|
||||
<textarea id="promptInput" class="geist-input video-textarea"
|
||||
placeholder="例如:街头霓虹雨夜,慢镜头,胶片质感"></textarea>
|
||||
</div>
|
||||
<div class="ref-meta">
|
||||
<span id="imageFileName" class="ref-name">未选择文件</span>
|
||||
<span class="ref-drop-hint">支持拖拽/粘贴图片</span>
|
||||
<div id="refDropZone" class="settings-block ref-block">
|
||||
<label class="field-label" for="imageUrlInput">参考图</label>
|
||||
<div class="ref-controls">
|
||||
<input id="imageUrlInput" class="geist-input" placeholder="https://... 或 data:image/...">
|
||||
</div>
|
||||
<div class="ref-meta">
|
||||
<span id="imageFileName" class="ref-name">未选择文件</span>
|
||||
<span class="ref-drop-hint">支持拖拽/粘贴图片</span>
|
||||
</div>
|
||||
<div class="ref-parent-row">
|
||||
<input id="parentPostInput" class="geist-input" placeholder="粘贴 parentPostId(全局记忆)">
|
||||
<button id="applyParentBtn" class="geist-button-outline text-xs px-3" type="button">使用ID</button>
|
||||
</div>
|
||||
<div id="referencePreview" class="ref-preview hidden">
|
||||
<img id="referencePreviewImg" class="ref-preview-image" alt="参考图预览">
|
||||
<div id="referencePreviewMeta" class="ref-preview-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ref-parent-row">
|
||||
<input id="parentPostInput" class="geist-input" placeholder="粘贴 parentPostId(全局记忆)">
|
||||
<button id="applyParentBtn" class="geist-button-outline text-xs px-3" type="button">使用ID</button>
|
||||
<div class="settings-block ratio-block">
|
||||
<label class="field-label" for="ratioSelect">画面比例</label>
|
||||
<select id="ratioSelect" class="geist-input">
|
||||
<option value="3:2" selected>3:2 横构图</option>
|
||||
<option value="2:3">2:3 竖构图</option>
|
||||
<option value="16:9">16:9 宽屏</option>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
<option value="1:1">1:1 方形</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="referencePreview" class="ref-preview hidden">
|
||||
<img id="referencePreviewImg" class="ref-preview-image" alt="参考图预览">
|
||||
<div id="referencePreviewMeta" class="ref-preview-meta"></div>
|
||||
<div class="settings-block length-block">
|
||||
<label class="field-label" for="lengthSelect">视频时长</label>
|
||||
<select id="lengthSelect" class="geist-input">
|
||||
<option value="6" selected>6 秒</option>
|
||||
<option value="10">10 秒</option>
|
||||
<option value="15">15 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block resolution-block">
|
||||
<label class="field-label" for="resolutionSelect">分辨率</label>
|
||||
<select id="resolutionSelect" class="geist-input">
|
||||
<option value="480p" selected>480p</option>
|
||||
<option value="720p">720p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block preset-block">
|
||||
<label class="field-label" for="presetSelect">风格预设</label>
|
||||
<select id="presetSelect" class="geist-input">
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="fun">Fun</option>
|
||||
<option value="spicy">Spicy</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block concurrent-block">
|
||||
<label class="field-label" for="concurrentSelect">并发视频数</label>
|
||||
<select id="concurrentSelect" class="geist-input">
|
||||
<option value="1" selected>1 路</option>
|
||||
<option value="2">2 路</option>
|
||||
<option value="3">3 路</option>
|
||||
<option value="4">4 路</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block upload-block">
|
||||
<label class="field-label"> </label>
|
||||
<button id="selectImageFileBtn" class="geist-button-outline text-xs px-3" type="button">上传</button>
|
||||
<input id="imageFileInput" class="ref-file-input" type="file" accept="image/*">
|
||||
</div>
|
||||
<div class="settings-block clear-block">
|
||||
<label class="field-label"> </label>
|
||||
<button id="clearImageFileBtn" class="geist-button-outline text-xs px-3" type="button">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block ratio-block">
|
||||
<label class="field-label" for="ratioSelect">画面比例</label>
|
||||
<select id="ratioSelect" class="geist-input">
|
||||
<option value="3:2" selected>3:2 横构图</option>
|
||||
<option value="2:3">2:3 竖构图</option>
|
||||
<option value="16:9">16:9 宽屏</option>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
<option value="1:1">1:1 方形</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block length-block">
|
||||
<label class="field-label" for="lengthSelect">视频时长</label>
|
||||
<select id="lengthSelect" class="geist-input">
|
||||
<option value="6" selected>6 秒</option>
|
||||
<option value="10">10 秒</option>
|
||||
<option value="15">15 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block resolution-block">
|
||||
<label class="field-label" for="resolutionSelect">分辨率</label>
|
||||
<select id="resolutionSelect" class="geist-input">
|
||||
<option value="480p" selected>480p</option>
|
||||
<option value="720p">720p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block preset-block">
|
||||
<label class="field-label" for="presetSelect">风格预设</label>
|
||||
<select id="presetSelect" class="geist-input">
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="fun">Fun</option>
|
||||
<option value="spicy">Spicy</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block concurrent-block">
|
||||
<label class="field-label" for="concurrentSelect">并发视频数</label>
|
||||
<select id="concurrentSelect" class="geist-input">
|
||||
<option value="1" selected>1 路</option>
|
||||
<option value="2">2 路</option>
|
||||
<option value="3">3 路</option>
|
||||
<option value="4">4 路</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block upload-block">
|
||||
<label class="field-label"> </label>
|
||||
<button id="selectImageFileBtn" class="geist-button-outline text-xs px-3" type="button">上传</button>
|
||||
<input id="imageFileInput" class="ref-file-input" type="file" accept="image/*">
|
||||
</div>
|
||||
<div class="settings-block clear-block">
|
||||
<label class="field-label"> </label>
|
||||
<button id="clearImageFileBtn" class="geist-button-outline text-xs px-3" type="button">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button id="startBtn" class="geist-button gap-2">
|
||||
@@ -136,7 +138,7 @@
|
||||
</div>
|
||||
<div id="editPanel" class="video-card edit-panel">
|
||||
<div class="video-preview-header">
|
||||
<div class="card-title">视频工作区</div>
|
||||
<div class="card-title">视频延长工作区</div>
|
||||
<div class="preview-actions">
|
||||
<button id="pickCachedVideoBtn" class="geist-button-outline text-xs px-3">选择视频</button>
|
||||
<button id="uploadWorkVideoBtn" class="geist-button-outline text-xs px-3" type="button">上传视频</button>
|
||||
@@ -147,78 +149,55 @@
|
||||
<div id="editHint" class="video-empty">请先选择或生成一个视频,工作区会始终展示当前视频。</div>
|
||||
<div id="editBody">
|
||||
<div class="meta-grid edit-meta-grid">
|
||||
<div class="meta-item"><div class="meta-label">当前工作视频</div><div id="mergeVideoA" class="meta-value">-</div></div>
|
||||
<div class="meta-item"><div class="meta-label">历史数量</div><div id="historyCount" class="meta-value">0</div></div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">当前工作视频</div>
|
||||
<div id="mergeVideoA" class="meta-value">-</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">历史数量</div>
|
||||
<div id="historyCount" class="meta-value">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-preview-wrap">
|
||||
<video id="editVideo" controls preload="metadata" playsinline webkit-playsinline></video>
|
||||
</div>
|
||||
<div class="edit-slider-wrap">
|
||||
<input id="editTimeline" class="timeline-delete-zone" data-delete-side="right" type="range" min="0" max="100000" step="1" value="0">
|
||||
<input id="editTimeline" class="timeline-delete-zone" data-delete-side="right" type="range" min="0"
|
||||
max="100000" step="1" value="0">
|
||||
<div class="progress-meta">
|
||||
<span id="editTimeText">00:00.000</span>
|
||||
<span id="editDurationText">总时长 -</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-grid edit-meta-grid">
|
||||
<div class="meta-item"><div class="meta-label">frame_index</div><div id="editFrameIndex" class="meta-value">-</div></div>
|
||||
<div class="meta-item"><div class="meta-label">timestamp_ms</div><div id="editTimestampMs" class="meta-value">-</div></div>
|
||||
<div class="meta-item"><div class="meta-label">frame_hash</div><div id="editFrameHash" class="meta-value">-</div></div>
|
||||
<div class="meta-item"><div class="meta-label">视频2(B)</div><div id="mergeVideoB" class="meta-value">-</div></div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">frame_index</div>
|
||||
<div id="editFrameIndex" class="meta-value">-</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">timestamp_ms</div>
|
||||
<div id="editTimestampMs" class="meta-value">-</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">extend_post_id</div>
|
||||
<div id="editExtendPostId" class="meta-value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block prompt-block edit-prompt-block">
|
||||
<label class="field-label" for="editPromptInput">拼接提示词</label>
|
||||
<textarea id="editPromptInput" class="geist-input video-textarea" placeholder="描述后续要拼接的画面内容"></textarea>
|
||||
<label class="field-label" for="editPromptInput">延长提示词</label>
|
||||
<textarea id="editPromptInput" class="geist-input video-textarea"
|
||||
placeholder="描述后续要延长的画面内容(留空则自然延续)"></textarea>
|
||||
</div>
|
||||
<div class="preview-actions splice-action-row">
|
||||
<button id="spliceBtn" class="geist-button gap-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H6a2 2 0 0 0-2 2v5" />
|
||||
<path d="M13 20h5a2 2 0 0 0 2-2v-5" />
|
||||
<path d="M20 6l-8 8" />
|
||||
<path d="M4 18l8-8" />
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
<span>拼接视频</span>
|
||||
<span>延长视频</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="video-card manual-merge-card">
|
||||
<div class="video-preview-header">
|
||||
<div class="card-title">手动两段拼接</div>
|
||||
<div class="preview-actions">
|
||||
<button id="pickMergeVideoBtn" class="geist-button-outline text-xs px-3" type="button">选择视频2</button>
|
||||
<button id="directMergeBtn" class="geist-button-outline text-xs px-3" type="button">手动拼接</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="manual-merge-grid">
|
||||
<div class="manual-merge-col">
|
||||
<div class="meta-label">视频1拼接帧(向左保留)</div>
|
||||
<div class="edit-preview-wrap">
|
||||
<video id="mergeVideoPreviewA" controls preload="metadata" playsinline webkit-playsinline></video>
|
||||
</div>
|
||||
<div class="edit-slider-wrap">
|
||||
<input id="mergeTimelineA" class="timeline-delete-zone" data-delete-side="right" type="range" min="0" max="100000" step="1" value="0">
|
||||
<div class="progress-meta">
|
||||
<span id="mergeTimeTextA">00:00.000</span>
|
||||
<span id="mergeDurationA">总时长 -</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="manual-merge-col">
|
||||
<div class="meta-label">视频2拼接帧(向右保留)</div>
|
||||
<div class="edit-preview-wrap">
|
||||
<video id="mergeVideoPreviewB" controls preload="metadata" playsinline webkit-playsinline></video>
|
||||
</div>
|
||||
<div class="edit-slider-wrap">
|
||||
<input id="mergeTimelineB" class="timeline-delete-zone" data-delete-side="left" type="range" min="0" max="100000" step="1" value="0">
|
||||
<div class="progress-meta">
|
||||
<span id="mergeTimeTextB">00:00.000</span>
|
||||
<span id="mergeDurationB">总时长 -</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,7 +245,7 @@
|
||||
<div class="panel-head">
|
||||
<div class="card-title">历史视频</div>
|
||||
</div>
|
||||
<div id="videoEmpty" class="video-empty">生成视频与拼接视频都会在这里保存,点击可替换到工作区。</div>
|
||||
<div id="videoEmpty" class="video-empty">生成视频与延长视频都会在这里保存,点击可替换到工作区。</div>
|
||||
<div id="videoStage" class="video-stage hidden"></div>
|
||||
</aside>
|
||||
</aside>
|
||||
@@ -298,9 +277,7 @@
|
||||
<script src="/static/common/js/toast.js"></script>
|
||||
<script src="/static/common/js/prompt-enhancer.js?v=0.1.15"></script>
|
||||
<script src="/static/common/js/pwa.js?v=0.1.1"></script>
|
||||
<script src="/static/vendor/ffmpeg/ffmpeg.js?v=0.1.0"></script>
|
||||
<script src="/static/vendor/ffmpeg/ffmpeg-util.js?v=0.1.0"></script>
|
||||
<script src="/static/public/js/video.js?v=0.5.42"></script>
|
||||
<script src="/static/public/js/video.js?v=0.6.0"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
Submodule
+1
Submodule ignore_delete/grok2api added at 755fa30afa.
Reference in new issue
Block a user