模特库标签分页与全能创作收口:藏长视频、选择器分页

角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
Azmat@qq.com
2026-09-21 16:24:37 +08:00
parent 690eb3d843
commit 0bd1db6bf9
48 changed files with 3386 additions and 896 deletions
+233 -89
View File
@@ -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))