feat: 支持视频单图模式切换
This commit is contained in:
1 parent
3f41749a7d
commit
94ee740c52
5 files changed
+107
-4
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -231,6 +231,13 @@
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<label class="field-label" for="singleImageModeSelect">单图模式</label>
|
||||
<select id="singleImageModeSelect" class="geist-input">
|
||||
<option value="frame" selected>作为首帧</option>
|
||||
<option value="reference">作为参考图</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<label class="field-label" for="concurrentSelect">并发视频数</label>
|
||||
<select id="concurrentSelect" class="geist-input">
|
||||
|
||||
@@ -419,6 +419,63 @@ curl http://127.0.0.1:8000/v1/chat/completions \
|
||||
}'
|
||||
```
|
||||
|
||||
### 单图模式视频
|
||||
|
||||
`POST /v1/public/video/start`
|
||||
|
||||
单图时可通过 `single_image_mode` 指定这张图的用途:
|
||||
|
||||
- `frame`:作为首帧
|
||||
- `reference`:作为参考图
|
||||
|
||||
单图作为首帧:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/v1/public/video/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-public-key: YOUR_PUBLIC_KEY" \
|
||||
-d '{
|
||||
"prompt": "两人合唱",
|
||||
"reference_items": [
|
||||
{
|
||||
"image_url": "data:image/png;base64,AAA...",
|
||||
"source_image_url": "data:image/png;base64,AAA...",
|
||||
"mention_alias": "Image 1"
|
||||
}
|
||||
],
|
||||
"single_image_mode": "frame",
|
||||
"aspect_ratio": "3:2",
|
||||
"video_length": 6,
|
||||
"resolution_name": "480p",
|
||||
"preset": "normal",
|
||||
"concurrent": 1
|
||||
}'
|
||||
```
|
||||
|
||||
单图作为参考图:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/v1/public/video/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-public-key: YOUR_PUBLIC_KEY" \
|
||||
-d '{
|
||||
"prompt": "两人合唱",
|
||||
"reference_items": [
|
||||
{
|
||||
"image_url": "data:image/png;base64,AAA...",
|
||||
"source_image_url": "data:image/png;base64,AAA...",
|
||||
"mention_alias": "Image 1"
|
||||
}
|
||||
],
|
||||
"single_image_mode": "reference",
|
||||
"aspect_ratio": "3:2",
|
||||
"video_length": 6,
|
||||
"resolution_name": "480p",
|
||||
"preset": "normal",
|
||||
"concurrent": 1
|
||||
}'
|
||||
```
|
||||
|
||||
### 图片编辑
|
||||
|
||||
`POST /v1/images/edits`
|
||||
|
||||
Reference in new issue
Block a user