1572 lines
62 KiB
Python
1572 lines
62 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from collections.abc import Awaitable, Callable
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
from sqlalchemy import and_, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.enums.celery_queue import CeleryQueue
|
|
from app.enums.generation_task import (
|
|
ChatGenerationPipelineStage,
|
|
ChatGenerationTaskStatus,
|
|
GenerationType,
|
|
)
|
|
from app.enums.video_upscale import (
|
|
LOCAL_PROCESSOR_KEYS,
|
|
REMOTE_PROCESSOR_KEYS,
|
|
VIDEO_UPSCALE_SOURCE_RETAINED_MARKER,
|
|
VideoUpscaleInputSourceType,
|
|
VideoUpscaleProbeStatus,
|
|
VideoUpscaleStage,
|
|
VideoUpscaleTaskStatus,
|
|
)
|
|
from app.models.chat_generation_task import ChatGenerationTask
|
|
from app.models.generation_record import GenerationRecord
|
|
from app.models.video_upscale_task import VideoUpscaleTask
|
|
from app.services.generation.pipeline.db_lock_service import apply_short_lock_timeout
|
|
from app.services.generation.pipeline.lifecycle_service import notify_owner_finished
|
|
from app.services.redis_registry_service import RedisExecutionLockError, RedisExecutionLockLost
|
|
from app.services.media_token_usage_snapshot_service import (
|
|
sync_chat_generation_task_media_token_snapshot,
|
|
sync_generation_record_media_token_snapshot,
|
|
)
|
|
from app.services.video_upscale.log_service import log_video_upscale_event
|
|
from app.services.resource_accounting_service import (
|
|
record_chat_task_generated_resource,
|
|
record_generation_record_generated_resource,
|
|
safe_file_size,
|
|
)
|
|
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
|
from app.services.video_upscale.local_ffmpeg_service import execute_local_ffmpeg_crop
|
|
from app.services.video_upscale.owner_service import (
|
|
VideoUpscaleOwner,
|
|
load_upscale_owner,
|
|
mark_owner_upscale_failed,
|
|
owner_is_completed,
|
|
owner_is_generating,
|
|
restore_owner_for_upscale_retry,
|
|
set_owner_stage,
|
|
upscale_stage_value,
|
|
)
|
|
from app.services.video_upscale.media_service import (
|
|
build_local_source_signed_url,
|
|
download_video_to_path,
|
|
is_valid_file,
|
|
parse_tos_signed_url_expiry,
|
|
probe_remote_url,
|
|
probe_video,
|
|
safe_remove,
|
|
)
|
|
from app.services.video_upscale.snapshot_service import parse_video_upscale_snapshot
|
|
from app.services.video_upscale.volc_service import (
|
|
VolcMediaKitError,
|
|
is_remote_input_access_error,
|
|
query_task,
|
|
submit_video_enhance,
|
|
)
|
|
from app.utils.id_gen import generate_id
|
|
|
|
|
|
|
|
ExecutionGuard = Callable[[], Awaitable[None]]
|
|
|
|
|
|
async def _ensure_guard(execution_guard: ExecutionGuard | None) -> None:
|
|
if execution_guard is not None:
|
|
await execution_guard()
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _aware(value: datetime | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def _date_dir(owner: VideoUpscaleOwner) -> str:
|
|
fixed = str(getattr(owner, "download_storage_date_dir", None) or "").strip().strip("/")
|
|
if fixed:
|
|
return fixed
|
|
created = _aware(owner.created_at) or _now()
|
|
return created.strftime("%Y/%m/%d")
|
|
|
|
|
|
def _final_video_path(owner: VideoUpscaleOwner) -> tuple[str, str]:
|
|
date_dir = _date_dir(owner)
|
|
path = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir, f"{owner.id}.mp4")
|
|
url = f"/generate/videos/{date_dir}/{owner.id}.mp4"
|
|
return path, url
|
|
|
|
|
|
def _snapshot(owner: VideoUpscaleOwner) -> dict[str, Any]:
|
|
data = parse_video_upscale_snapshot(owner.video_upscale_snapshot_json)
|
|
if not data:
|
|
raise RuntimeError("任务缺少视频超分快照")
|
|
return data
|
|
|
|
|
|
|
|
def _sanitize_provider_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
data = dict(payload or {})
|
|
value = data.get("video_url")
|
|
if isinstance(value, str) and value.startswith(("http://", "https://")):
|
|
parts = urlsplit(value)
|
|
data["video_url"] = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
return data
|
|
|
|
|
|
def _processor(snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
value = snapshot.get("processor")
|
|
if not isinstance(value, dict):
|
|
raise RuntimeError("超分快照缺少处理器参数")
|
|
return value
|
|
|
|
|
|
def _max_failures(snapshot: dict[str, Any]) -> int:
|
|
return max(1, int(_processor(snapshot).get("max_attempts") or 3))
|
|
|
|
|
|
def _retry_at(failure_count: int) -> datetime:
|
|
base = max(1, int(settings.VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS or 60))
|
|
multiplier = min(max(1, failure_count), 10)
|
|
return _now() + timedelta(seconds=base * multiplier)
|
|
|
|
|
|
def _countdown(value: datetime | None) -> int:
|
|
target = _aware(value)
|
|
if not target:
|
|
return 1
|
|
return max(1, math.ceil((target - _now()).total_seconds()))
|
|
|
|
|
|
def _lease_until(seconds: int | None = None) -> datetime:
|
|
return _now() + timedelta(seconds=max(60, int(seconds or settings.VIDEO_UPSCALE_TASK_LEASE_SECONDS or 1800)))
|
|
|
|
|
|
def _safe_apply_async(
|
|
celery_task: Any,
|
|
*,
|
|
args: list[Any],
|
|
queue: str,
|
|
task: VideoUpscaleOwner | None,
|
|
upscale_task: VideoUpscaleTask | None,
|
|
action: str,
|
|
countdown: int | None = None,
|
|
kwargs: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
options: dict[str, Any] = {"args": args, "queue": queue}
|
|
if countdown is not None:
|
|
options["countdown"] = max(0, int(countdown))
|
|
if kwargs:
|
|
options["kwargs"] = kwargs
|
|
try:
|
|
celery_task.apply_async(**options)
|
|
return True
|
|
except Exception as exc:
|
|
log_video_upscale_event(
|
|
event_type="upscale_queue_enqueue_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale_task,
|
|
message=str(exc),
|
|
detail={"action": action, "queue": queue, "countdown": countdown},
|
|
error=str(exc),
|
|
)
|
|
return False
|
|
|
|
|
|
async def _load_pair(
|
|
db: AsyncSession,
|
|
upscale_task_id: str,
|
|
*,
|
|
for_update: bool = True,
|
|
) -> tuple[VideoUpscaleTask | None, VideoUpscaleOwner | None]:
|
|
query = select(VideoUpscaleTask).where(VideoUpscaleTask.id == upscale_task_id)
|
|
if for_update:
|
|
await apply_short_lock_timeout(db)
|
|
query = query.with_for_update()
|
|
result = await db.execute(query.limit(1))
|
|
upscale = result.scalar_one_or_none()
|
|
if not upscale:
|
|
return None, None
|
|
owner = await load_upscale_owner(db, upscale, for_update=for_update)
|
|
return upscale, owner
|
|
|
|
|
|
async def _persist_provider_error_payload(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
payload: dict[str, Any] | None,
|
|
) -> None:
|
|
if not payload:
|
|
return
|
|
upscale, _owner = await _load_pair(db, upscale_task_id, for_update=True)
|
|
if not upscale:
|
|
return
|
|
upscale.provider_response_json = json.dumps(payload, ensure_ascii=False, default=str)
|
|
await db.commit()
|
|
|
|
|
|
async def prepare_video_upscale_task(
|
|
db: AsyncSession,
|
|
*,
|
|
task: ChatGenerationTask | None = None,
|
|
generation_record: GenerationRecord | None = None,
|
|
source_local_path: str,
|
|
source_file_size_bytes: int,
|
|
source_remote_url: str | None = None,
|
|
source_info: Any | None = None,
|
|
) -> VideoUpscaleTask:
|
|
owner: VideoUpscaleOwner | None = task or generation_record
|
|
if owner is None:
|
|
raise RuntimeError("缺少视频超分所有者")
|
|
if owner.gen_type != GenerationType.VIDEO.value or not bool(owner.video_upscale_enabled_snapshot):
|
|
raise RuntimeError("当前任务未启用视频超分快照")
|
|
snapshot = _snapshot(owner)
|
|
# ffprobe should run before the owner row is locked by the caller.
|
|
# Keep this fallback for non-generation callers that do not pass probe data.
|
|
source_info = source_info or await probe_video(source_local_path)
|
|
remote_url = str(source_remote_url or getattr(owner, "remote_result_url", None) or "").strip() or None
|
|
signed_at, expires_at = parse_tos_signed_url_expiry(remote_url)
|
|
|
|
owner_filter = (
|
|
VideoUpscaleTask.chat_generation_task_id == owner.id
|
|
if isinstance(owner, ChatGenerationTask)
|
|
else VideoUpscaleTask.generation_record_id == owner.id
|
|
)
|
|
await apply_short_lock_timeout(db)
|
|
result = await db.execute(
|
|
select(VideoUpscaleTask).where(owner_filter).with_for_update().limit(1)
|
|
)
|
|
upscale = result.scalar_one_or_none()
|
|
if upscale is None:
|
|
upscale = VideoUpscaleTask(
|
|
id=generate_id(),
|
|
chat_generation_task_id=owner.id if isinstance(owner, ChatGenerationTask) else None,
|
|
generation_record_id=owner.id if isinstance(owner, GenerationRecord) else None,
|
|
processor_key=str(snapshot.get("processor_key") or ""),
|
|
target_width=int(snapshot.get("target_width") or 0),
|
|
target_height=int(snapshot.get("target_height") or 0),
|
|
)
|
|
db.add(upscale)
|
|
upscale.status = VideoUpscaleTaskStatus.PENDING.value
|
|
upscale.stage = VideoUpscaleStage.SOURCE_READY.value
|
|
upscale.source_local_path = source_local_path
|
|
upscale.source_file_size_bytes = int(source_file_size_bytes or safe_file_size(source_local_path))
|
|
upscale.source_width = source_info.width
|
|
upscale.source_height = source_info.height
|
|
upscale.source_duration_seconds = float(source_info.duration_seconds)
|
|
upscale.source_deleted_at = None
|
|
upscale.source_delete_error = None
|
|
upscale.source_remote_url = remote_url
|
|
upscale.source_remote_url_signed_at = signed_at
|
|
upscale.source_remote_url_expires_at = expires_at
|
|
upscale.source_remote_url_probe_status = VideoUpscaleProbeStatus.NOT_CHECKED.value
|
|
upscale.last_error = None
|
|
upscale.next_retry_at = None
|
|
upscale.lease_token = None
|
|
upscale.lease_until = None
|
|
set_owner_stage(owner, upscale_stage_value(owner, ChatGenerationPipelineStage.UPSCALE_QUEUED))
|
|
await db.flush()
|
|
log_video_upscale_event(
|
|
event_type="upscale_source_download_success",
|
|
task=owner,
|
|
upscale_task=upscale,
|
|
detail={
|
|
"source_local_path": source_local_path,
|
|
"source_width": source_info.width,
|
|
"source_height": source_info.height,
|
|
"source_duration_seconds": source_info.duration_seconds,
|
|
},
|
|
)
|
|
return upscale
|
|
|
|
|
|
async def enqueue_upscale_task(db: AsyncSession, *, upscale: VideoUpscaleTask, reason: str) -> str:
|
|
from app.tasks.video_upscale_tasks import execute_local, submit_remote
|
|
|
|
upscale_id = str(upscale.id)
|
|
processor_key = str(upscale.processor_key)
|
|
celery_id = f"upscale:{upscale_id}:{uuid.uuid4().hex[:16]}"
|
|
upscale.celery_task_id = celery_id
|
|
upscale.status = VideoUpscaleTaskStatus.PENDING.value
|
|
upscale.stage = VideoUpscaleStage.QUEUED.value
|
|
upscale.next_retry_at = None
|
|
log_snapshot = SimpleNamespace(
|
|
id=upscale_id,
|
|
chat_generation_task_id=(
|
|
str(upscale.chat_generation_task_id)
|
|
if upscale.chat_generation_task_id
|
|
else None
|
|
),
|
|
generation_record_id=(
|
|
str(upscale.generation_record_id)
|
|
if upscale.generation_record_id
|
|
else None
|
|
),
|
|
processor_key=processor_key,
|
|
status=VideoUpscaleTaskStatus.PENDING.value,
|
|
stage=VideoUpscaleStage.QUEUED.value,
|
|
attempt_count=int(upscale.attempt_count or 0),
|
|
failure_count=int(upscale.failure_count or 0),
|
|
provider_task_id=(str(upscale.provider_task_id) if upscale.provider_task_id else None),
|
|
input_source_type=upscale.input_source_type,
|
|
target_width=upscale.target_width,
|
|
target_height=upscale.target_height,
|
|
)
|
|
await db.commit()
|
|
|
|
try:
|
|
if processor_key in LOCAL_PROCESSOR_KEYS:
|
|
execute_local.apply_async(
|
|
args=[upscale_id],
|
|
queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_LOCAL.value,
|
|
task_id=celery_id,
|
|
)
|
|
elif processor_key in REMOTE_PROCESSOR_KEYS:
|
|
submit_remote.apply_async(
|
|
args=[upscale_id],
|
|
queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_REMOTE.value,
|
|
task_id=celery_id,
|
|
)
|
|
else:
|
|
raise RuntimeError(f"未注册的超分处理器: {processor_key}")
|
|
log_video_upscale_event(
|
|
event_type="upscale_task_enqueued",
|
|
upscale_task=log_snapshot,
|
|
detail={"reason": reason, "celery_task_id": celery_id, "processor_key": processor_key},
|
|
)
|
|
except Exception as exc:
|
|
# 数据库状态已经提交,不把队列瞬时异常误判为原视频下载失败或触发退款;
|
|
# gen_recovery 会扫描 pending/queued 任务并补投。
|
|
log_video_upscale_event(
|
|
event_type="upscale_task_enqueue_failed",
|
|
event_status="failed",
|
|
upscale_task=log_snapshot,
|
|
message=str(exc),
|
|
detail={"reason": reason, "celery_task_id": celery_id, "processor_key": processor_key},
|
|
error=str(exc),
|
|
)
|
|
return celery_id
|
|
|
|
|
|
async def _claim(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
stage: str,
|
|
chat_stage: str,
|
|
increment_attempt: bool,
|
|
execution_token: str,
|
|
lease_seconds: int | None = None,
|
|
) -> tuple[VideoUpscaleTask, VideoUpscaleOwner, dict[str, Any]] | None:
|
|
upscale, task = await _load_pair(db, upscale_task_id, for_update=True)
|
|
if not upscale or not task:
|
|
return None
|
|
if upscale.status in {VideoUpscaleTaskStatus.COMPLETED.value, VideoUpscaleTaskStatus.FAILED.value}:
|
|
return None
|
|
# 对于 API v3 任务(有 api_key_id 属性),即使所有者已完成也允许超分继续
|
|
if not hasattr(task, "api_key_id") and not owner_is_generating(task):
|
|
return None
|
|
lease_until = _aware(upscale.lease_until)
|
|
if lease_until and lease_until > _now() and upscale.status == VideoUpscaleTaskStatus.PROCESSING.value:
|
|
return None
|
|
|
|
snapshot = _snapshot(task)
|
|
upscale.status = VideoUpscaleTaskStatus.PROCESSING.value
|
|
upscale.stage = stage
|
|
upscale.lease_token = execution_token
|
|
upscale.lease_until = _lease_until(lease_seconds)
|
|
upscale.started_at = upscale.started_at or _now()
|
|
upscale.next_retry_at = None
|
|
if increment_attempt:
|
|
upscale.attempt_count = int(upscale.attempt_count or 0) + 1
|
|
set_owner_stage(task, upscale_stage_value(task, chat_stage))
|
|
await db.commit()
|
|
return upscale, task, snapshot
|
|
|
|
|
|
async def _load_owned_pair(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
execution_token: str,
|
|
for_update: bool = True,
|
|
) -> tuple[VideoUpscaleTask, VideoUpscaleOwner]:
|
|
upscale, task = await _load_pair(db, upscale_task_id, for_update=for_update)
|
|
if not upscale or not task:
|
|
raise RedisExecutionLockLost(f"超分任务或所属任务不存在: {upscale_task_id}")
|
|
if str(upscale.lease_token or "") != str(execution_token):
|
|
raise RedisExecutionLockLost(f"超分数据库执行租约已失效: {upscale_task_id}")
|
|
return upscale, task
|
|
|
|
|
|
async def _final_fail(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
error_message: str,
|
|
) -> None:
|
|
upscale, task = await _load_pair(db, upscale_task_id, for_update=True)
|
|
if not upscale or not task:
|
|
return
|
|
if upscale.status == VideoUpscaleTaskStatus.COMPLETED.value:
|
|
return
|
|
now = _now()
|
|
upscale.status = VideoUpscaleTaskStatus.FAILED.value
|
|
upscale.stage = VideoUpscaleStage.FAILED.value
|
|
upscale.failed_at = now
|
|
upscale.last_error = error_message
|
|
upscale.next_retry_at = None
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
await mark_owner_upscale_failed(db, task, error_message=error_message)
|
|
await db.commit()
|
|
await notify_owner_finished(db, task)
|
|
log_video_upscale_event(
|
|
event_type="upscale_retry_exhausted",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
message=error_message,
|
|
detail={
|
|
"refund_policy": "no_refund",
|
|
"source_retained": True,
|
|
"manual_recovery_available": True,
|
|
"failure_count": upscale.failure_count,
|
|
},
|
|
error=error_message,
|
|
)
|
|
|
|
|
|
async def _schedule_retry(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
error_message: str,
|
|
retry_action: str,
|
|
retryable: bool = True,
|
|
) -> None:
|
|
from app.tasks.video_upscale_tasks import download_remote_result, execute_local, finalize, poll_remote, submit_remote
|
|
|
|
upscale, task = await _load_pair(db, upscale_task_id, for_update=True)
|
|
if not upscale or not task:
|
|
return
|
|
snapshot = _snapshot(task)
|
|
upscale.failure_count = int(upscale.failure_count or 0) + 1
|
|
max_failures = _max_failures(snapshot)
|
|
if not retryable or upscale.failure_count >= max_failures:
|
|
await db.commit()
|
|
await _final_fail(db, upscale_task_id=upscale_task_id, error_message=error_message)
|
|
return
|
|
|
|
next_retry_at = _retry_at(upscale.failure_count)
|
|
upscale.status = VideoUpscaleTaskStatus.RETRY_WAITING.value
|
|
upscale.stage = VideoUpscaleStage.RETRY_WAITING.value
|
|
upscale.last_error = error_message
|
|
upscale.next_retry_at = next_retry_at
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
set_owner_stage(task, upscale_stage_value(task, ChatGenerationPipelineStage.UPSCALE_RETRY_WAITING))
|
|
await db.commit()
|
|
|
|
task_map = {
|
|
"local": (execute_local, settings.VIDEO_UPSCALE_LOCAL_QUEUE),
|
|
"submit": (submit_remote, settings.VIDEO_UPSCALE_REMOTE_QUEUE),
|
|
"poll": (poll_remote, settings.VIDEO_UPSCALE_REMOTE_QUEUE),
|
|
"download": (download_remote_result, settings.VIDEO_UPSCALE_REMOTE_QUEUE),
|
|
"finalize": (finalize, settings.VIDEO_UPSCALE_LOCAL_QUEUE),
|
|
}
|
|
celery_task, queue = task_map[retry_action]
|
|
enqueue_error: str | None = None
|
|
try:
|
|
celery_task.apply_async(args=[upscale_task_id], countdown=_countdown(next_retry_at), queue=queue)
|
|
except Exception as exc:
|
|
enqueue_error = str(exc)
|
|
log_video_upscale_event(
|
|
event_type="upscale_retry_scheduled" if enqueue_error is None else "upscale_retry_enqueue_failed",
|
|
event_status="retry_waiting" if enqueue_error is None else "failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
message=error_message,
|
|
detail={
|
|
"retry_action": retry_action,
|
|
"failure_count": upscale.failure_count,
|
|
"max_failures": max_failures,
|
|
"next_retry_at": next_retry_at,
|
|
"enqueue_error": enqueue_error,
|
|
},
|
|
error=enqueue_error or error_message,
|
|
)
|
|
|
|
|
|
async def _queue_finalize(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
final_path: str,
|
|
reason: str,
|
|
execution_token: str,
|
|
execution_guard: ExecutionGuard | None = None,
|
|
) -> None:
|
|
await _ensure_guard(execution_guard)
|
|
upscale, task = await _load_owned_pair(
|
|
db, upscale_task_id=upscale_task_id, execution_token=execution_token, for_update=True
|
|
)
|
|
if not upscale or not task:
|
|
return
|
|
if not is_valid_file(final_path):
|
|
raise RuntimeError(f"超分最终视频不存在或为空: {final_path}")
|
|
upscale.final_local_path = final_path
|
|
upscale.status = VideoUpscaleTaskStatus.PROCESSING.value
|
|
upscale.stage = VideoUpscaleStage.FINALIZING.value
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
upscale.next_retry_at = None
|
|
set_owner_stage(task, upscale_stage_value(task, ChatGenerationPipelineStage.UPSCALE_FINALIZING))
|
|
await db.commit()
|
|
|
|
from app.tasks.video_upscale_tasks import finalize
|
|
|
|
_safe_apply_async(
|
|
finalize,
|
|
args=[upscale_task_id],
|
|
queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE,
|
|
task=task,
|
|
upscale_task=upscale,
|
|
action="finalize",
|
|
countdown=max(1, int(settings.VIDEO_UPSCALE_STAGE_HANDOFF_DELAY_SECONDS or 2)),
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_finalize_enqueued",
|
|
event_status="queued",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"reason": reason, "final_local_path": final_path},
|
|
)
|
|
|
|
|
|
async def _finalize_success(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
final_path: str,
|
|
execution_token: str,
|
|
execution_guard: ExecutionGuard | None = None,
|
|
) -> None:
|
|
await _ensure_guard(execution_guard)
|
|
upscale, task = await _load_owned_pair(
|
|
db, upscale_task_id=upscale_task_id, execution_token=execution_token, for_update=False
|
|
)
|
|
if not upscale or not task:
|
|
return
|
|
snapshot = _snapshot(task)
|
|
info = await probe_video(final_path)
|
|
expected_width = int(snapshot.get("target_width") or upscale.target_width or 0)
|
|
expected_height = int(snapshot.get("target_height") or upscale.target_height or 0)
|
|
processor_key = str(snapshot.get("processor_key") or upscale.processor_key or "")
|
|
if processor_key in LOCAL_PROCESSOR_KEYS:
|
|
if abs(info.width - expected_width) > 2 or abs(info.height - expected_height) > 2:
|
|
raise RuntimeError(
|
|
f"本地超分最终视频尺寸不符合快照: 实际 {info.width}x{info.height},预期 {expected_width}x{expected_height}"
|
|
)
|
|
else:
|
|
expected_short_edge = int(snapshot.get("target_short_edge_pixels") or min(expected_width, expected_height))
|
|
actual_short_edge = min(info.width, info.height)
|
|
short_edge_tolerance = max(4, int(round(expected_short_edge * 0.03)))
|
|
if abs(actual_short_edge - expected_short_edge) > short_edge_tolerance:
|
|
raise RuntimeError(
|
|
f"火山超分最终视频短边不符合目标档位: 实际 {actual_short_edge},目标 {expected_short_edge}"
|
|
)
|
|
if expected_width <= 0 or expected_height <= 0 or info.width <= 0 or info.height <= 0:
|
|
raise RuntimeError("火山超分最终视频宽高无效")
|
|
expected_ratio = expected_width / expected_height
|
|
actual_ratio = info.width / info.height
|
|
ratio_error = abs(actual_ratio - expected_ratio) / expected_ratio
|
|
if ratio_error > 0.03:
|
|
raise RuntimeError(
|
|
f"火山超分最终视频比例异常: 实际 {info.width}:{info.height},预期约 {expected_width}:{expected_height}"
|
|
)
|
|
try:
|
|
source_duration = float(upscale.source_duration_seconds or 0)
|
|
except (TypeError, ValueError):
|
|
source_duration = 0.0
|
|
if source_duration > 0 and info.duration_seconds > 0:
|
|
duration_tolerance = max(1.0, source_duration * 0.03)
|
|
if abs(info.duration_seconds - source_duration) > duration_tolerance:
|
|
raise RuntimeError(
|
|
f"超分最终视频时长异常: 源视频 {source_duration:.3f}s,最终视频 {info.duration_seconds:.3f}s"
|
|
)
|
|
|
|
date_dir = _date_dir(task)
|
|
cover_url, cover_path = await async_create_video_cover_for_local_video(
|
|
record_id=task.id,
|
|
video_path=final_path,
|
|
date_dir=date_dir,
|
|
log_prefix=f"超分最终视频封面生成 task_id={task.id}",
|
|
)
|
|
if not cover_url or not cover_path:
|
|
raise RuntimeError("超分最终视频封面生成失败")
|
|
|
|
await _ensure_guard(execution_guard)
|
|
upscale, task = await _load_owned_pair(
|
|
db, upscale_task_id=upscale_task_id, execution_token=execution_token, for_update=True
|
|
)
|
|
if not upscale or not task:
|
|
return
|
|
if owner_is_completed(task) and task.video_url:
|
|
return
|
|
|
|
final_url = f"/generate/videos/{date_dir}/{task.id}.mp4"
|
|
now = _now()
|
|
task.video_url = final_url
|
|
task.video_cover_url = cover_url
|
|
if isinstance(task, ChatGenerationTask):
|
|
task.status = ChatGenerationTaskStatus.COMPLETED.value
|
|
task.pipeline_stage = ChatGenerationPipelineStage.DONE.value
|
|
else:
|
|
task.status = "completed"
|
|
task.pipeline_stage = "done"
|
|
task.generated_at = now
|
|
task.error_message = None
|
|
if isinstance(task, ChatGenerationTask):
|
|
task.retry_count = int(task.manual_retry_count or 0)
|
|
|
|
upscale.status = VideoUpscaleTaskStatus.COMPLETED.value
|
|
upscale.stage = VideoUpscaleStage.COMPLETED.value
|
|
upscale.completed_at = now
|
|
upscale.final_local_path = final_path
|
|
upscale.final_resource_url = final_url
|
|
upscale.final_file_size_bytes = safe_file_size(final_path)
|
|
upscale.effective_target_width = info.width
|
|
upscale.effective_target_height = info.height
|
|
upscale.last_error = None
|
|
upscale.next_retry_at = None
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
|
|
if isinstance(task, ChatGenerationTask):
|
|
await record_chat_task_generated_resource(
|
|
db,
|
|
task,
|
|
resource_url=final_url,
|
|
storage_path=final_path,
|
|
file_size_bytes=upscale.final_file_size_bytes,
|
|
remote_url=upscale.source_remote_url,
|
|
generated_at=now,
|
|
)
|
|
await sync_chat_generation_task_media_token_snapshot(db, task)
|
|
else:
|
|
await record_generation_record_generated_resource(
|
|
db,
|
|
task,
|
|
resource_url=final_url,
|
|
storage_path=final_path,
|
|
file_size_bytes=upscale.final_file_size_bytes,
|
|
remote_url=upscale.source_remote_url,
|
|
generated_at=now,
|
|
)
|
|
await sync_generation_record_media_token_snapshot(db, task)
|
|
|
|
source_path = str(upscale.source_local_path or "")
|
|
delete_source_after_success = bool(snapshot.get("delete_source_after_success", True))
|
|
if not delete_source_after_success:
|
|
upscale.source_delete_error = VIDEO_UPSCALE_SOURCE_RETAINED_MARKER
|
|
await db.commit()
|
|
if isinstance(task, ChatGenerationTask):
|
|
await notify_owner_finished(db, task)
|
|
if delete_source_after_success and source_path and os.path.abspath(source_path) != os.path.abspath(final_path):
|
|
removed = safe_remove(source_path)
|
|
cleanup_error = None if removed else f"源视频删除失败: {source_path}"
|
|
await apply_short_lock_timeout(db)
|
|
cleanup_result = await db.execute(
|
|
select(VideoUpscaleTask).where(VideoUpscaleTask.id == upscale_task_id).with_for_update().limit(1)
|
|
)
|
|
cleanup_task = cleanup_result.scalar_one_or_none()
|
|
if cleanup_task:
|
|
cleanup_task.source_deleted_at = _now() if removed else None
|
|
cleanup_task.source_delete_error = cleanup_error
|
|
await db.commit()
|
|
if removed:
|
|
log_video_upscale_event(
|
|
event_type="upscale_source_cleanup_success",
|
|
task=task,
|
|
upscale_task=cleanup_task or upscale,
|
|
detail={"source_local_path": source_path, "delete_source_after_success": True},
|
|
)
|
|
else:
|
|
log_video_upscale_event(
|
|
event_type="upscale_source_cleanup_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=cleanup_task or upscale,
|
|
message="超分完成后源视频删除失败",
|
|
detail={"source_local_path": source_path, "delete_source_after_success": True},
|
|
error="source_cleanup_failed",
|
|
)
|
|
elif not delete_source_after_success:
|
|
log_video_upscale_event(
|
|
event_type="upscale_source_retained_by_snapshot",
|
|
event_status="success",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={
|
|
"source_local_path": source_path,
|
|
"delete_source_after_success": False,
|
|
"source_retained_by_config": True,
|
|
},
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_success",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={
|
|
"final_url": final_url,
|
|
"cover_url": cover_url,
|
|
"width": info.width,
|
|
"height": info.height,
|
|
"file_size_bytes": upscale.final_file_size_bytes,
|
|
},
|
|
)
|
|
|
|
|
|
async def run_local_upscale(
|
|
db: AsyncSession, upscale_task_id: str, *, execution_token: str, execution_guard: ExecutionGuard | None = None
|
|
) -> None:
|
|
claimed = await _claim(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
stage=VideoUpscaleStage.LOCAL_PROCESSING.value,
|
|
chat_stage=ChatGenerationPipelineStage.UPSCALE_PROCESSING.value,
|
|
increment_attempt=True,
|
|
execution_token=execution_token,
|
|
lease_seconds=int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600) + 300,
|
|
)
|
|
if not claimed:
|
|
return
|
|
upscale, task, snapshot = claimed
|
|
processor = _processor(snapshot)
|
|
final_path, _ = _final_video_path(task)
|
|
log_video_upscale_event(event_type="upscale_local_start", task=task, upscale_task=upscale)
|
|
try:
|
|
await execute_local_ffmpeg_crop(
|
|
source_path=str(upscale.source_local_path or ""),
|
|
final_path=final_path,
|
|
target_width=int(snapshot.get("target_width") or upscale.target_width),
|
|
target_height=int(snapshot.get("target_height") or upscale.target_height),
|
|
timeout_seconds=int(processor.get("timeout_seconds") or settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS),
|
|
execution_guard=execution_guard,
|
|
)
|
|
await _ensure_guard(execution_guard)
|
|
log_video_upscale_event(
|
|
event_type="upscale_local_success",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"final_local_path": final_path},
|
|
)
|
|
except RedisExecutionLockError:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"本地 FFmpeg 超分失败: {exc}",
|
|
retry_action="local",
|
|
retryable=True,
|
|
)
|
|
return
|
|
|
|
try:
|
|
await _queue_finalize(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
final_path=final_path,
|
|
reason="local_upscale_completed",
|
|
execution_token=execution_token,
|
|
execution_guard=execution_guard,
|
|
)
|
|
except RedisExecutionLockError:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"本地超分结果进入最终化失败: {exc}",
|
|
retry_action="finalize",
|
|
retryable=True,
|
|
)
|
|
|
|
|
|
async def _select_remote_input(db: AsyncSession, upscale: VideoUpscaleTask, processor: dict[str, Any]) -> tuple[str, str]:
|
|
remote_url = str(upscale.source_remote_url or "").strip()
|
|
expires_at = _aware(upscale.source_remote_url_expires_at)
|
|
now = _now()
|
|
threshold = max(0, int(settings.VIDEO_UPSCALE_REMOTE_URL_PROBE_THRESHOLD_SECONDS or 600))
|
|
use_remote = False
|
|
probe_status = VideoUpscaleProbeStatus.NOT_CHECKED.value
|
|
|
|
# 远程源地址已被火山明确判定不可访问后,后续同一处理器重提必须固定使用本地签名地址。
|
|
if int(upscale.input_source_fallback_count or 0) > 0:
|
|
signed_url = build_local_source_signed_url(
|
|
str(upscale.source_local_path or ""),
|
|
int(processor.get("source_url_expire_seconds") or settings.VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS),
|
|
)
|
|
return signed_url, VideoUpscaleInputSourceType.LOCAL_SIGNED.value
|
|
|
|
if remote_url:
|
|
if expires_at is not None:
|
|
remaining = (expires_at - now).total_seconds()
|
|
if remaining > threshold:
|
|
use_remote = True
|
|
elif remaining > 0:
|
|
use_remote = await probe_remote_url(remote_url)
|
|
probe_status = VideoUpscaleProbeStatus.SUCCESS.value if use_remote else VideoUpscaleProbeStatus.FAILED.value
|
|
else:
|
|
probe_status = VideoUpscaleProbeStatus.EXPIRED.value
|
|
else:
|
|
use_remote = await probe_remote_url(remote_url)
|
|
probe_status = VideoUpscaleProbeStatus.SUCCESS.value if use_remote else VideoUpscaleProbeStatus.UNPARSABLE.value
|
|
|
|
if probe_status != VideoUpscaleProbeStatus.NOT_CHECKED.value:
|
|
upscale.source_remote_url_last_probe_at = now
|
|
upscale.source_remote_url_probe_status = probe_status
|
|
await db.commit()
|
|
|
|
if use_remote:
|
|
return remote_url, VideoUpscaleInputSourceType.PROVIDER_REMOTE.value
|
|
signed_url = build_local_source_signed_url(
|
|
str(upscale.source_local_path or ""),
|
|
int(processor.get("source_url_expire_seconds") or settings.VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS),
|
|
)
|
|
return signed_url, VideoUpscaleInputSourceType.LOCAL_SIGNED.value
|
|
|
|
|
|
async def run_remote_submit(
|
|
db: AsyncSession, upscale_task_id: str, *, count_attempt: bool = True, execution_token: str,
|
|
execution_guard: ExecutionGuard | None = None,
|
|
) -> None:
|
|
claimed = await _claim(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
stage=VideoUpscaleStage.REMOTE_SUBMITTING.value,
|
|
chat_stage=ChatGenerationPipelineStage.UPSCALE_PROCESSING.value,
|
|
increment_attempt=count_attempt,
|
|
execution_token=execution_token,
|
|
lease_seconds=300,
|
|
)
|
|
if not claimed:
|
|
return
|
|
upscale, task, snapshot = claimed
|
|
processor = _processor(snapshot)
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_submit_start",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={
|
|
"processor_key": upscale.processor_key,
|
|
"target_resolution": snapshot.get("target_resolution"),
|
|
"target_width": snapshot.get("target_width"),
|
|
"target_height": snapshot.get("target_height"),
|
|
},
|
|
)
|
|
try:
|
|
source_width = int(upscale.source_width or 0)
|
|
source_height = int(upscale.source_height or 0)
|
|
source_short_edge = min(source_width, source_height)
|
|
source_long_edge = max(source_width, source_height)
|
|
if upscale.processor_key == "volc_large_model_v1":
|
|
if not (360 <= source_short_edge <= 1080 and 360 <= source_long_edge <= 1920):
|
|
raise VolcMediaKitError(
|
|
f"火山画质增强大模型输入尺寸不支持: {source_width}x{source_height},短边需 360-1080、长边需 360-1920",
|
|
code="UnsupportedInputResolution",
|
|
retryable=False,
|
|
)
|
|
source_info = await probe_video(str(upscale.source_local_path or ""))
|
|
hdr_transfers = {"smpte2084", "arib-std-b67"}
|
|
if str(source_info.color_transfer or "").strip().lower() in hdr_transfers:
|
|
raise VolcMediaKitError(
|
|
f"火山画质增强大模型仅支持 SDR 视频,当前 color_transfer={source_info.color_transfer}",
|
|
code="UnsupportedHdrInput",
|
|
retryable=False,
|
|
)
|
|
elif upscale.processor_key in {"volc_standard_v1", "volc_professional_v1"}:
|
|
if source_short_edge > 1440 or source_long_edge > 2560:
|
|
raise VolcMediaKitError(
|
|
f"火山标准版/专业版输入视频最高支持 2K,当前 {source_width}x{source_height}",
|
|
code="UnsupportedInputResolution",
|
|
retryable=False,
|
|
)
|
|
source_url, source_type = await _select_remote_input(db, upscale, processor)
|
|
submit_result = await submit_video_enhance(
|
|
processor_key=upscale.processor_key,
|
|
video_url=source_url,
|
|
target_resolution=str(snapshot.get("target_resolution") or task.resolution or ""),
|
|
target_width=int(snapshot.get("target_width") or upscale.target_width),
|
|
target_height=int(snapshot.get("target_height") or upscale.target_height),
|
|
processor=processor,
|
|
client_token=f"{upscale.id}-{int(upscale.attempt_count or 0)}-{int(upscale.input_source_fallback_count or 0)}",
|
|
)
|
|
await _ensure_guard(execution_guard)
|
|
upscale, task = await _load_owned_pair(
|
|
db, upscale_task_id=upscale_task_id, execution_token=execution_token, for_update=True
|
|
)
|
|
upscale.provider_task_id = submit_result.task_id
|
|
upscale.provider_submitted_at = _now()
|
|
upscale.provider_request_json = json.dumps(_sanitize_provider_payload(submit_result.request_payload), ensure_ascii=False, default=str)
|
|
upscale.provider_response_json = json.dumps(submit_result.response_payload, ensure_ascii=False, default=str)
|
|
upscale.input_source_type = source_type
|
|
upscale.status = VideoUpscaleTaskStatus.PROCESSING.value
|
|
upscale.stage = VideoUpscaleStage.REMOTE_POLLING.value
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
set_owner_stage(task, upscale_stage_value(task, ChatGenerationPipelineStage.UPSCALE_POLLING))
|
|
await db.commit()
|
|
|
|
from app.tasks.video_upscale_tasks import poll_remote
|
|
_safe_apply_async(
|
|
poll_remote,
|
|
args=[upscale_task_id],
|
|
countdown=max(5, int(processor.get("poll_interval_seconds") or 30)),
|
|
queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE,
|
|
task=task,
|
|
upscale_task=upscale,
|
|
action="poll_after_submit",
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_submit_success",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
remote_request_id=submit_result.request_id,
|
|
detail={"provider_task_id": submit_result.task_id, "input_source_type": source_type},
|
|
)
|
|
except RedisExecutionLockError:
|
|
await db.rollback()
|
|
raise
|
|
except VolcMediaKitError as exc:
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_submit_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
remote_request_id=exc.request_id,
|
|
message=str(exc),
|
|
detail=exc.log_detail(),
|
|
error=str(exc),
|
|
)
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _persist_provider_error_payload(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
payload=exc.response_payload,
|
|
)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分提交失败: {exc}",
|
|
retry_action="submit",
|
|
retryable=exc.retryable,
|
|
)
|
|
except Exception as exc:
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_submit_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
message=str(exc),
|
|
error=str(exc),
|
|
)
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分提交失败: {exc}",
|
|
retry_action="submit",
|
|
)
|
|
|
|
|
|
async def run_remote_poll(
|
|
db: AsyncSession, upscale_task_id: str, *, execution_token: str, execution_guard: ExecutionGuard | None = None
|
|
) -> None:
|
|
claimed = await _claim(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
stage=VideoUpscaleStage.REMOTE_POLLING.value,
|
|
chat_stage=ChatGenerationPipelineStage.UPSCALE_POLLING.value,
|
|
increment_attempt=False,
|
|
execution_token=execution_token,
|
|
lease_seconds=300,
|
|
)
|
|
if not claimed:
|
|
return
|
|
upscale, task, snapshot = claimed
|
|
processor = _processor(snapshot)
|
|
provider_task_id = str(upscale.provider_task_id or "").strip()
|
|
if not provider_task_id:
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message="火山超分任务缺少 provider_task_id",
|
|
retry_action="submit",
|
|
retryable=True,
|
|
)
|
|
return
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_poll_start",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"provider_task_id": provider_task_id},
|
|
)
|
|
try:
|
|
query_result = await query_task(
|
|
provider_task_id,
|
|
request_timeout_seconds=int(processor.get("request_timeout_seconds") or 30),
|
|
)
|
|
await _ensure_guard(execution_guard)
|
|
upscale, task = await _load_owned_pair(
|
|
db, upscale_task_id=upscale_task_id, execution_token=execution_token, for_update=True
|
|
)
|
|
upscale.provider_response_json = json.dumps(query_result.response_payload, ensure_ascii=False, default=str)
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
|
|
if query_result.status == "running":
|
|
provider_submitted_at = _aware(upscale.provider_submitted_at) or _aware(upscale.started_at) or _now()
|
|
poll_timeout = max(60, int(processor.get("poll_timeout_seconds") or 7200))
|
|
if (_now() - provider_submitted_at).total_seconds() > poll_timeout:
|
|
await db.commit()
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分轮询超过 {poll_timeout} 秒",
|
|
retry_action="submit",
|
|
retryable=True,
|
|
)
|
|
return
|
|
await db.commit()
|
|
from app.tasks.video_upscale_tasks import poll_remote
|
|
_safe_apply_async(
|
|
poll_remote,
|
|
args=[upscale_task_id],
|
|
countdown=max(5, int(processor.get("poll_interval_seconds") or 30)),
|
|
queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE,
|
|
task=task,
|
|
upscale_task=upscale,
|
|
action="poll_running",
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_poll_running",
|
|
event_status="running",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
remote_request_id=query_result.request_id,
|
|
)
|
|
return
|
|
|
|
if query_result.status == "failed":
|
|
if (
|
|
upscale.input_source_type == VideoUpscaleInputSourceType.PROVIDER_REMOTE.value
|
|
and int(upscale.input_source_fallback_count or 0) < 1
|
|
and is_remote_input_access_error(query_result.error)
|
|
):
|
|
upscale.input_source_fallback_count = int(upscale.input_source_fallback_count or 0) + 1
|
|
upscale.provider_task_id = None
|
|
upscale.provider_submitted_at = None
|
|
upscale.provider_output_url = None
|
|
upscale.status = VideoUpscaleTaskStatus.PENDING.value
|
|
upscale.stage = VideoUpscaleStage.QUEUED.value
|
|
await db.commit()
|
|
from app.tasks.video_upscale_tasks import submit_remote
|
|
_safe_apply_async(
|
|
submit_remote,
|
|
args=[upscale_task_id],
|
|
kwargs={"count_attempt": False},
|
|
queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE,
|
|
task=task,
|
|
upscale_task=upscale,
|
|
action="submit_local_source_fallback",
|
|
countdown=max(1, int(settings.VIDEO_UPSCALE_STAGE_HANDOFF_DELAY_SECONDS or 2)),
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_source_fallback_local",
|
|
event_status="retrying",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"provider_error": query_result.error},
|
|
)
|
|
return
|
|
error = query_result.error or {}
|
|
code = str(error.get("code") or "")
|
|
error_type = str(error.get("type") or "")
|
|
retryable = code not in {"InvalidParameter", "Unauthorized", "Forbidden", "NotFound"} and error_type not in {
|
|
"BadRequest",
|
|
"AuthError",
|
|
}
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_task_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
remote_request_id=query_result.request_id,
|
|
message=str(error.get("message") or "火山超分任务失败"),
|
|
detail={
|
|
"error_code": code,
|
|
"error_type": error_type,
|
|
"error_param": error.get("param"),
|
|
"provider_error": error,
|
|
"retryable": retryable,
|
|
},
|
|
error=str(error.get("message") or code),
|
|
)
|
|
await db.commit()
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分任务失败: {code} {error.get('message') or ''}".strip(),
|
|
retry_action="submit",
|
|
retryable=retryable,
|
|
)
|
|
return
|
|
|
|
result = query_result.result or {}
|
|
output_url = str(result.get("video_url") or "").strip()
|
|
if not output_url:
|
|
await db.commit()
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message="火山超分任务已完成但未返回 result.video_url",
|
|
retry_action="poll",
|
|
retryable=True,
|
|
)
|
|
return
|
|
upscale.provider_output_url = output_url
|
|
upscale.provider_output_url_expires_at = (
|
|
datetime.fromtimestamp(query_result.expires_at, tz=timezone.utc) if query_result.expires_at else None
|
|
)
|
|
upscale.status = VideoUpscaleTaskStatus.PROCESSING.value
|
|
upscale.stage = VideoUpscaleStage.RESULT_READY.value
|
|
set_owner_stage(task, upscale_stage_value(task, ChatGenerationPipelineStage.UPSCALE_DOWNLOADING))
|
|
await db.commit()
|
|
from app.tasks.video_upscale_tasks import download_remote_result
|
|
_safe_apply_async(
|
|
download_remote_result,
|
|
args=[upscale_task_id],
|
|
queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE,
|
|
task=task,
|
|
upscale_task=upscale,
|
|
action="download_remote_result",
|
|
countdown=max(1, int(settings.VIDEO_UPSCALE_STAGE_HANDOFF_DELAY_SECONDS or 2)),
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_poll_success",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
remote_request_id=query_result.request_id,
|
|
detail={"provider_output_url_expires_at": upscale.provider_output_url_expires_at},
|
|
)
|
|
except RedisExecutionLockError:
|
|
await db.rollback()
|
|
raise
|
|
except VolcMediaKitError as exc:
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_poll_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
remote_request_id=exc.request_id,
|
|
message=str(exc),
|
|
detail=exc.log_detail(),
|
|
error=str(exc),
|
|
)
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _persist_provider_error_payload(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
payload=exc.response_payload,
|
|
)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分查询失败: {exc}",
|
|
retry_action="poll",
|
|
retryable=exc.retryable,
|
|
)
|
|
except Exception as exc:
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_poll_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
message=str(exc),
|
|
error=str(exc),
|
|
)
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分查询失败: {exc}",
|
|
retry_action="poll",
|
|
)
|
|
|
|
|
|
async def run_finalize_upscale(
|
|
db: AsyncSession, upscale_task_id: str, *, execution_token: str, execution_guard: ExecutionGuard | None = None
|
|
) -> None:
|
|
claimed = await _claim(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
stage=VideoUpscaleStage.FINALIZING.value,
|
|
chat_stage=ChatGenerationPipelineStage.UPSCALE_FINALIZING.value,
|
|
increment_attempt=False,
|
|
execution_token=execution_token,
|
|
lease_seconds=max(300, int(settings.VIDEO_COVER_TIMEOUT_SECONDS or 15) + 300),
|
|
)
|
|
if not claimed:
|
|
return
|
|
upscale, task, _snapshot_data = claimed
|
|
final_path = str(upscale.final_local_path or "").strip()
|
|
if not final_path:
|
|
final_path, _ = _final_video_path(task)
|
|
log_video_upscale_event(
|
|
event_type="upscale_finalize_start",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"final_local_path": final_path},
|
|
)
|
|
try:
|
|
await _finalize_success(
|
|
db, upscale_task_id=upscale_task_id, final_path=final_path,
|
|
execution_token=execution_token, execution_guard=execution_guard,
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_finalize_success",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"final_local_path": final_path},
|
|
)
|
|
except RedisExecutionLockError:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
log_video_upscale_event(
|
|
event_type="upscale_finalize_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
message=str(exc),
|
|
detail={"final_local_path": final_path},
|
|
error=str(exc),
|
|
)
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"超分最终化失败: {exc}",
|
|
retry_action="finalize",
|
|
retryable=True,
|
|
)
|
|
|
|
|
|
async def run_remote_result_download(
|
|
db: AsyncSession, upscale_task_id: str, *, execution_token: str, execution_guard: ExecutionGuard | None = None
|
|
) -> None:
|
|
claimed = await _claim(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
stage=VideoUpscaleStage.RESULT_DOWNLOADING.value,
|
|
chat_stage=ChatGenerationPipelineStage.UPSCALE_DOWNLOADING.value,
|
|
increment_attempt=False,
|
|
execution_token=execution_token,
|
|
lease_seconds=int(settings.VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS or 600) + 120,
|
|
)
|
|
if not claimed:
|
|
return
|
|
upscale, task, snapshot = claimed
|
|
expires_at = _aware(upscale.provider_output_url_expires_at)
|
|
if upscale.provider_task_id and expires_at and (expires_at - _now()).total_seconds() < 2 * 3600:
|
|
upscale.status = VideoUpscaleTaskStatus.PROCESSING.value
|
|
upscale.stage = VideoUpscaleStage.REMOTE_POLLING.value
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
set_owner_stage(task, upscale_stage_value(task, ChatGenerationPipelineStage.UPSCALE_POLLING))
|
|
await db.commit()
|
|
from app.tasks.video_upscale_tasks import poll_remote
|
|
_safe_apply_async(
|
|
poll_remote,
|
|
args=[upscale_task_id],
|
|
queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE,
|
|
task=task,
|
|
upscale_task=upscale,
|
|
action="renew_remote_result",
|
|
countdown=max(1, int(settings.VIDEO_UPSCALE_STAGE_HANDOFF_DELAY_SECONDS or 2)),
|
|
)
|
|
log_video_upscale_event(
|
|
event_type="upscale_provider_result_renew_query",
|
|
event_status="queued",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"provider_output_url_expires_at": expires_at},
|
|
)
|
|
return
|
|
output_url = str(upscale.provider_output_url or "").strip()
|
|
if not output_url:
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message="火山超分结果下载缺少 provider_output_url",
|
|
retry_action="poll",
|
|
)
|
|
return
|
|
final_path, _ = _final_video_path(task)
|
|
log_video_upscale_event(
|
|
event_type="upscale_output_download_start",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"provider_output_url_expires_at": upscale.provider_output_url_expires_at},
|
|
)
|
|
try:
|
|
await download_video_to_path(
|
|
output_url,
|
|
final_path,
|
|
int(settings.VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS or 600),
|
|
execution_guard=execution_guard,
|
|
)
|
|
await _ensure_guard(execution_guard)
|
|
log_video_upscale_event(
|
|
event_type="upscale_output_download_success",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
detail={"final_local_path": final_path, "file_size_bytes": safe_file_size(final_path)},
|
|
)
|
|
except RedisExecutionLockError:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
log_video_upscale_event(
|
|
event_type="upscale_output_download_failed",
|
|
event_status="failed",
|
|
task=task,
|
|
upscale_task=upscale,
|
|
message=str(exc),
|
|
detail={"provider_output_url_expires_at": upscale.provider_output_url_expires_at},
|
|
error=str(exc),
|
|
)
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
expires_at = _aware(upscale.provider_output_url_expires_at)
|
|
action = "submit" if expires_at and expires_at <= _now() else "download"
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分结果下载失败: {exc}",
|
|
retry_action=action,
|
|
retryable=True,
|
|
)
|
|
return
|
|
|
|
try:
|
|
await _queue_finalize(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
final_path=final_path,
|
|
reason="remote_result_download_completed",
|
|
execution_token=execution_token,
|
|
execution_guard=execution_guard,
|
|
)
|
|
except RedisExecutionLockError:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
await _ensure_guard(execution_guard)
|
|
await _schedule_retry(
|
|
db,
|
|
upscale_task_id=upscale_task_id,
|
|
error_message=f"火山超分结果进入最终化失败: {exc}",
|
|
retry_action="finalize",
|
|
retryable=True,
|
|
)
|
|
|
|
|
|
async def recover_video_upscale_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
|
now = _now()
|
|
batch_size = max(1, int(settings.VIDEO_UPSCALE_RECOVERY_BATCH_SIZE or 50))
|
|
await apply_short_lock_timeout(db)
|
|
result = await db.execute(
|
|
select(VideoUpscaleTask)
|
|
.where(
|
|
or_(
|
|
VideoUpscaleTask.status == VideoUpscaleTaskStatus.PENDING.value,
|
|
and_(
|
|
VideoUpscaleTask.status == VideoUpscaleTaskStatus.PROCESSING.value,
|
|
or_(VideoUpscaleTask.lease_until.is_(None), VideoUpscaleTask.lease_until <= now),
|
|
),
|
|
and_(
|
|
VideoUpscaleTask.status == VideoUpscaleTaskStatus.RETRY_WAITING.value,
|
|
or_(VideoUpscaleTask.next_retry_at.is_(None), VideoUpscaleTask.next_retry_at <= now),
|
|
),
|
|
and_(
|
|
VideoUpscaleTask.status == VideoUpscaleTaskStatus.COMPLETED.value,
|
|
VideoUpscaleTask.source_local_path.is_not(None),
|
|
VideoUpscaleTask.source_deleted_at.is_(None),
|
|
or_(
|
|
VideoUpscaleTask.source_delete_error.is_(None),
|
|
VideoUpscaleTask.source_delete_error != VIDEO_UPSCALE_SOURCE_RETAINED_MARKER,
|
|
),
|
|
),
|
|
)
|
|
)
|
|
.order_by(VideoUpscaleTask.updated_at.asc())
|
|
.limit(batch_size)
|
|
.with_for_update(skip_locked=True)
|
|
)
|
|
tasks = list(result.scalars().all())
|
|
await db.commit()
|
|
|
|
from app.tasks.video_upscale_tasks import download_remote_result, execute_local, finalize, poll_remote, submit_remote
|
|
|
|
counts: dict[str, int] = {}
|
|
for item in tasks:
|
|
action = ""
|
|
try:
|
|
if item.status == VideoUpscaleTaskStatus.COMPLETED.value:
|
|
action = "cleanup"
|
|
cleanup_upscale, cleanup_chat_task = await _load_pair(db, str(item.id), for_update=True)
|
|
if not cleanup_upscale or not cleanup_chat_task:
|
|
continue
|
|
cleanup_snapshot = _snapshot(cleanup_chat_task)
|
|
if not bool(cleanup_snapshot.get("delete_source_after_success", True)):
|
|
cleanup_upscale.source_delete_error = VIDEO_UPSCALE_SOURCE_RETAINED_MARKER
|
|
await db.commit()
|
|
counts["cleanup_skipped_by_snapshot"] = counts.get("cleanup_skipped_by_snapshot", 0) + 1
|
|
log_video_upscale_event(
|
|
event_type="upscale_source_retained_by_snapshot",
|
|
event_status="success",
|
|
task=cleanup_chat_task,
|
|
upscale_task=cleanup_upscale,
|
|
detail={
|
|
"source_local_path": cleanup_upscale.source_local_path,
|
|
"recovery": True,
|
|
"delete_source_after_success": False,
|
|
},
|
|
)
|
|
continue
|
|
source_path = str(cleanup_upscale.source_local_path or "")
|
|
removed = safe_remove(source_path)
|
|
await apply_short_lock_timeout(db)
|
|
cleanup_result = await db.execute(
|
|
select(VideoUpscaleTask).where(VideoUpscaleTask.id == item.id).with_for_update().limit(1)
|
|
)
|
|
cleanup_task = cleanup_result.scalar_one_or_none()
|
|
if cleanup_task:
|
|
cleanup_task.source_deleted_at = _now() if removed else None
|
|
cleanup_task.source_delete_error = None if removed else f"源视频删除失败: {source_path}"
|
|
await db.commit()
|
|
counts["cleanup_success" if removed else "cleanup_failed"] = counts.get(
|
|
"cleanup_success" if removed else "cleanup_failed", 0
|
|
) + 1
|
|
log_video_upscale_event(
|
|
event_type="upscale_source_cleanup_success" if removed else "upscale_source_cleanup_failed",
|
|
event_status="success" if removed else "failed",
|
|
upscale_task=cleanup_task or item,
|
|
detail={"source_local_path": source_path, "recovery": True},
|
|
error=None if removed else "source_cleanup_failed",
|
|
)
|
|
continue
|
|
|
|
if item.stage == VideoUpscaleStage.FINALIZING.value and is_valid_file(item.final_local_path):
|
|
action = "finalize"
|
|
finalize.apply_async(args=[item.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
|
|
elif item.processor_key in LOCAL_PROCESSOR_KEYS:
|
|
action = "local"
|
|
execute_local.apply_async(args=[item.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
|
|
elif item.provider_output_url and item.stage in {
|
|
VideoUpscaleStage.RESULT_READY.value,
|
|
VideoUpscaleStage.RESULT_DOWNLOADING.value,
|
|
}:
|
|
output_expires_at = _aware(item.provider_output_url_expires_at)
|
|
if item.provider_task_id and output_expires_at and (output_expires_at - _now()).total_seconds() < 2 * 3600:
|
|
action = "poll"
|
|
poll_remote.apply_async(args=[item.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
|
|
else:
|
|
action = "download"
|
|
download_remote_result.apply_async(args=[item.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
|
|
elif item.provider_task_id:
|
|
action = "poll"
|
|
poll_remote.apply_async(args=[item.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
|
|
else:
|
|
action = "submit"
|
|
submit_remote.apply_async(args=[item.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
|
|
counts[action] = counts.get(action, 0) + 1
|
|
log_video_upscale_event(
|
|
event_type="upscale_recovery_enqueued",
|
|
upscale_task=item,
|
|
detail={"action": action},
|
|
)
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
counts["enqueue_failed"] = counts.get("enqueue_failed", 0) + 1
|
|
log_video_upscale_event(
|
|
event_type="upscale_recovery_enqueue_failed",
|
|
event_status="failed",
|
|
upscale_task=item,
|
|
message=str(exc),
|
|
error=str(exc),
|
|
)
|
|
return {"checked": len(tasks), "results": counts}
|
|
|
|
|
|
async def reset_failed_upscale_task_for_manual_retry(
|
|
db: AsyncSession,
|
|
*,
|
|
upscale_task_id: str,
|
|
force_resubmit: bool = False,
|
|
) -> VideoUpscaleTask:
|
|
upscale, task = await _load_pair(db, upscale_task_id, for_update=True)
|
|
if not upscale or not task:
|
|
raise RuntimeError(f"超分任务不存在: {upscale_task_id}")
|
|
if not is_valid_file(upscale.source_local_path):
|
|
raise RuntimeError(f"超分源视频不存在,无法人工恢复: {upscale.source_local_path}")
|
|
restore_owner_for_upscale_retry(task)
|
|
upscale.status = VideoUpscaleTaskStatus.PENDING.value
|
|
upscale.stage = VideoUpscaleStage.QUEUED.value
|
|
upscale.failure_count = 0
|
|
upscale.last_error = None
|
|
upscale.failed_at = None
|
|
upscale.next_retry_at = None
|
|
upscale.lease_until = None
|
|
upscale.lease_token = None
|
|
upscale.manual_retry_count = int(upscale.manual_retry_count or 0) + 1
|
|
if force_resubmit:
|
|
upscale.provider_task_id = None
|
|
upscale.provider_submitted_at = None
|
|
upscale.provider_output_url = None
|
|
upscale.provider_output_url_expires_at = None
|
|
await db.commit()
|
|
return upscale
|