模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
@@ -181,105 +181,242 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
|
||||
一张结果卡。任一片段先完成就先写回 GENERATING 卡供用户预览;所有分段完成后才转成
|
||||
可合并的结果卡,绝不在此处触发 ffmpeg。
|
||||
"""
|
||||
from .free_video import finalize_free_video
|
||||
from django.conf import settings
|
||||
|
||||
from .free_video import IN_FLIGHT_STATUSES, finalize_free_video, submit_free_video
|
||||
from .models import AITask
|
||||
|
||||
payload = message.payload or {}
|
||||
ids = [str(value) for value in payload.get("task_ids") or [] if value]
|
||||
if not ids:
|
||||
# 团队级锁:同一团队若有两支长视频同时回填,不能都按同一份剩余并发额度补交。
|
||||
lock_key = f"omni:segment-schedule:{message.conversation.team_id}"
|
||||
if not cache.add(lock_key, "1", timeout=90):
|
||||
return False
|
||||
by_id = {
|
||||
str(task.id): task
|
||||
for task in AITask.objects.filter(id__in=ids).select_related("model_config")
|
||||
}
|
||||
tasks = [by_id.get(task_id) for task_id in ids]
|
||||
if any(task is None for task in tasks):
|
||||
fail_generating_message(message, "分段视频任务不完整,请重新生成。")
|
||||
return True
|
||||
try:
|
||||
message.refresh_from_db()
|
||||
payload = dict(message.payload or {})
|
||||
segments = [dict(item) for item in payload.get("segments") or [] if isinstance(item, dict)]
|
||||
ids = [str(value) for value in payload.get("task_ids") or [] if value]
|
||||
if not ids or not segments:
|
||||
return False
|
||||
by_id = {
|
||||
str(task.id): task
|
||||
for task in AITask.objects.filter(id__in=ids).select_related("model_config")
|
||||
}
|
||||
tasks = [by_id.get(task_id) for task_id in ids]
|
||||
if any(task is None for task in tasks):
|
||||
fail_generating_message(message, "分段视频任务不完整,请重新生成。")
|
||||
return True
|
||||
|
||||
# 本地没有 worker 时,用户的会话轮询本身即可推进每个片段;已在 worker 中的任务则幂等返回。
|
||||
refreshed = []
|
||||
for task in tasks:
|
||||
assert task is not None
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 - 单段网络抖动不能使整组直接失败
|
||||
# 本地没有 worker 时,会话轮询也能推进已提交片段;已在 worker 中则幂等返回。
|
||||
refreshed = []
|
||||
for task in tasks:
|
||||
assert task is not None
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 - 单段网络抖动不能使整组直接失败
|
||||
task.refresh_from_db()
|
||||
else:
|
||||
task.refresh_from_db()
|
||||
else:
|
||||
task.refresh_from_db()
|
||||
refreshed.append(task)
|
||||
refreshed.append(task)
|
||||
|
||||
failed = next(
|
||||
(task for task in refreshed if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED)),
|
||||
None,
|
||||
)
|
||||
if failed is not None:
|
||||
from .generation_errors import public_error_for_task
|
||||
segment_by_task = {
|
||||
str(item.get("task_id")): int(item.get("index") or index)
|
||||
for index, item in enumerate(segments, start=1)
|
||||
if item.get("task_id")
|
||||
}
|
||||
failed = next(
|
||||
(task for task in refreshed if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED)),
|
||||
None,
|
||||
)
|
||||
|
||||
number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1)
|
||||
public_error = public_error_for_task(failed, operation="video_generate")
|
||||
detail = public_error.fallback_message if public_error else (failed.error_message or "请重试")
|
||||
fail_generating_message(message, f"第 {number} 段生成失败:{detail}")
|
||||
assets: list[dict] = []
|
||||
completed_segments = 0
|
||||
for task in refreshed:
|
||||
if task.status != AITask.Status.SUCCEEDED:
|
||||
continue
|
||||
task_assets = _assets_from_task(task)
|
||||
if not task_assets:
|
||||
continue
|
||||
segment_index = segment_by_task.get(str(task.id), completed_segments + 1)
|
||||
completed_segments += 1
|
||||
for asset in task_assets:
|
||||
assets.append({**asset, "label": f"第 {segment_index} 段", "segment_index": segment_index})
|
||||
assets.sort(key=lambda item: int(item.get("segment_index") or 0))
|
||||
|
||||
if failed is not None:
|
||||
from .generation_errors import public_error_for_task
|
||||
|
||||
number = segment_by_task.get(str(failed.id), 1)
|
||||
public_error = public_error_for_task(failed, operation="video_generate")
|
||||
detail = public_error.fallback_message if public_error else (failed.error_message or "请重试")
|
||||
error_text = f"第 {number} 段生成失败:{detail}"
|
||||
# 取消仍在飞的其它分段,避免失败后继续扣费/刷进度。
|
||||
for task in refreshed:
|
||||
if task.id == failed.id:
|
||||
continue
|
||||
if task.status in IN_FLIGHT_STATUSES:
|
||||
from apps.billing.services.ledger import release_credit
|
||||
|
||||
task.status = AITask.Status.CANCELLED
|
||||
task.error_message = "同组其它分段已失败,已取消"
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
if task.credit_reservation is not None:
|
||||
try:
|
||||
release_credit(
|
||||
reservation=task.credit_reservation,
|
||||
reason="同组分段失败,取消未完成片段",
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if assets:
|
||||
first = next((task for task in refreshed if task.status == AITask.Status.SUCCEEDED), failed)
|
||||
finish_generating_message(
|
||||
message,
|
||||
assets=assets,
|
||||
meta={
|
||||
**_meta_from_task(first, message),
|
||||
"kind": "video_segments",
|
||||
"segment_count": len(segments),
|
||||
"total_duration": payload.get("total_duration") or "",
|
||||
"needs_merge": False,
|
||||
"partial_failure": True,
|
||||
"failed_segment_index": number,
|
||||
"error": error_text,
|
||||
"segments": segments,
|
||||
"task_ids": ids,
|
||||
},
|
||||
)
|
||||
conversation = message.conversation
|
||||
conversation.status = CreationConversation.Status.FAILED
|
||||
conversation.save(update_fields=["status", "updated_at"])
|
||||
append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ERROR,
|
||||
text=f"{error_text}。已保留成功片段供预览,未合并成片;可重新确认方案再生成。",
|
||||
)
|
||||
else:
|
||||
fail_generating_message(message, error_text)
|
||||
return True
|
||||
|
||||
# 释放出来的并发槽位自动补交下一批。首批与后续批次统一从 generation_spec 派生,
|
||||
# 因而复用同一份人物/商品参考、完整脚本和 seed。
|
||||
pending = [item for item in segments if not item.get("task_id")]
|
||||
if pending:
|
||||
in_flight = AITask.objects.filter(
|
||||
team=message.conversation.team,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=IN_FLIGHT_STATUSES,
|
||||
).count()
|
||||
slots = max(0, int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3)) - in_flight)
|
||||
spec = payload.get("generation_spec") if isinstance(payload.get("generation_spec"), dict) else {}
|
||||
if slots and not spec:
|
||||
fail_generating_message(message, "长视频续段参数不完整,请重新生成。")
|
||||
return True
|
||||
if slots:
|
||||
from .creation_agent import build_segment_video_submit
|
||||
|
||||
total_duration = int(payload.get("total_duration") or 0)
|
||||
submitted_now = 0
|
||||
try:
|
||||
for segment in pending[:slots]:
|
||||
task = submit_free_video(
|
||||
team=message.conversation.team,
|
||||
user=message.conversation.created_by,
|
||||
params=build_segment_video_submit(spec, segment, total_duration),
|
||||
)
|
||||
task_id = str(task.id)
|
||||
segment["task_id"] = task_id
|
||||
ids.append(task_id)
|
||||
request_payload = dict(task.request_payload or {})
|
||||
marker = dict(request_payload.get("omni_segment") or {})
|
||||
marker.update({
|
||||
"index": int(segment.get("index") or 0),
|
||||
"start": int(segment.get("start") or 0),
|
||||
"end": int(segment.get("end") or 0),
|
||||
"total_duration": total_duration,
|
||||
"message_id": str(message.id),
|
||||
})
|
||||
request_payload["omni_segment"] = marker
|
||||
task.request_payload = request_payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
submitted_now += 1
|
||||
except ValueError as exc:
|
||||
if not submitted_now:
|
||||
fail_generating_message(message, f"后续分段提交失败:{exc}")
|
||||
return True
|
||||
# 逐段提交期间并发槽位被别的请求占用时,保留本轮已提交任务;余下片段下轮再补。
|
||||
payload["task_ids"] = ids
|
||||
payload["segments"] = segments
|
||||
|
||||
progress = {
|
||||
**payload,
|
||||
"assets": assets,
|
||||
"completed_segment_count": completed_segments,
|
||||
"submitted_segment_count": len(ids),
|
||||
}
|
||||
all_submitted = all(item.get("task_id") for item in segments)
|
||||
all_succeeded = all(task.status == AITask.Status.SUCCEEDED for task in refreshed)
|
||||
# 刚补交的新任务不在 refreshed 内,因此必须同时校验提交数,不能提前完成结果卡。
|
||||
if not all_submitted or len(refreshed) != len(segments) or not all_succeeded:
|
||||
if progress != payload:
|
||||
message.payload = progress
|
||||
message.save(update_fields=["payload", "updated_at"])
|
||||
return True
|
||||
return False
|
||||
|
||||
# 状态已成功但资产尚未落库时继续等待,避免最终成片缺段。
|
||||
if completed_segments != len(segments):
|
||||
if progress != payload:
|
||||
message.payload = progress
|
||||
message.save(update_fields=["payload", "updated_at"])
|
||||
return True
|
||||
return False
|
||||
|
||||
first = refreshed[0]
|
||||
finish_generating_message(
|
||||
message,
|
||||
assets=assets,
|
||||
meta={
|
||||
**_meta_from_task(first, message),
|
||||
"kind": "video_segments",
|
||||
"segment_count": len(segments),
|
||||
"total_duration": payload.get("total_duration") or "",
|
||||
"needs_merge": True,
|
||||
"auto_merge": True,
|
||||
"segments": segments,
|
||||
"task_ids": ids,
|
||||
},
|
||||
)
|
||||
message.refresh_from_db()
|
||||
# 全部分段成功后由平台自动合并,不再等用户点「合并成片」。
|
||||
try:
|
||||
merge_task, _generating = start_segmented_video_merge(
|
||||
conversation=message.conversation,
|
||||
message=message,
|
||||
user=message.conversation.created_by,
|
||||
)
|
||||
try:
|
||||
from .tasks import merge_omni_video_segments_task
|
||||
|
||||
merge_omni_video_segments_task.apply_async(args=[str(merge_task.id)])
|
||||
except Exception: # noqa: BLE001
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
"omni auto video merge enqueue failed for %s", merge_task.id, exc_info=True
|
||||
)
|
||||
except ValueError:
|
||||
# 已在合并中或状态不允许时忽略,避免重复入队。
|
||||
pass
|
||||
return True
|
||||
assets: list[dict] = []
|
||||
completed_segments = 0
|
||||
for index, task in enumerate(refreshed, start=1):
|
||||
if task.status != AITask.Status.SUCCEEDED:
|
||||
continue
|
||||
task_assets = _assets_from_task(task)
|
||||
# 上游状态先成功、资产稍后才落库时,保留 GENERATING,下一轮再展示这段。
|
||||
if not task_assets:
|
||||
continue
|
||||
completed_segments += 1
|
||||
for asset in task_assets:
|
||||
assets.append({**asset, "label": f"第 {index} 段", "segment_index": index})
|
||||
|
||||
# 先完成的片段必须立刻回到前端,不能等另一段慢任务一起完成才出现。
|
||||
# 保持同一条 GENERATING 消息,避免把一个 60 秒视频拆成多条对话消息。
|
||||
if not all(task.status == AITask.Status.SUCCEEDED for task in refreshed):
|
||||
progress = {
|
||||
**payload,
|
||||
"assets": assets,
|
||||
"completed_segment_count": completed_segments,
|
||||
}
|
||||
if progress != payload:
|
||||
message.payload = progress
|
||||
message.save(update_fields=["payload", "updated_at"])
|
||||
return True
|
||||
return False
|
||||
|
||||
# 任务都成功但有片段的资产还在落库,继续保持生成中,避免最终结果缺片。
|
||||
if completed_segments != len(refreshed):
|
||||
progress = {
|
||||
**payload,
|
||||
"assets": assets,
|
||||
"completed_segment_count": completed_segments,
|
||||
}
|
||||
if progress != payload:
|
||||
message.payload = progress
|
||||
message.save(update_fields=["payload", "updated_at"])
|
||||
return True
|
||||
return False
|
||||
|
||||
first = refreshed[0]
|
||||
finish_generating_message(
|
||||
message,
|
||||
assets=assets,
|
||||
meta={
|
||||
**_meta_from_task(first, message),
|
||||
"kind": "video_segments",
|
||||
"segment_count": len(refreshed),
|
||||
"total_duration": payload.get("total_duration") or "",
|
||||
"needs_merge": True,
|
||||
"segments": payload.get("segments") or [],
|
||||
},
|
||||
)
|
||||
return True
|
||||
finally:
|
||||
cache.delete(lock_key)
|
||||
|
||||
|
||||
def start_segmented_video_merge(*, conversation: CreationConversation, message: CreationMessage, user):
|
||||
"""用户明确点击后才建立合并任务;这之前绝不下载片段或调用 ffmpeg。"""
|
||||
"""建立合并任务(全部分段成功后由平台自动调用;也可由旧接口手动触发)。合并执行仍只在 run_segmented_video_merge。"""
|
||||
from .models import AITask
|
||||
|
||||
payload = dict(message.payload or {})
|
||||
@@ -406,9 +543,16 @@ def sync_generating_messages(conversation: CreationConversation) -> int:
|
||||
def sync_generating_for_task(task) -> int:
|
||||
"""worker / poll 终态后:只扫挂在这个任务上的 GENERATING。失败不能向外抛。"""
|
||||
try:
|
||||
from django.db.models import Q
|
||||
|
||||
marker = (task.request_payload or {}).get("omni_segment") or {}
|
||||
aggregate_message_id = str(marker.get("message_id") or "") if isinstance(marker, dict) else ""
|
||||
lookup = Q(task=task)
|
||||
if aggregate_message_id:
|
||||
lookup |= Q(id=aggregate_message_id)
|
||||
pending = list(
|
||||
CreationMessage.objects.filter(
|
||||
task=task, kind=CreationMessage.Kind.GENERATING,
|
||||
lookup, kind=CreationMessage.Kind.GENERATING,
|
||||
).select_related("conversation", "task", "task__model_config")
|
||||
)
|
||||
return sum(1 for message in pending if sync_generating_message(message))
|
||||
|
||||
Reference in New Issue
Block a user