diff --git a/app/api/v1/public_api/video.py b/app/api/v1/public_api/video.py index 344c50e..5d16811 100644 --- a/app/api/v1/public_api/video.py +++ b/app/api/v1/public_api/video.py @@ -102,6 +102,7 @@ async def _new_session( source_image_url: Optional[str], reference_items: Optional[List[Dict[str, str]]], reasoning_effort: Optional[str], + single_image_mode: str = "frame", # 视频延长相关 is_video_extension: bool = False, extend_post_id: Optional[str] = None, @@ -125,6 +126,7 @@ async def _new_session( "source_image_url": source_image_url, "reference_items": reference_items or [], "reasoning_effort": reasoning_effort, + "single_image_mode": single_image_mode, "is_video_extension": is_video_extension, "extend_post_id": extend_post_id, "video_extension_start_time": video_extension_start_time, @@ -290,6 +292,7 @@ class VideoStartRequest(BaseModel): video_length: Optional[int] = 6 resolution_name: Optional[str] = "480p" preset: Optional[str] = "normal" + single_image_mode: Optional[str] = "frame" concurrent: Optional[int] = Field(1, ge=1, le=4) image_url: Optional[str] = None parent_post_id: Optional[str] = None @@ -343,6 +346,10 @@ async def public_video_start(data: VideoStartRequest): if concurrent < 1 or concurrent > 4: raise HTTPException(status_code=400, detail="concurrent must be between 1 and 4") + single_image_mode = str(data.single_image_mode or "frame").strip().lower() + if single_image_mode not in ("frame", "reference"): + raise HTTPException(status_code=400, detail="single_image_mode must be one of ['frame','reference']") + reference_items = _build_reference_items(data) if len(reference_items) > 7: raise HTTPException(status_code=400, detail="最多支持 7 张参考图") @@ -435,6 +442,7 @@ async def public_video_start(data: VideoStartRequest): source_image_url, reference_items, reasoning_effort, + single_image_mode=single_image_mode, is_video_extension=is_video_extension, extend_post_id=extend_post_id, video_extension_start_time=video_extension_start_time, @@ -453,6 +461,7 @@ async def public_video_start(data: VideoStartRequest): "reference_count": len(reference_items), "extend_post_id": extend_post_id, "file_attachment_id": file_attachment_id or "", + "single_image_mode": single_image_mode, } @@ -472,6 +481,7 @@ async def public_video_sse(request: Request, task_id: str = Query("")): source_image_url = str(session.get("source_image_url") or "").strip() or None reference_items = session.get("reference_items") or [] reasoning_effort = session.get("reasoning_effort") + single_image_mode = str(session.get("single_image_mode") or "frame").strip() or "frame" async def event_stream(): try: @@ -524,6 +534,7 @@ async def public_video_sse(request: Request, task_id: str = Query("")): stitch_with_extend=stitch_with_extend, source_image_url=source_image_url, reference_items=reference_items, + single_image_mode=single_image_mode, ) async for chunk in stream: diff --git a/app/services/grok/services/video.py b/app/services/grok/services/video.py index 57961db..11d2b50 100644 --- a/app/services/grok/services/video.py +++ b/app/services/grok/services/video.py @@ -680,18 +680,31 @@ class VideoService: ) -> AsyncGenerator[bytes, None]: """Generate video from image.""" token_tag = _token_tag(token) + normalized_image_url = str(image_url or "").strip() + if normalized_image_url.startswith("data:"): + upload_service = UploadService() + try: + _, file_uri = await upload_service.upload_file(normalized_image_url, token) + normalized_image_url = f"https://assets.grok.com/{file_uri}" + logger.info( + "Image to video source uploaded before generation: " + f"token={token_tag}, asset_url={normalized_image_url}" + ) + finally: + await upload_service.close() + # 确定逻辑上的 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={official_mode}" + f"Image to video: token={token_tag}, prompt='{prompt[:50]}...', image={normalized_image_url[:80]}, mode={official_mode}" ) - post_id = await self.create_image_post(token, image_url) + post_id = await self.create_image_post(token, normalized_image_url) message = self._build_video_message( prompt=prompt, preset=preset, - source_image_url=image_url, + source_image_url=normalized_image_url, ) model_config_override = { "modelMap": { @@ -1070,6 +1083,7 @@ class VideoService: stitch_with_extend: bool = True, source_image_url: str | None = None, reference_items: list[dict[str, Any]] | None = None, + single_image_mode: str = "frame", ): """Video generation entrypoint.""" # Get token via intelligent routing. @@ -1190,6 +1204,7 @@ class VideoService: item = reference_items[0] single_parent_post_id = str(item.get("parent_post_id") or "").strip() single_source_image_url = str(item.get("source_image_url") or item.get("image_url") or "").strip() + effective_single_image_mode = str(single_image_mode or "frame").strip().lower() or "frame" if single_parent_post_id: response = await service.generate_from_parent_post( token=token, @@ -1201,6 +1216,16 @@ class VideoService: resolution=resolution, preset=preset, ) + elif effective_single_image_mode == "reference": + response = await service.generate_from_reference_items( + token=token, + prompt=prompt, + reference_items=reference_items, + aspect_ratio=aspect_ratio, + video_length=video_length, + resolution=resolution, + preset=preset, + ) else: response = await service.generate_from_image( token, diff --git a/app/static/public/js/video.js b/app/static/public/js/video.js index c5f94c9..5d4cb6a 100644 --- a/app/static/public/js/video.js +++ b/app/static/public/js/video.js @@ -38,6 +38,7 @@ const resolutionSelect = document.getElementById('resolutionSelect'); const presetSelect = document.getElementById('presetSelect'); const concurrentSelect = document.getElementById('concurrentSelect'); + const singleImageModeSelect = document.getElementById('singleImageModeSelect'); const statusText = document.getElementById('statusText'); const progressBar = document.getElementById('progressBar'); const progressFill = document.getElementById('progressFill'); @@ -1414,6 +1415,7 @@ video_length: lengthSelect ? parseInt(lengthSelect.value, 10) : 6, resolution_name: resolutionSelect ? resolutionSelect.value : '480p', preset: presetSelect ? presetSelect.value : 'normal', + single_image_mode: singleImageModeSelect ? singleImageModeSelect.value : 'frame', concurrent: getConcurrentValue() }) }); @@ -2103,6 +2105,7 @@ video_length: lengthSelect ? parseInt(lengthSelect.value, 10) : 6, resolution_name: resolutionSelect ? resolutionSelect.value : '480p', preset: presetSelect ? presetSelect.value : 'custom', + single_image_mode: 'frame', concurrent, n: concurrent, edit_context: editCtx @@ -3089,7 +3092,7 @@ hideReferenceMentionMenu(); }); - [ratioSelect, lengthSelect, resolutionSelect, presetSelect, concurrentSelect] + [ratioSelect, lengthSelect, resolutionSelect, presetSelect, concurrentSelect, singleImageModeSelect] .filter(Boolean) .forEach((el) => { el.addEventListener('change', updateMeta); diff --git a/app/static/public/pages/video.html b/app/static/public/pages/video.html index b959faa..80423dc 100644 --- a/app/static/public/pages/video.html +++ b/app/static/public/pages/video.html @@ -231,6 +231,13 @@ +
+ + +