From 2ae236ace38b6d9294f7ed4b03583a73f343bfe8 Mon Sep 17 00:00:00 2001 From: XianYuDaXian Date: Tue, 3 Mar 2026 22:59:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E7=94=A8=E5=AE=98=E7=BD=91?= =?UTF-8?q?=E6=8B=BC=E6=8E=A5=E5=BB=B6=E9=95=BF=E6=95=88=E6=9E=9C=E6=9B=B4?= =?UTF-8?q?=E4=BD=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/public_api/video.py | 93 +- app/services/grok/services/image.py | 21 +- app/services/grok/services/video.py | 250 +++- app/services/grok/utils/asset_token_map.py | 79 ++ app/services/reverse/video_upscale.py | 2 +- app/static/public/js/imagine.js | 11 +- app/static/public/js/video.js | 1417 +++++--------------- app/static/public/pages/video.html | 233 ++-- ignore_delete/grok2api | 1 + 9 files changed, 890 insertions(+), 1217 deletions(-) create mode 100644 app/services/grok/utils/asset_token_map.py create mode 160000 ignore_delete/grok2api diff --git a/app/api/v1/public_api/video.py b/app/api/v1/public_api/video.py index c409c7c..a1f81b0 100644 --- a/app/api/v1/public_api/video.py +++ b/app/api/v1/public_api/video.py @@ -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: diff --git a/app/services/grok/services/image.py b/app/services/grok/services/image.py index 2742231..f7bd892 100644 --- a/app/services/grok/services/image.py +++ b/app/services/grok/services/image.py @@ -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 "" diff --git a/app/services/grok/services/video.py b/app/services/grok/services/video.py index 0b8f719..c8476a8 100644 --- a/app/services/grok/services/video.py +++ b/app/services/grok/services/video.py @@ -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\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: diff --git a/app/services/grok/utils/asset_token_map.py b/app/services/grok/utils/asset_token_map.py new file mode 100644 index 0000000..fb7e0f2 --- /dev/null +++ b/app/services/grok/utils/asset_token_map.py @@ -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 diff --git a/app/services/reverse/video_upscale.py b/app/services/reverse/video_upscale.py index 0ef606d..a90165f 100644 --- a/app/services/reverse/video_upscale.py +++ b/app/services/reverse/video_upscale.py @@ -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 diff --git a/app/static/public/js/imagine.js b/app/static/public/js/imagine.js index b8eb65e..2f4c400 100644 --- a/app/static/public/js/imagine.js +++ b/app/static/public/js/imagine.js @@ -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++; diff --git a/app/static/public/js/video.js b/app/static/public/js/video.js index 21a92dc..afebfbe 100644 --- a/app/static/public/js/video.js +++ b/app/static/public/js/video.js @@ -19,21 +19,10 @@ const editDurationText = document.getElementById('editDurationText'); const editFrameIndex = document.getElementById('editFrameIndex'); const editTimestampMs = document.getElementById('editTimestampMs'); - const editFrameHash = document.getElementById('editFrameHash'); + const editExtendPostId = document.getElementById('editExtendPostId'); const editPromptInput = document.getElementById('editPromptInput'); const spliceBtn = document.getElementById('spliceBtn'); - const pickMergeVideoBtn = document.getElementById('pickMergeVideoBtn'); - const directMergeBtn = document.getElementById('directMergeBtn'); - const mergeVideoA = document.getElementById('mergeVideoA'); - const mergeVideoB = document.getElementById('mergeVideoB'); - const mergeVideoPreviewA = document.getElementById('mergeVideoPreviewA'); - const mergeVideoPreviewB = document.getElementById('mergeVideoPreviewB'); - const mergeTimelineA = document.getElementById('mergeTimelineA'); - const mergeTimelineB = document.getElementById('mergeTimelineB'); - const mergeTimeTextA = document.getElementById('mergeTimeTextA'); - const mergeTimeTextB = document.getElementById('mergeTimeTextB'); - const mergeDurationA = document.getElementById('mergeDurationA'); - const mergeDurationB = document.getElementById('mergeDurationB'); + const promptInput = document.getElementById('promptInput'); const imageUrlInput = document.getElementById('imageUrlInput'); const parentPostInput = document.getElementById('parentPostInput'); @@ -83,18 +72,12 @@ let activeSpliceRun = null; let lockedFrameIndex = -1; let lockedTimestampMs = 0; - let lastFrameHash = ''; - let ffmpegInstance = null; - let ffmpegLoaded = false; - let ffmpegLoading = false; - const ffmpegLogBuffer = []; + let currentExtendPostId = ''; // 当前视频 postId(随链式延长更新) + let currentFileAttachmentId = ''; // 当前视频 postId(当前选中视频的 postId) + let originalFileAttachmentId = ''; // 原始图片 postId(首次设置后不随延长更新) const DEFAULT_REASONING_EFFORT = 'low'; const EDIT_TIMELINE_MAX = 100000; const TAIL_FRAME_GUARD_MS = 80; - let mergeTargetVideoUrl = ''; - let mergeTargetVideoName = ''; - let mergeCutMsA = 0; - let mergeCutMsB = 0; let workVideoObjectUrl = ''; let editTimelineTaskLocked = false; let workspacePreviewSizeLocked = false; @@ -104,7 +87,7 @@ function buildHistoryTitle(type, serial) { const n = Math.max(1, parseInt(String(serial || '1'), 10) || 1); if (type === 'splice') { - return `拼接视频${n}`; + return `延长视频${n}`; } return `生成视频${n}`; } @@ -179,53 +162,24 @@ function setEditMeta() { if (editFrameIndex) editFrameIndex.textContent = lockedFrameIndex >= 0 ? String(lockedFrameIndex) : '-'; if (editTimestampMs) editTimestampMs.textContent = String(Math.max(0, Math.round(lockedTimestampMs))); - if (editFrameHash) editFrameHash.textContent = shortHash(lastFrameHash); + if (editExtendPostId) editExtendPostId.textContent = shortHash(currentExtendPostId); } - function pushFfmpegLog(line) { - const text = String(line || '').trim(); - if (!text) return; - ffmpegLogBuffer.push(`[${new Date().toISOString()}] ${text}`); - if (ffmpegLogBuffer.length > 400) { - ffmpegLogBuffer.splice(0, ffmpegLogBuffer.length - 400); - } + // 从缓存视频文件名中提取 parentPostId + // 文件名格式示例: users-xxx-generated-{postId}-generated_video_hd.mp4 + function extractPostIdFromFileName(name) { + const s = String(name || '').trim(); + if (!s) return ''; + // 尝试 generated-{uuid}- 模式 + const m = s.match(/generated-([0-9a-fA-F-]{32,36})-/); + if (m) return m[1]; + // 回退:匹配最后一个 UUID 格式 + const allUuids = s.match(/[0-9a-fA-F-]{32,36}/g); + return allUuids && allUuids.length ? allUuids[allUuids.length - 1] : ''; } function debugLog(...args) { - console.log('[video-splice-debug]', ...args); - } - - function dumpFfmpegLogs(context, err, extra) { - const msg = String(err && err.message ? err.message : err); - console.groupCollapsed(`[ffmpeg-debug] ${context}: ${msg}`); - if (extra) { - console.log('extra:', extra); - } - const tail = ffmpegLogBuffer.slice(-120); - if (tail.length) { - console.log('recent logs:\n' + tail.join('\n')); - } else { - console.log('recent logs: '); - } - console.groupEnd(); - } - - function attachFfmpegLogger(ff) { - if (!ff || ff.__debugLoggerAttached) return; - if (typeof ff.on === 'function') { - try { - ff.on('log', (event) => { - const type = event && event.type ? String(event.type) : 'log'; - const message = event && event.message ? String(event.message) : ''; - const line = `[${type}] ${message}`; - pushFfmpegLog(line); - console.log('[ffmpeg]', line); - }); - ff.__debugLoggerAttached = true; - } catch (e) { - // ignore - } - } + // console.log('[video-extend-debug]', ...args); } function getSafeEditMaxTimestampMs() { @@ -254,17 +208,15 @@ function refreshAllDeleteZoneTracks() { updateDeleteZoneTrack(editTimeline); - updateDeleteZoneTrack(mergeTimelineA); - updateDeleteZoneTrack(mergeTimelineB); } function setSpliceButtonState(state) { if (!spliceBtn) return; - const iconSplice = ''; + const iconExtend = ''; const iconStop = ''; if (state === 'running') { spliceBtn.disabled = false; - spliceBtn.innerHTML = `${iconStop}中止拼接`; + spliceBtn.innerHTML = `${iconStop}中止延长`; return; } if (state === 'stopping') { @@ -273,7 +225,7 @@ return; } spliceBtn.disabled = false; - spliceBtn.innerHTML = `${iconSplice}拼接视频`; + spliceBtn.innerHTML = `${iconExtend}延长视频`; } function syncTimelineAvailability() { @@ -282,15 +234,6 @@ editTimeline.disabled = editTimelineTaskLocked || !hasWorkspaceVideo; editTimeline.classList.toggle('is-disabled', editTimeline.disabled); } - if (mergeTimelineA) { - mergeTimelineA.disabled = !hasWorkspaceVideo; - mergeTimelineA.classList.toggle('is-disabled', mergeTimelineA.disabled); - } - const hasVideoB = Boolean(String(mergeTargetVideoUrl || '').trim()); - if (mergeTimelineB) { - mergeTimelineB.disabled = !hasVideoB; - mergeTimelineB.classList.toggle('is-disabled', mergeTimelineB.disabled); - } } function setEditTimelineLock(locked) { @@ -314,10 +257,7 @@ if (enterEditBtn) enterEditBtn.disabled = true; closeEditPanel(); } - if (mergeTargetVideoUrl && url && mergeTargetVideoUrl === url) { - mergeTargetVideoUrl = ''; - mergeTargetVideoName = ''; - } + item.remove(); const hasAny = videoStage.querySelector('.video-item'); if (!hasAny) { @@ -326,31 +266,9 @@ } updateHistoryCount(); refreshVideoSelectionUi(); - updateMergeLabels(); - updateManualActionsVisibility(); syncTimelineAvailability(); } - function updateMergeLabels() { - if (mergeVideoA) { - mergeVideoA.textContent = selectedVideoUrl ? shortHash(selectedVideoUrl) : '-'; - } - if (mergeVideoB) { - mergeVideoB.textContent = mergeTargetVideoName || (mergeTargetVideoUrl ? shortHash(mergeTargetVideoUrl) : '-'); - } - } - - function updateManualActionsVisibility() { - const hasWorkspaceVideo = Boolean(String(selectedVideoUrl || '').trim()); - if (pickMergeVideoBtn) { - pickMergeVideoBtn.style.display = hasWorkspaceVideo ? '' : 'none'; - } - if (directMergeBtn) { - directMergeBtn.style.display = hasWorkspaceVideo ? '' : 'none'; - directMergeBtn.disabled = !hasWorkspaceVideo; - } - } - function getParentMemoryApi() { return window.ParentPostMemory || null; } @@ -608,10 +526,8 @@ editVideo.removeAttribute('src'); editVideo.load(); } - mergeTargetVideoUrl = ''; - mergeTargetVideoName = ''; - mergeCutMsA = 0; - mergeCutMsB = 0; + currentExtendPostId = ''; + currentFileAttachmentId = ''; if (workVideoObjectUrl) { try { URL.revokeObjectURL(workVideoObjectUrl); } catch (e) { /* ignore */ } workVideoObjectUrl = ''; @@ -621,9 +537,7 @@ } if (enterEditBtn) enterEditBtn.disabled = true; closeEditPanel(); - updateMergeLabels(); updateHistoryCount(); - updateManualActionsVisibility(); } if (durationValue) { durationValue.textContent = '耗时 -'; @@ -667,16 +581,9 @@ editBtn.textContent = '编辑'; editBtn.disabled = true; - const setBBtn = document.createElement('button'); - setBBtn.className = 'geist-button-outline text-xs px-3 video-set-b'; - setBBtn.type = 'button'; - setBBtn.textContent = '设为视频2'; - setBBtn.disabled = true; - actions.appendChild(openBtn); actions.appendChild(downloadBtn); actions.appendChild(editBtn); - actions.appendChild(setBBtn); header.appendChild(title); const body = document.createElement('div'); @@ -704,7 +611,6 @@ const openBtn = item.querySelector('.video-open'); const downloadBtn = item.querySelector('.video-download'); const editBtn = item.querySelector('.video-edit'); - const setBBtn = item.querySelector('.video-set-b'); const link = item.querySelector('.video-item-link'); const safeUrl = url || ''; item.dataset.url = safeUrl; @@ -729,9 +635,6 @@ if (editBtn) { editBtn.disabled = !safeUrl; } - if (setBBtn) { - setBBtn.disabled = !safeUrl; - } if (safeUrl) { item.classList.remove('is-pending'); } @@ -771,13 +674,13 @@ if (imageFileName) { imageFileName.textContent = '未选择文件'; } - const rawUrl = imageUrlInput ? imageUrlInput.value.trim() : ''; - if (rawUrl) { - const resolved = resolveReferenceByText(rawUrl); - setReferencePreview(resolved.url || resolved.sourceUrl || rawUrl, resolved.parentPostId || ''); - } else { - clearReferencePreview(); + if (imageUrlInput) { + imageUrlInput.value = ''; } + if (parentPostInput) { + parentPostInput.value = ''; + } + clearReferencePreview(); } async function readFileAsDataUrl(file) { @@ -863,224 +766,6 @@ return authHeader; } - function getFfmpegApis() { - const ffmpegApi = window.FFmpegWASM || {}; - const utilApi = window.FFmpegUtil || {}; - const FFmpegCtor = ffmpegApi.FFmpeg || ffmpegApi.createFFmpeg || null; - const fetchFile = utilApi.fetchFile || null; - return { FFmpegCtor, fetchFile }; - } - - async function fetchWithTimeout(url, timeoutMs) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const resp = await fetch(url, { signal: controller.signal, cache: 'force-cache' }); - if (!resp.ok) { - throw new Error(`fetch_${resp.status}`); - } - return await resp.blob(); - } finally { - clearTimeout(timer); - } - } - - async function toBlobUrlFromCandidates(urls, mime, timeoutMs = 12000) { - let lastErr = null; - for (const url of urls) { - try { - const blob = await fetchWithTimeout(url, timeoutMs); - return URL.createObjectURL(new Blob([blob], { type: mime })); - } catch (e) { - lastErr = e; - } - } - throw lastErr || new Error('blob_url_candidates_failed'); - } - - async function toOptionalBlobUrl(urls, mime, timeoutMs = 12000) { - try { - return await toBlobUrlFromCandidates(urls, mime, timeoutMs); - } catch (e) { - return ''; - } - } - - async function ensureFfmpeg() { - if (ffmpegLoaded && ffmpegInstance) return ffmpegInstance; - if (ffmpegLoading) { - for (let i = 0; i < 80; i++) { - await new Promise((r) => setTimeout(r, 100)); - if (ffmpegLoaded && ffmpegInstance) return ffmpegInstance; - } - throw new Error('ffmpeg_load_timeout'); - } - const { FFmpegCtor } = getFfmpegApis(); - if (!FFmpegCtor) { - throw new Error('ffmpeg_runtime_missing'); - } - ffmpegLoading = true; - debugLog('ensureFfmpeg:start'); - try { - const candidates = { - coreURL: [ - '/v1/public/video/vendor/ffmpeg-core.js', - ], - wasmURL: [ - '/v1/public/video/vendor/ffmpeg-core.wasm', - ], - workerURL: [ - // worker 文件在部分 ffmpeg core 版本中不存在,默认不请求以避免无效报错 - ], - }; - // 统一转为同源 blob URL,避免跨域 Worker 限制,同时实现多源超时切换。 - const coreURL = await toBlobUrlFromCandidates(candidates.coreURL, 'text/javascript'); - const wasmURL = await toBlobUrlFromCandidates(candidates.wasmURL, 'application/wasm'); - const workerURL = await toOptionalBlobUrl(candidates.workerURL, 'text/javascript'); - if (typeof FFmpegCtor === 'function') { - try { - // 兼容 @ffmpeg/ffmpeg 0.12+ 的 class FFmpeg - ffmpegInstance = new FFmpegCtor(); - } catch (e) { - // 回退兼容 createFFmpeg 工厂模式 - ffmpegInstance = FFmpegCtor({ log: false }); - } - if (ffmpegInstance && typeof ffmpegInstance.load === 'function') { - const loadConfig = { coreURL, wasmURL }; - if (workerURL) { - loadConfig.workerURL = workerURL; - } - await ffmpegInstance.load(loadConfig); - debugLog('ensureFfmpeg:loaded'); - } - } - if (!ffmpegInstance) { - throw new Error('ffmpeg_instance_init_failed'); - } - attachFfmpegLogger(ffmpegInstance); - ffmpegLoaded = true; - debugLog('ensureFfmpeg:ready'); - return ffmpegInstance; - } finally { - ffmpegLoading = false; - } - } - - async function resetFfmpegInstance() { - if (!ffmpegInstance) return; - debugLog('resetFfmpegInstance:start'); - try { - if (typeof ffmpegInstance.terminate === 'function') { - await ffmpegInstance.terminate(); - } - } catch (e) { - // ignore - } - ffmpegInstance = null; - ffmpegLoaded = false; - ffmpegLoading = false; - debugLog('resetFfmpegInstance:done'); - } - - function ffTaskPrefix(tag) { - return `${tag}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - } - - function isFsError(e) { - const msg = String(e && e.message ? e.message : e); - return msg.includes('FS error') || msg.includes('ErrnoError'); - } - - function toStableUint8(input) { - if (input instanceof Uint8Array) { - return new Uint8Array(input); - } - if (input instanceof ArrayBuffer) { - return new Uint8Array(input.slice(0)); - } - if (ArrayBuffer.isView(input)) { - const view = input; - const start = view.byteOffset || 0; - const end = start + (view.byteLength || 0); - return new Uint8Array(view.buffer.slice(start, end)); - } - throw new Error('binary_input_invalid'); - } - - function toStableArrayBuffer(input) { - return toStableUint8(input).buffer; - } - - async function sha256Hex(bytes) { - const stableBytes = toStableUint8(bytes); - const hashBuffer = await crypto.subtle.digest('SHA-256', stableBytes); - return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, '0')).join(''); - } - - async function fetchArrayBuffer(url) { - const resp = await fetch(url, { mode: 'cors' }); - if (!resp.ok) { - throw new Error(`fetch_failed_${resp.status}`); - } - const raw = await resp.arrayBuffer(); - return raw.slice(0); - } - - async function ffmpegWriteFile(ff, name, data) { - const stable = toStableUint8(data); - if (typeof ff.writeFile === 'function') { - return await ff.writeFile(name, stable); - } - if (ff.FS) { - ff.FS('writeFile', name, stable); - return; - } - throw new Error('ffmpeg_writefile_unsupported'); - } - - async function ffmpegReadFile(ff, name) { - if (typeof ff.readFile === 'function') { - const out = await ff.readFile(name); - return toStableUint8(out); - } - if (ff.FS) { - const out = ff.FS('readFile', name); - return toStableUint8(out); - } - throw new Error('ffmpeg_readfile_unsupported'); - } - - async function ffmpegDeleteFileSafe(ff, name) { - try { - if (typeof ff.deleteFile === 'function') { - await ff.deleteFile(name); - return; - } - if (ff.FS) { - ff.FS('unlink', name); - } - } catch (e) { - // ignore - } - } - - async function ffmpegExec(ff, args) { - debugLog('ffmpegExec:run', args.join(' ')); - try { - if (typeof ff.exec === 'function') { - return await ff.exec(args); - } - if (typeof ff.run === 'function') { - return await ff.run(...args); - } - throw new Error('ffmpeg_exec_unsupported'); - } catch (e) { - dumpFfmpegLogs('ffmpegExec_failed', e, { args }); - console.error('[video-splice-debug] ffmpegExec:error', args, e); - throw e; - } - } - function buildSseUrl(taskId, rawPublicKey) { const httpProtocol = window.location.protocol === 'https:' ? 'https' : 'http'; const base = `${httpProtocol}://${window.location.host}/v1/public/video/sse`; @@ -1099,6 +784,7 @@ return Math.max(1, Math.min(4, raw)); } + async function createVideoTasks(authHeader) { const prompt = promptInput ? promptInput.value.trim() : ''; const rawUrl = imageUrlInput ? imageUrlInput.value.trim() : ''; @@ -1111,16 +797,17 @@ if (!fileDataUrl) { resolvedRef = resolveReferenceByText(rawParent || rawUrl); } - const parentPostId = fileDataUrl ? '' : String(resolvedRef.parentPostId || '').trim(); - const imageUrl = fileDataUrl ? fileDataUrl : (parentPostId ? '' : resolvedRef.url); - if (!fileDataUrl && resolvedRef.parentPostId) { - if (imageUrlInput) { + const parentPostId = fileDataUrl ? '' : (rawParent || resolvedRef.parentPostId || '').trim(); + const imageUrl = fileDataUrl ? fileDataUrl : (parentPostId ? (rawUrl || resolvedRef.sourceUrl || '') : (rawUrl || resolvedRef.url || '')); + + if (!fileDataUrl && (resolvedRef.parentPostId || parentPostId)) { + if (imageUrlInput && !imageUrlInput.value.trim() && (resolvedRef.sourceUrl || resolvedRef.url)) { imageUrlInput.value = resolvedRef.sourceUrl || resolvedRef.url; } - if (parentPostInput) { - parentPostInput.value = resolvedRef.parentPostId; + if (parentPostInput && !parentPostInput.value.trim() && (resolvedRef.parentPostId || parentPostId)) { + parentPostInput.value = resolvedRef.parentPostId || parentPostId; } - setReferencePreview(resolvedRef.url || resolvedRef.sourceUrl, resolvedRef.parentPostId); + setReferencePreview(resolvedRef.url || resolvedRef.sourceUrl || imageUrl, resolvedRef.parentPostId || parentPostId); } const res = await fetch('/v1/public/video/start', { method: 'POST', @@ -1132,7 +819,7 @@ prompt, image_url: imageUrl || null, parent_post_id: parentPostId || null, - source_image_url: parentPostId ? (resolvedRef.sourceUrl || null) : null, + source_image_url: parentPostId ? (resolvedRef.sourceUrl || imageUrl || null) : null, reasoning_effort: DEFAULT_REASONING_EFFORT, aspect_ratio: ratioSelect ? ratioSelect.value : '3:2', video_length: lengthSelect ? parseInt(lengthSelect.value, 10) : 6, @@ -1282,6 +969,18 @@ if (editHint) { editHint.classList.toggle('hidden', Boolean(safeUrl)); } + // 从 URL 中提取 postId (支持历史面板点击编辑) + const postId = extractPostIdFromFileName(safeUrl); + if (postId) { + currentExtendPostId = postId; + currentFileAttachmentId = postId; + // 首次设置 originalFileAttachmentId(后续延长不覆盖) + if (!originalFileAttachmentId) { + originalFileAttachmentId = postId; + debugLog('bindEditVideoSource: set originalFileAttachmentId =', postId); + } + debugLog('bindEditVideoSource: extracted postId =', postId); + } if (!editVideo) return; enforceInlinePlayback(editVideo); editVideo.src = safeUrl; @@ -1289,12 +988,8 @@ lockWorkspacePreviewSize(); lockedFrameIndex = -1; lockedTimestampMs = 0; - lastFrameHash = ''; setEditMeta(); - updateMergeLabels(); - updateManualActionsVisibility(); syncTimelineAvailability(); - bindMergeVideoA(safeUrl); } function scrollToWorkspaceTop() { @@ -1302,29 +997,6 @@ editPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } - function bindMergeVideoA(url) { - const safeUrl = String(url || '').trim(); - if (!mergeVideoPreviewA) return; - enforceInlinePlayback(mergeVideoPreviewA); - mergeVideoPreviewA.src = safeUrl; - mergeVideoPreviewA.load(); - mergeCutMsA = 0; - if (mergeTimeTextA) mergeTimeTextA.textContent = formatMs(0); - if (mergeTimelineA) mergeTimelineA.value = '0'; - syncTimelineAvailability(); - } - - function bindMergeVideoB(url) { - const safeUrl = String(url || '').trim(); - if (!mergeVideoPreviewB) return; - enforceInlinePlayback(mergeVideoPreviewB); - mergeVideoPreviewB.src = safeUrl; - mergeVideoPreviewB.load(); - mergeCutMsB = 0; - if (mergeTimeTextB) mergeTimeTextB.textContent = formatMs(0); - if (mergeTimelineB) mergeTimelineB.value = '0'; - syncTimelineAvailability(); - } function openEditPanel() { const item = getSelectedVideoItem(); @@ -1493,9 +1165,7 @@ } }, { once: true }); }); - const activeUrlRaw = cacheModalPickMode === 'merge_target' - ? mergeTargetVideoUrl - : selectedVideoUrl; + const activeUrlRaw = selectedVideoUrl; const activeUrl = normalizeVideoUrlForCompare(activeUrlRaw); if (!activeUrl) return; const rows = cacheVideoList.querySelectorAll('.cache-video-item'); @@ -1514,22 +1184,23 @@ function useCachedVideo(url, name) { const safeUrl = String(url || '').trim(); if (!safeUrl) return; - if (cacheModalPickMode === 'merge_target') { - mergeTargetVideoUrl = safeUrl; - mergeTargetVideoName = String(name || '').trim(); - updateMergeLabels(); - bindMergeVideoB(safeUrl); - closeCacheVideoModal(); - toast('已选择视频2', 'success'); - return; - } selectedVideoItemId = `cache-${Date.now()}`; selectedVideoUrl = safeUrl; + // 从文件名中自动提取 parentPostId 用于视频延长 + const extractedPostId = extractPostIdFromFileName(String(name || '')); + if (extractedPostId) { + currentExtendPostId = extractedPostId; + currentFileAttachmentId = extractedPostId; + // 从缓存重新选择视频时重置 originalFileAttachmentId(开启新的延长链) + originalFileAttachmentId = extractedPostId; + debugLog('useCachedVideo: extracted postId =', extractedPostId); + } if (imageUrlInput) imageUrlInput.value = safeUrl; if (imageFileName && name) imageFileName.textContent = name; if (enterEditBtn) enterEditBtn.disabled = false; closeCacheVideoModal(); openEditPanel(); + setEditMeta(); } function updateTimelineByVideoTime() { @@ -1546,7 +1217,15 @@ function lockFrameByCurrentTime() { if (!editVideo) return; - const currentTime = Number(editVideo.currentTime || 0); + let currentTime = Number(editVideo.currentTime || 0); + const duration = Number(editVideo.duration || 0); + // 强制限制提取的秒数上限为 20s + if (currentTime > 20) { + currentTime = 20; + if (editTimeText) { + editTimeText.textContent = formatMs(clampEditTimestampMs(Math.round(currentTime * 1000))) + " (已达官方20s延长上限)"; + } + } lockedTimestampMs = clampEditTimestampMs(Math.round(currentTime * 1000)); const approxFps = 30; lockedFrameIndex = Math.max(0, Math.round(currentTime * approxFps)); @@ -1661,11 +1340,6 @@ } async function startConnection() { - const prompt = promptInput ? promptInput.value.trim() : ''; - if (!prompt) { - toast('请输入提示词', 'error'); - return; - } if (isRunning) { toast('已在生成中', 'warning'); @@ -1934,444 +1608,20 @@ }); } - async function requestCancelSplice() { - const run = activeSpliceRun; - if (!run || run.done) return; - if (run.cancelling) return; - run.cancelled = true; - run.cancelling = true; - setStatus('connecting', '正在中止拼接...'); - setSpliceButtonState('stopping'); - if (run.sources && run.sources.size) { - run.sources.forEach((es) => { - try { es.close(); } catch (e) { /* ignore */ } - }); - run.sources.clear(); - } - if (run.pendingRejects && run.pendingRejects.size) { - run.pendingRejects.forEach((rejectFn) => { - try { rejectFn(new Error('edit_cancelled')); } catch (e) { /* ignore */ } - }); - run.pendingRejects.clear(); - } - if (run.taskIds && run.taskIds.length) { - try { - await stopVideoTask(run.taskIds, run.authHeader); - } catch (e) { - // ignore - } - } - if (run.placeholders) { - run.placeholders.forEach((item) => { - if (!item) return; - const completed = String(item.dataset.completed || '0') === '1'; - if (!completed) { - removePreviewItem(item); - } - }); - } - run.cancelling = false; - toast('已中止拼接任务', 'warning'); - } - async function extractFrameAtCurrentPoint(videoUrl) { - debugLog('extractFrame:start', { videoUrl, lockedTimestampMs, lockedFrameIndex }); - const srcBuffer = await fetchArrayBuffer(videoUrl); - - const runOnce = async () => { - await resetFfmpegInstance(); - const ff = await ensureFfmpeg(); - const prefix = ffTaskPrefix('edit'); - const inputName = `${prefix}_input.mp4`; - const frameName = `${prefix}_frame.png`; - try { - await ffmpegWriteFile(ff, inputName, srcBuffer); - const baseMs = clampEditTimestampMs(lockedTimestampMs); - const candidates = [baseMs, baseMs - 34, baseMs - 68, baseMs - 102] - .map((v) => Math.max(0, Math.round(v))); - let frameBytes = null; - for (const ms of candidates) { - const seconds = (ms / 1000).toFixed(3); - try { - await ffmpegExec(ff, ['-y', '-ss', seconds, '-i', inputName, '-frames:v', '1', '-update', '1', frameName]); - frameBytes = await ffmpegReadFile(ff, frameName); - lockedTimestampMs = ms; - break; - } catch (e) { - await ffmpegDeleteFileSafe(ff, frameName); - } - } - if (!frameBytes) { - debugLog('extractFrame:failedCandidates', { baseMs, candidates }); - throw new Error('extract_frame_failed_near_tail'); - } - const frameHash = await sha256Hex(frameBytes); - const sourceHash = await sha256Hex(srcBuffer); - let binary = ''; - const chunk = 0x8000; - for (let i = 0; i < frameBytes.length; i += chunk) { - binary += String.fromCharCode(...frameBytes.subarray(i, i + chunk)); - } - const dataUrl = `data:image/png;base64,${btoa(binary)}`; - lastFrameHash = frameHash; - setEditMeta(); - return { - dataUrl, - frameHash, - sourceHash, - sourceBuffer: toStableArrayBuffer(srcBuffer), - }; - } finally { - await ffmpegDeleteFileSafe(ff, inputName); - await ffmpegDeleteFileSafe(ff, frameName); - } - }; - - try { - return await runOnce(); - } catch (e) { - if (isFsError(e)) { - return await runOnce(); - } - throw e; - } - } - - async function concatVideosLocal(sourceBuffer, generatedVideoUrl) { - debugLog('concatLocal:start', { generatedVideoUrl, lockedTimestampMs }); - const safeGeneratedUrl = normalizePlayableVideoUrl(generatedVideoUrl); - const generatedBuffer = await fetchArrayBuffer(safeGeneratedUrl); - const sourceStable = toStableArrayBuffer(sourceBuffer); - - const runOnce = async () => { - const ff = await ensureFfmpeg(); - const prefix = ffTaskPrefix('concat'); - const segASource = `${prefix}_a_source.mp4`; - const segBSource = `${prefix}_b_source.mp4`; - const segAVideo = `${prefix}_a_video.mp4`; - const segBVideo = `${prefix}_b_video.mp4`; - const segAAudio = `${prefix}_a_audio.m4a`; - const segBAudio = `${prefix}_b_audio.m4a`; - const listVideo = `${prefix}_video_list.txt`; - const listAudio = `${prefix}_audio_list.txt`; - const mergedVideoFile = `${prefix}_merged_video.mp4`; - const mergedAudioFile = `${prefix}_merged_audio.m4a`; - const mergedFile = `${prefix}_merged.mp4`; - try { - const trimSeconds = (Math.max(0, lockedTimestampMs) / 1000).toFixed(3); - if (Number(trimSeconds) <= 0) { - return new Blob([toStableUint8(generatedBuffer)], { type: 'video/mp4' }); - } - await ffmpegWriteFile(ff, segASource, sourceStable); - await ffmpegWriteFile(ff, segBSource, generatedBuffer); - - try { - await ffmpegExec( - ff, - [ - '-y', - '-i', segASource, - '-t', trimSeconds, - '-map', '0:v:0', - '-c', 'copy', - segAVideo - ] - ); - await ffmpegExec( - ff, - [ - '-y', - '-i', segBSource, - '-map', '0:v:0', - '-c', 'copy', - segBVideo - ] - ); - await ffmpegWriteFile( - ff, - listVideo, - new TextEncoder().encode(`file '${segAVideo}'\nfile '${segBVideo}'\n`) - ); - await ffmpegExec( - ff, - [ - '-y', - '-f', 'concat', - '-safe', '0', - '-i', listVideo, - '-c', 'copy', - mergedVideoFile - ] - ); - } catch (videoCopyErr) { - await ffmpegExec( - ff, - [ - '-y', - '-i', segASource, - '-i', segBSource, - '-filter_complex', - `[0:v]trim=end=${trimSeconds},setpts=PTS-STARTPTS[v0];[1:v]setpts=PTS-STARTPTS[v1];[v0][v1]concat=n=2:v=1:a=0[v]`, - '-map', '[v]', - '-c:v', 'libx264', - '-preset', 'ultrafast', - '-pix_fmt', 'yuv420p', - '-r', '30', - '-an', - mergedVideoFile - ] - ); - } - - let mergedWithAudio = false; - try { - await ffmpegExec( - ff, - [ - '-y', - '-i', segASource, - '-t', trimSeconds, - '-map', '0:a:0', - '-c', 'copy', - segAAudio - ] - ); - await ffmpegExec( - ff, - [ - '-y', - '-i', segBSource, - '-map', '0:a:0', - '-c', 'copy', - segBAudio - ] - ); - await ffmpegWriteFile( - ff, - listAudio, - new TextEncoder().encode(`file '${segAAudio}'\nfile '${segBAudio}'\n`) - ); - await ffmpegExec( - ff, - [ - '-y', - '-f', 'concat', - '-safe', '0', - '-i', listAudio, - '-c', 'copy', - mergedAudioFile - ] - ); - await ffmpegExec( - ff, - [ - '-y', - '-i', mergedVideoFile, - '-i', mergedAudioFile, - '-map', '0:v:0', - '-map', '1:a:0', - '-c', 'copy', - '-shortest', - mergedFile - ] - ); - mergedWithAudio = true; - } catch (audioMuxErr) { - mergedWithAudio = false; - } - if (!mergedWithAudio) { - await ffmpegExec( - ff, - [ - '-y', - '-i', mergedVideoFile, - '-c', 'copy', - mergedFile - ] - ); - } - const merged = await ffmpegReadFile(ff, mergedFile); - return new Blob([toStableUint8(merged)], { type: 'video/mp4' }); - } finally { - await ffmpegDeleteFileSafe(ff, segASource); - await ffmpegDeleteFileSafe(ff, segBSource); - await ffmpegDeleteFileSafe(ff, segAVideo); - await ffmpegDeleteFileSafe(ff, segBVideo); - await ffmpegDeleteFileSafe(ff, segAAudio); - await ffmpegDeleteFileSafe(ff, segBAudio); - await ffmpegDeleteFileSafe(ff, listVideo); - await ffmpegDeleteFileSafe(ff, listAudio); - await ffmpegDeleteFileSafe(ff, mergedVideoFile); - await ffmpegDeleteFileSafe(ff, mergedAudioFile); - await ffmpegDeleteFileSafe(ff, mergedFile); - } - }; - - let lastErr = null; - for (let attempt = 1; attempt <= 3; attempt += 1) { - try { - debugLog('concatLocal:attempt', attempt); - await resetFfmpegInstance(); - return await runOnce(); - } catch (e) { - lastErr = e; - console.error('[video-splice-debug] concatLocal:attemptError', attempt, e); - const msg = String(e && e.message ? e.message : e); - if (msg === 'edit_cancelled') { - throw e; - } - } finally { - await resetFfmpegInstance(); - } - } - throw lastErr || new Error('concat_failed'); - } - - async function concatTwoVideosManual(videoAUrl, videoBUrl, cutAMs, cutBMs) { - const bufferA = await fetchArrayBuffer(videoAUrl); - const bufferB = await fetchArrayBuffer(videoBUrl); - const cutA = (Math.max(0, Number(cutAMs) || 0) / 1000).toFixed(3); - const cutB = (Math.max(0, Number(cutBMs) || 0) / 1000).toFixed(3); - - const runOnce = async () => { - await resetFfmpegInstance(); - const ff = await ensureFfmpeg(); - const prefix = ffTaskPrefix('manual_merge'); - const srcA = `${prefix}_a_source.mp4`; - const srcB = `${prefix}_b_source.mp4`; - const segA = `${prefix}_a.mp4`; - const segB = `${prefix}_b.mp4`; - const segANorm = `${prefix}_a_norm.mp4`; - const segBNorm = `${prefix}_b_norm.mp4`; - const list = `${prefix}_list.txt`; - const out = `${prefix}_out.mp4`; - const files = [srcA, srcB, segA, segB, segANorm, segBNorm, list, out]; - try { - await ffmpegWriteFile(ff, srcA, bufferA); - await ffmpegWriteFile(ff, srcB, bufferB); - - let hasA = false; - let hasB = false; - if (Number(cutA) > 0) { - await ffmpegExec(ff, ['-y', '-i', srcA, '-t', cutA, '-c', 'copy', segA]); - hasA = true; - } - await ffmpegExec(ff, ['-y', '-ss', cutB, '-i', srcB, '-c', 'copy', segB]); - hasB = true; - - const parts = []; - if (hasA) parts.push(segA); - if (hasB) parts.push(segB); - if (!parts.length) { - throw new Error('manual_merge_empty_output'); - } - - await ffmpegWriteFile( - ff, - list, - new TextEncoder().encode(parts.map((p) => `file '${p}'`).join('\n') + '\n') - ); - try { - await ffmpegExec(ff, ['-y', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', out]); - } catch (copyErr) { - if (hasA) { - await ffmpegExec( - ff, - ['-y', '-i', srcA, '-t', cutA, '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-ar', '48000', '-ac', '2', segANorm] - ); - } - await ffmpegExec( - ff, - ['-y', '-ss', cutB, '-i', srcB, '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-ar', '48000', '-ac', '2', segBNorm] - ); - const normParts = []; - if (hasA) normParts.push(segANorm); - normParts.push(segBNorm); - await ffmpegWriteFile( - ff, - list, - new TextEncoder().encode(normParts.map((p) => `file '${p}'`).join('\n') + '\n') - ); - await ffmpegExec( - ff, - ['-y', '-f', 'concat', '-safe', '0', '-i', list, '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-ar', '48000', '-ac', '2', out] - ); - } - const merged = await ffmpegReadFile(ff, out); - return new Blob([toStableUint8(merged)], { type: 'video/mp4' }); - } finally { - for (const f of files) { - await ffmpegDeleteFileSafe(ff, f); - } - } - }; - - try { - return await runOnce(); - } catch (e) { - if (isFsError(e)) { - return await runOnce(); - } - throw e; - } - } - - async function directMergeTwoVideos() { - const sourceA = String(selectedVideoUrl || '').trim(); - const sourceB = String(mergeTargetVideoUrl || '').trim(); - if (!sourceA) { - toast('请先选中视频1', 'warning'); - return; - } - if (!sourceB) { - toast('请先选择视频2', 'warning'); - return; - } - if (sourceA === sourceB) { - toast('视频1和视频2不能相同', 'warning'); - return; - } + // ====== 视频延长(替代旧 runSplice) ====== + async function runExtendVideo() { + debugLog('runExtendVideo:start'); if (editingBusy) { - toast('拼接任务进行中', 'warning'); - return; - } - editingBusy = true; - setEditTimelineLock(true); - if (directMergeBtn) directMergeBtn.disabled = true; - setStatus('connecting', '手动两段拼接处理中'); - try { - const mergedBlob = await concatTwoVideosManual(sourceA, sourceB, mergeCutMsA, mergeCutMsB); - const mergedUrl = URL.createObjectURL(mergedBlob); - const item = initPreviewSlot() || null; - if (item) { - selectedVideoItemId = String(item.dataset.index || ''); - item.dataset.url = mergedUrl; - setPreviewTitle(item, buildHistoryTitle('splice', item.dataset.index || previewCount)); - renderVideoFromUrl({ previewItem: item }, mergedUrl); - refreshVideoSelectionUi(); - } - bindEditVideoSource(mergedUrl); - scrollToWorkspaceTop(); - setStatus('connected', '手动拼接完成'); - toast('手动拼接完成', 'success'); - } catch (e) { - setStatus('error', '手动拼接失败'); - toast(`手动拼接失败: ${String(e && e.message ? e.message : e)}`, 'error'); - } finally { - editingBusy = false; - setEditTimelineLock(false); - if (directMergeBtn) directMergeBtn.disabled = false; - } - } - - async function runSplice() { - debugLog('runSplice:start'); - if (editingBusy) { - toast('拼接任务进行中', 'warning'); + toast('延长任务进行中', 'warning'); return; } if (!selectedVideoUrl) { - toast('请先选中视频并进入编辑模式', 'error'); + toast('请先选中视频并进入工作区', 'error'); + return; + } + if (!currentExtendPostId) { + toast('无法识别当前视频的 postId,请从缓存选择视频', 'error'); return; } const authHeader = await ensurePublicKey(); @@ -2381,14 +1631,7 @@ return; } const prompt = String(editPromptInput ? editPromptInput.value : '').trim(); - if (!prompt) { - toast('请输入拼接提示词', 'warning'); - return; - } - if (!('crypto' in window) || !crypto.subtle) { - toast('当前浏览器不支持拼接所需的加密能力', 'error'); - return; - } + const extensionStartTime = Math.max(0, lockedTimestampMs / 1000); editingBusy = true; setEditTimelineLock(true); const spliceRun = { @@ -2405,129 +1648,263 @@ }; activeSpliceRun = spliceRun; setSpliceButtonState('running'); - setStatus('connecting', '截帧与拼接处理中'); + setStatus('connecting', '视频延长处理中'); + setIndeterminate(true); + updateProgress(0); + startAt = Date.now(); + startElapsedTimer(); try { - const sourceVideoUrl = String(selectedVideoUrl || '').trim(); const nextRound = editingRound + 1; - const frameInfo = await extractFrameAtCurrentPoint(sourceVideoUrl); - const editCtx = { - source_video_url: sourceVideoUrl, - source_video_sha256: frameInfo.sourceHash, - splice_at_ms: Math.round(lockedTimestampMs), - frame_index: Math.max(0, lockedFrameIndex), - frame_hash_sha256: frameInfo.frameHash, - edit_session_id: selectedVideoItemId || 'video-edit', - round: nextRound - }; - const taskIds = await createEditVideoTasks(authHeader, frameInfo.dataUrl, prompt, editCtx); - spliceRun.taskIds = taskIds.slice(); - const rawPublicKey = normalizeAuthHeader(authHeader); - setStatus('connecting', `拼接生成中 (${taskIds.length} 路)`); - for (const taskId of taskIds) { - const item = initPreviewSlot() || null; - if (!item) continue; - spliceRun.placeholders.set(taskId, item); - setPreviewTitle(item, buildHistoryTitle('splice', item.dataset.index || previewCount)); - } + const basePreset = presetSelect ? presetSelect.value : 'normal'; - let successCount = 0; - let lastMergedUrl = ''; - let processChain = Promise.resolve(); - const waitTasks = taskIds.map((taskId) => - waitEditVideoResult(taskId, rawPublicKey, spliceRun) - .then((generatedVideoUrl) => { - processChain = processChain.then(async () => { - if (spliceRun.cancelled) return; - const item = spliceRun.placeholders.get(taskId) || null; - try { - const mergedBlob = await concatVideosLocal(frameInfo.sourceBuffer, generatedVideoUrl); - if (spliceRun.cancelled) { - throw new Error('edit_cancelled'); - } - const mergedUrl = URL.createObjectURL(mergedBlob); - if (item) { - selectedVideoItemId = String(item.dataset.index || ''); - item.dataset.url = mergedUrl; - item.dataset.completed = '1'; - item.dataset.round = String(nextRound); - setPreviewTitle(item, buildHistoryTitle('splice', item.dataset.index || previewCount)); - const state = { previewItem: item }; - renderVideoFromUrl(state, mergedUrl); - refreshVideoSelectionUi(); - } - lastMergedUrl = mergedUrl; - successCount += 1; - } catch (singleErr) { - if (item) { - spliceRun.failedPlaceholders.add(item); - } - spliceRun.failedReasons.push(String(singleErr && singleErr.message ? singleErr.message : singleErr)); - if (String(singleErr && singleErr.message || '') === 'edit_cancelled') { - return; - } + const body = { + prompt: prompt, + aspect_ratio: ratioSelect ? ratioSelect.value : '16:9', + video_length: 10, // 官方延长固定为 10 秒 + resolution_name: resolutionSelect ? resolutionSelect.value : '480p', + preset: (!prompt || prompt.trim() === '') ? 'spicy' : (presetSelect ? presetSelect.value : 'normal'), + reasoning_effort: typeof DEFAULT_REASONING_EFFORT !== 'undefined' ? DEFAULT_REASONING_EFFORT : null, + concurrent: 1, + is_video_extension: true, + extend_post_id: currentExtendPostId, + video_extension_start_time: extensionStartTime, + // original_post_id & file_attachment_id: 对齐官方抓包,始终传原始图片/视频 postId + original_post_id: currentExtendPostId, + file_attachment_id: originalFileAttachmentId || currentExtendPostId, + stitch_with_extend: true, + }; + debugLog('runExtendVideo:request', JSON.stringify(body)); + const resp = await fetch('/v1/public/video/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': authHeader, + }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const errText = await resp.text(); + throw new Error(`延长请求失败: ${resp.status} ${errText}`); + } + const result = await resp.json(); + const taskIds = result.task_ids || [result.task_id]; + spliceRun.taskIds = taskIds; + debugLog('runExtendVideo:taskIds', taskIds); + // 创建历史占位 + const serial = nextRound; + for (const tid of taskIds) { + const placeholder = initPreviewSlot(); + setPreviewTitle(placeholder, buildHistoryTitle('splice', serial)); + if (placeholder) placeholder.dataset.taskId = tid; + spliceRun.placeholders.set(tid, placeholder); + } + editingRound = nextRound; + // 连接 SSE + const rawPublicKey = normalizeAuthHeader(authHeader); + for (const tid of taskIds) { + const sseUrl = buildSseUrl(tid, rawPublicKey); + const source = new EventSource(sseUrl); + spliceRun.sources.add(source); + const taskState = { + progress: 0, + videoUrl: '', + done: false, + error: false, + progressBuffer: '', + contentBuffer: '', + collectingContent: false + }; + source.onmessage = (event) => { + if (spliceRun.cancelled) return; + const raw = String(event.data || '').trim(); + console.log('[SSE 调试] 收到数据:', raw); + if (raw === '[DONE]') { + console.log('[SSE 调试] 收到 [DONE] 标记'); + taskState.done = true; + source.close(); + checkAllExtendDone(spliceRun); + return; + } + try { + const parsed = JSON.parse(raw); + if (parsed.error) { + console.error('[SSE 调试] 解析到错误对象:', parsed.error); + taskState.error = true; + taskState.done = true; + spliceRun.failedReasons.push(parsed.error); + spliceRun.failedPlaceholders.add(tid); + const item = spliceRun.placeholders.get(tid); + if (item) { + setPreviewTitle(item, `延长失败: ${parsed.error}`); + item.classList.add('is-failed'); } - }); - }) - .catch((err) => { - if (String(err && err.message || '') === 'edit_cancelled') { + source.close(); + checkAllExtendDone(spliceRun); return; } - spliceRun.failedReasons.push(String(err && err.message ? err.message : err)); - const missItem = spliceRun.placeholders.get(taskId) || null; - if (missItem) spliceRun.failedPlaceholders.add(missItem); - }) - ); - await Promise.allSettled(waitTasks); - await processChain; - if (spliceRun.failedPlaceholders && spliceRun.failedPlaceholders.size) { - spliceRun.failedPlaceholders.forEach((item) => { - if (!item) return; - const completed = String(item.dataset.completed || '0') === '1'; - if (!completed) { - removePreviewItem(item); - } - }); - spliceRun.failedPlaceholders.clear(); - } - if (spliceRun.cancelled) { - throw new Error('edit_cancelled'); + const choice = parsed.choices && parsed.choices[0]; + const delta = choice && choice.delta ? choice.delta : null; + if (delta && delta.content) { + const text = delta.content; + + if (text.includes('') || text.includes('')) { + return; + } + + if (!taskState.collectingContent) { + if (text.includes(' + if (!videoUrl) { + const m = taskState.contentBuffer.match(/src="([^"]+\.mp4)"/i); + if (m) { + videoUrl = m[1]; + } + } + + if (videoUrl && !taskState.videoUrl) { + console.log('[SSE 调试] 解析到生成的视频 URL:', videoUrl); + taskState.videoUrl = videoUrl; + const item = spliceRun.placeholders.get(tid); + if (item) { + item.classList.remove('is-generating'); + item.classList.remove('is-failed'); + if (info && info.html) { + renderVideoFromHtml({ previewItem: item }, info.html); + } else { + renderVideoFromUrl({ previewItem: item }, videoUrl); + } + } + + + + // 更新工作区视频和 extendPostId + selectedVideoUrl = videoUrl; + if (editVideo) { + editVideo.src = videoUrl; + editVideo.load(); + } + // 从新视频 URL 提取 postId 用于链式延长 + const newPostId = extractPostIdFromFileName(videoUrl); + if (newPostId) { + currentExtendPostId = newPostId; // 更新当前 postId + currentFileAttachmentId = newPostId; + console.log('[SSE 调试] 从新视频成功提取到新的 extend_post_id:', newPostId); + } else { + console.warn('[SSE 调试] 未能从新视频地址提取出新的 extend_post_id!', videoUrl); + } + setEditMeta(); + toast('视频延长完成', 'success'); + } + } else { + taskState.progressBuffer += text; + // 从缓冲中匹配所有可能是进度的数字,包括带换行的文本 + const matches = [...taskState.progressBuffer.matchAll(/当前进度\s*(\d+)\s*%/g)]; + if (matches.length) { + const lastValue = parseInt(matches[matches.length - 1][1], 10); + if (lastValue >= taskState.progress) { + taskState.progress = lastValue; + setIndeterminate(false); + updateProgress(taskState.progress); + } + } + if (text.includes('超分辨率')) { + setStatus('connecting', '超分辨率中'); + setIndeterminate(true); + } + + // 定期清理过长的缓冲 + if (taskState.progressBuffer.length > 500) { + taskState.progressBuffer = taskState.progressBuffer.slice(-200); + } + } + } + } catch (e) { + // debugLog('runExtendVideo:parse_error', e); // ignore chunks split issues + } + }; + source.onerror = (err) => { + console.error('[SSE 调试] EventSource 抛出 onerror 异常', err); + taskState.error = true; + taskState.done = true; + source.close(); + checkAllExtendDone(spliceRun); + }; } - if (!successCount || !lastMergedUrl) { - const firstReason = String((spliceRun.failedReasons && spliceRun.failedReasons[0]) || '').trim(); - throw new Error(firstReason || 'edit_all_failed'); - } - bindEditVideoSource(lastMergedUrl); - scrollToWorkspaceTop(); - editingRound = nextRound; - setStatus('connected', `拼接完成(成功 ${successCount}/${taskIds.length})`); - toast(`拼接完成,成功 ${successCount}/${taskIds.length}`, 'success'); } catch (e) { - console.error('[video-splice-debug] runSplice:error', e); - const msg = String(e && e.message ? e.message : e); - if (msg === 'edit_cancelled') { - setStatus('', '未连接'); - } else { - setStatus('error', '拼接失败'); - if (msg === 'extract_frame_failed_near_tail') { - toast('拼接失败: 当前帧过于接近结尾,请前移 2-3 帧再试', 'error'); - } else if (msg === 'edit_video_url_missing') { - toast('拼接失败: 生成结果未返回视频地址', 'error'); - } else { - toast(`拼接失败: ${msg}`, 'error'); - } - } - } finally { + debugLog('runExtendVideo:error', e); + toast(String(e.message || '视频延长失败'), 'error'); + setStatus('error', '延长失败'); spliceRun.done = true; - if (activeSpliceRun === spliceRun) { - activeSpliceRun = null; - } + activeSpliceRun = null; editingBusy = false; setEditTimelineLock(false); setSpliceButtonState('idle'); } } + function checkAllExtendDone(spliceRun) { + const allDone = [...spliceRun.placeholders.keys()].every(tid => { + return spliceRun.failedPlaceholders.has(tid) || + (spliceRun.placeholders.get(tid) && spliceRun.placeholders.get(tid).dataset.completed === '1'); + }); + // 简化判断:所有 SSE 都关闭就算完成 + let openSources = 0; + for (const src of spliceRun.sources) { + if (src.readyState !== EventSource.CLOSED) openSources++; + } + if (openSources > 0) return; + spliceRun.done = true; + activeSpliceRun = null; + editingBusy = false; + setEditTimelineLock(false); + setSpliceButtonState('idle'); + stopElapsedTimer(); + setIndeterminate(false); + if (!spliceRun.failedReasons.length) { + updateProgress(100); + } + if (spliceRun.failedReasons.length) { + setStatus('error', '延长部分失败'); + } else { + setStatus('connected', '延长完成'); + } + } + + async function requestCancelExtend() { + if (!activeSpliceRun || activeSpliceRun.done) return; + activeSpliceRun.cancelled = true; + activeSpliceRun.cancelling = true; + setSpliceButtonState('stopping'); + for (const src of activeSpliceRun.sources) { + try { src.close(); } catch (e) { /* ignore */ } + } + const taskIdsToStop = Array.from(activeSpliceRun.taskIds || []); + activeSpliceRun.done = true; + activeSpliceRun = null; + editingBusy = false; + setEditTimelineLock(false); + setSpliceButtonState('idle'); + stopElapsedTimer(); + setIndeterminate(false); + setStatus('disconnected', '已取消'); + toast('已中止延长', 'info'); + + if (taskIdsToStop.length > 0) { + const authHeader = await ensurePublicKey(); + stopVideoTask(taskIdsToStop, authHeader).catch(e => console.error('[SSE] 延长中止请求失败', e)); + } + } + function finishRun(hasError) { if (!isRunning) return; closeAllSources(); @@ -2586,11 +1963,23 @@ const duration = Number(editVideo.duration || 0); if (!Number.isFinite(duration) || duration <= 0) return; const ratio = Number(editTimeline.value || 0) / EDIT_TIMELINE_MAX; - const nextTime = Math.max(0, Math.min(duration, duration * ratio)); + let nextTime = Math.max(0, Math.min(duration, duration * ratio)); + // 官方限制最多延长至 30s,即起始点不晚于 20s + if (nextTime > 20) { + nextTime = 20; + // 强制回弹对应的 UI 表现 + const forceRatio = 20 / duration; + editTimeline.value = String(Math.round(forceRatio * EDIT_TIMELINE_MAX)); + } editVideo.currentTime = nextTime; updateDeleteZoneTrack(editTimeline); lockedTimestampMs = clampEditTimestampMs(Math.round(nextTime * 1000)); - if (editTimeText) editTimeText.textContent = formatMs(lockedTimestampMs); + if (editTimeText) { + editTimeText.textContent = formatMs(lockedTimestampMs); + if (nextTime === 20 && duration > 20) { + editTimeText.textContent += " (已达官方20s延长上限)"; + } + } lockFrameByCurrentTime(); }); } @@ -2607,7 +1996,7 @@ } lockedTimestampMs = 0; lockedFrameIndex = 0; - lastFrameHash = ''; + setEditMeta(); updateTimelineByVideoTime(); }); @@ -2633,77 +2022,6 @@ setTimeout(() => scheduleWorkspacePreviewLock(true), 160); }); - if (mergeVideoPreviewA) { - enforceInlinePlayback(mergeVideoPreviewA); - mergeVideoPreviewA.addEventListener('loadedmetadata', () => { - const duration = Number(mergeVideoPreviewA.duration || 0); - if (mergeDurationA) { - mergeDurationA.textContent = duration > 0 ? `总时长 ${formatMs(duration * 1000)}` : '总时长 -'; - } - mergeCutMsA = 0; - if (mergeTimeTextA) mergeTimeTextA.textContent = formatMs(0); - if (mergeTimelineA) mergeTimelineA.value = '0'; - updateDeleteZoneTrack(mergeTimelineA); - }); - } - - if (mergeVideoPreviewB) { - enforceInlinePlayback(mergeVideoPreviewB); - mergeVideoPreviewB.addEventListener('loadedmetadata', () => { - const duration = Number(mergeVideoPreviewB.duration || 0); - if (mergeDurationB) { - mergeDurationB.textContent = duration > 0 ? `总时长 ${formatMs(duration * 1000)}` : '总时长 -'; - } - mergeCutMsB = 0; - if (mergeTimeTextB) mergeTimeTextB.textContent = formatMs(0); - if (mergeTimelineB) mergeTimelineB.value = '0'; - updateDeleteZoneTrack(mergeTimelineB); - }); - } - - if (mergeTimelineA) { - mergeTimelineA.addEventListener('input', () => { - if (!mergeVideoPreviewA) return; - const duration = Number(mergeVideoPreviewA.duration || 0); - if (!Number.isFinite(duration) || duration <= 0) return; - const ratio = Number(mergeTimelineA.value || 0) / EDIT_TIMELINE_MAX; - const nextTime = Math.max(0, Math.min(duration, duration * ratio)); - mergeVideoPreviewA.currentTime = nextTime; - updateDeleteZoneTrack(mergeTimelineA); - mergeCutMsA = Math.round(nextTime * 1000); - if (mergeTimeTextA) mergeTimeTextA.textContent = formatMs(mergeCutMsA); - }); - } - - if (mergeTimelineB) { - mergeTimelineB.addEventListener('input', () => { - if (!mergeVideoPreviewB) return; - const duration = Number(mergeVideoPreviewB.duration || 0); - if (!Number.isFinite(duration) || duration <= 0) return; - const ratio = Number(mergeTimelineB.value || 0) / EDIT_TIMELINE_MAX; - const nextTime = Math.max(0, Math.min(duration, duration * ratio)); - mergeVideoPreviewB.currentTime = nextTime; - updateDeleteZoneTrack(mergeTimelineB); - mergeCutMsB = Math.round(nextTime * 1000); - if (mergeTimeTextB) mergeTimeTextB.textContent = formatMs(mergeCutMsB); - }); - } - - if (spliceBtn) { - spliceBtn.addEventListener('click', () => { - if (activeSpliceRun && !activeSpliceRun.done) { - requestCancelSplice(); - return; - } - runSplice(); - }); - } - - if (directMergeBtn) { - directMergeBtn.addEventListener('click', () => { - directMergeTwoVideos(); - }); - } if (pickCachedVideoBtn) { pickCachedVideoBtn.addEventListener('click', async () => { @@ -2746,24 +2064,6 @@ }); } - if (pickMergeVideoBtn) { - pickMergeVideoBtn.addEventListener('click', async () => { - try { - cacheModalPickMode = 'merge_target'; - openCacheVideoModal(pickMergeVideoBtn); - if (cacheVideoList) { - cacheVideoList.innerHTML = '
正在读取缓存视频...
'; - } - const items = await loadCachedVideos(); - renderCachedVideoList(items); - } catch (e) { - if (cacheVideoList) { - cacheVideoList.innerHTML = '
读取失败,请稍后重试
'; - } - toast('读取缓存视频失败', 'error'); - } - }); - } if (closeCacheVideoModalBtn) { closeCacheVideoModalBtn.addEventListener('click', () => { closeCacheVideoModal(); @@ -2814,11 +2114,16 @@ toast('该视频暂无可用地址', 'warning'); return; } - mergeTargetVideoUrl = bUrl; - mergeTargetVideoName = `视频 ${String(item.dataset.index || '')}`; - updateMergeLabels(); - bindMergeVideoB(mergeTargetVideoUrl); - toast('已将该视频设为视频2', 'success'); + // 提取 postId 用于延长 + const postId = extractPostIdFromFileName(bUrl); + if (postId) { + currentExtendPostId = postId; + currentFileAttachmentId = postId; + setEditMeta(); + toast('已提取该视频的 postId', 'success'); + } else { + toast('无法从该视频提取 postId', 'warning'); + } return; } selectedVideoItemId = String(item.dataset.index || ''); @@ -2827,8 +2132,7 @@ if (enterEditBtn) { enterEditBtn.disabled = !selectedVideoUrl; } - updateMergeLabels(); - bindMergeVideoA(selectedVideoUrl); + if (target.classList.contains('video-edit')) { event.preventDefault(); openEditPanel(); @@ -2851,7 +2155,12 @@ const blobUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = blobUrl; - anchor.download = index ? `grok_video_${index}.mp4` : 'grok_video.mp4'; + // 文件名包含 postId:grok_video_{postId}_{index}.mp4 + const postIdInUrl = extractPostIdFromFileName(url); + const nameParts = ['grok_video']; + if (postIdInUrl) nameParts.push(postIdInUrl); + if (index) nameParts.push(index); + anchor.download = `${nameParts.join('_')}.mp4`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); @@ -3069,15 +2378,21 @@ el.addEventListener('change', updateMeta); }); - window.__VIDEO_FFMPEG_LOGS__ = () => ffmpegLogBuffer.slice(); - updateMeta(); - updateMergeLabels(); updateHistoryCount(); - updateManualActionsVisibility(); refreshAllDeleteZoneTracks(); syncTimelineAvailability(); setSpliceButtonState('idle'); + + if (spliceBtn) { + spliceBtn.addEventListener('click', () => { + if (activeSpliceRun && !activeSpliceRun.done) { + requestCancelExtend(); + return; + } + runExtendVideo(); + }); + } if (imageUrlInput && imageUrlInput.value.trim()) { const resolved = resolveReferenceByText(imageUrlInput.value.trim()); setReferencePreview(resolved.url || resolved.sourceUrl || imageUrlInput.value.trim(), resolved.parentPostId || ''); diff --git a/app/static/public/pages/video.html b/app/static/public/pages/video.html index b41de71..7e92a49 100644 --- a/app/static/public/pages/video.html +++ b/app/static/public/pages/video.html @@ -3,7 +3,8 @@ - + Grok2API - Video 视频工作台 @@ -42,80 +43,81 @@
生成设置
-
- - -
-
- -
- +
+ +
-
- 未选择文件 - 支持拖拽/粘贴图片 +
+ +
+ +
+
+ 未选择文件 + 支持拖拽/粘贴图片 +
+
+ + +
+
-
- - +
+ +
- -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - -
-
- - -
-
视频工作区
+
视频延长工作区
@@ -147,78 +149,55 @@
请先选择或生成一个视频,工作区会始终展示当前视频。
-
当前工作视频
-
-
历史数量
0
+
+
当前工作视频
+
-
+
+
+
历史数量
+
0
+
- +
00:00.000 总时长 -
-
frame_index
-
-
timestamp_ms
-
-
frame_hash
-
-
视频2(B)
-
+
+
frame_index
+
-
+
+
+
timestamp_ms
+
-
+
+
+
extend_post_id
+
-
+
- - + +
-
-
-
手动两段拼接
-
- - -
-
-
-
-
视频1拼接帧(向左保留)
-
- -
-
- -
- 00:00.000 - 总时长 - -
-
-
-
-
视频2拼接帧(向右保留)
-
- -
-
- -
- 00:00.000 - 总时长 - -
-
-
-
-
@@ -266,7 +245,7 @@
历史视频
-
生成视频与拼接视频都会在这里保存,点击可替换到工作区。
+
生成视频与延长视频都会在这里保存,点击可替换到工作区。
@@ -298,9 +277,7 @@ - - - + - + \ No newline at end of file diff --git a/ignore_delete/grok2api b/ignore_delete/grok2api new file mode 160000 index 0000000..755fa30 --- /dev/null +++ b/ignore_delete/grok2api @@ -0,0 +1 @@ +Subproject commit 755fa30afaa6895909790f99d0ab6432d983033a