完成视频复刻和优化
This commit is contained in:
@@ -700,7 +700,274 @@ def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None:
|
||||
return serialize_digest_history(task)
|
||||
|
||||
|
||||
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict:
|
||||
class _FileFromPath:
|
||||
def __init__(self, path: str, name: str):
|
||||
self.name = name
|
||||
self.size = Path(path).stat().st_size
|
||||
self._path = path
|
||||
|
||||
def chunks(self, chunk_size=1024 * 1024):
|
||||
with Path(self._path).open("rb") as handle:
|
||||
while True:
|
||||
data = handle.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
|
||||
def _store_raw_source(*, team, path: str, suffix: str) -> tuple[str, str]:
|
||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||
mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4")
|
||||
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}"
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
storage = TosStorage()
|
||||
with Path(path).open("rb") as fileobj:
|
||||
stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime)
|
||||
return stored.object_key, storage.public_url(object_key=stored.object_key)
|
||||
|
||||
|
||||
def _download_source_to_temp(object_key: str, suffix: str) -> str:
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||
storage = TosStorage()
|
||||
body = storage.client.get_object(Bucket=storage.bucket, Key=object_key)["Body"].read()
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=ext.lower(), delete=False)
|
||||
try:
|
||||
tmp.write(body)
|
||||
tmp.flush()
|
||||
finally:
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
|
||||
def serialize_digest_job(task) -> dict:
|
||||
from apps.ai.generation_errors import public_error_for_task
|
||||
from apps.ai.models import AITask
|
||||
|
||||
req = task.request_payload or {}
|
||||
resp = task.response_payload or {}
|
||||
prompt = str(resp.get("prompt") or resp.get("digest") or "").strip()
|
||||
inflight = task.status in {
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
}
|
||||
if inflight:
|
||||
job_status = "processing"
|
||||
elif task.status == AITask.Status.SUCCEEDED:
|
||||
job_status = "succeeded"
|
||||
else:
|
||||
job_status = "failed"
|
||||
duration = float(req.get("duration_seconds") or 0)
|
||||
file_name = str(req.get("file_name") or "") or "参考视频.mp4"
|
||||
public_error = public_error_for_task(task, operation="video_digest") if job_status == "failed" else None
|
||||
video_url = _media_url_from_payload(req, "video_url", "video_key", signed=True) or _media_url_from_payload(
|
||||
req, "source_url", "source_key", signed=True
|
||||
)
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"task_id": str(task.id),
|
||||
"status": job_status,
|
||||
"text": prompt,
|
||||
"prompt": prompt,
|
||||
"chars": len(prompt),
|
||||
"duration": round(duration, 1) if duration else 0,
|
||||
"shots": int(req.get("shot_count") or 0) or shot_count(prompt),
|
||||
"file_name": file_name,
|
||||
"title": str(req.get("title") or "").strip() or title_from_filename(file_name),
|
||||
"ratio": str(req.get("ratio") or ""),
|
||||
"width": int(req.get("width") or 0),
|
||||
"height": int(req.get("height") or 0),
|
||||
"cover_url": _media_url_from_payload(req, "cover_url", "cover_key"),
|
||||
"video_url": video_url,
|
||||
"estimated_cost": str(task.estimated_cost),
|
||||
"error_message": (public_error.fallback_message if public_error else "") or str(task.error_message or ""),
|
||||
}
|
||||
|
||||
|
||||
def get_team_digest_job(*, team, task_id) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = AITask.objects.filter(
|
||||
id=task_id,
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
).first()
|
||||
if task is None:
|
||||
return None
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def get_inflight_team_digest(*, team) -> dict | None:
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = (
|
||||
AITask.objects.filter(
|
||||
team=team,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
status__in={
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
},
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
return None
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
|
||||
"""秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。"""
|
||||
from django.db import transaction
|
||||
|
||||
from apps.ai.models import AITask
|
||||
from apps.ai.tasks import run_video_digest_task
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
from apps.billing.services.ledger import reserve_credit
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
if model_config is None:
|
||||
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
|
||||
|
||||
path, suffix, size, duration = _materialize_upload(upload)
|
||||
file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
|
||||
width = height = 0
|
||||
source_key = source_url = cover_key = cover_url = ""
|
||||
try:
|
||||
width, height = probe_video_size(path)
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extract_cover_jpeg(path, duration))
|
||||
source_key, source_url = _store_raw_source(team=team, path=path, suffix=suffix)
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
if not source_key:
|
||||
raise VideoDigestError("参考视频上传失败,请重试")
|
||||
|
||||
quote = quote_video_digest(team=team, model_config=model_config)
|
||||
request_payload = {
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"feature": "video_remix",
|
||||
"duration_seconds": round(duration, 2),
|
||||
"file_name": file_name,
|
||||
"title": title_from_filename(file_name),
|
||||
"file_size": size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"ratio": ratio_label(width, height),
|
||||
"suffix": suffix,
|
||||
"source_key": source_key,
|
||||
"source_url": source_url,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
}
|
||||
if quote.meta.get("rate"):
|
||||
request_payload["points_per_yuan_snapshot"] = quote.meta["rate"]
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
task = AITask.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
project=None,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
status=AITask.Status.CREATED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"video_digest:{team.id}:{uuid.uuid4()}",
|
||||
request_payload=request_payload,
|
||||
estimated_cost=quote.points,
|
||||
base_cost=quote.base_cost_yuan,
|
||||
)
|
||||
reserve_credit(team=team, user=user, task=task, amount=quote.points)
|
||||
task.status = AITask.Status.RESERVED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
except ValueError as exc:
|
||||
if "insufficient credit" in str(exc).lower():
|
||||
raise VideoDigestError("团队余额不足,请充值后重试") from exc
|
||||
raise VideoDigestError(str(exc)) from exc
|
||||
|
||||
run_video_digest_task.delay(str(task.id))
|
||||
return serialize_digest_job(task)
|
||||
|
||||
|
||||
def run_team_digest_task(*, task_id: str) -> None:
|
||||
"""Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = AITask.objects.select_related("team", "created_by", "model_config", "credit_reservation").filter(
|
||||
id=task_id,
|
||||
task_type=AITask.Type.VIDEO_DIGEST,
|
||||
project__isnull=True,
|
||||
).first()
|
||||
if task is None:
|
||||
return
|
||||
if task.status == AITask.Status.SUCCEEDED:
|
||||
return
|
||||
if task.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status == AITask.Status.SUCCEEDED:
|
||||
return
|
||||
if locked.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}:
|
||||
return
|
||||
locked.status = AITask.Status.SUBMITTED
|
||||
locked.submitted_at = locked.submitted_at or timezone.now()
|
||||
locked.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
task = locked
|
||||
|
||||
req = task.request_payload or {}
|
||||
source_key = str(req.get("source_key") or "")
|
||||
suffix = str(req.get("suffix") or ".mp4")
|
||||
file_name = str(req.get("file_name") or "参考视频.mp4")
|
||||
if not source_key:
|
||||
reservation = getattr(task, "credit_reservation", None)
|
||||
_fail_digest_task(task, reservation, "参考视频丢失,请重新上传")
|
||||
return
|
||||
|
||||
local_path = ""
|
||||
try:
|
||||
local_path = _download_source_to_temp(source_key, suffix)
|
||||
upload = _FileFromPath(local_path, file_name)
|
||||
_digest_video(
|
||||
team=task.team,
|
||||
user=task.created_by,
|
||||
upload=upload,
|
||||
project=None,
|
||||
product_hint="",
|
||||
model_config_id=str(task.model_config_id) if task.model_config_id else None,
|
||||
existing_task=task,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("async video digest failed for %s", task.id)
|
||||
task.refresh_from_db()
|
||||
if task.status not in {AITask.Status.SUCCEEDED, AITask.Status.FAILED}:
|
||||
reservation = getattr(task, "credit_reservation", None)
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
finally:
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None, existing_task=None) -> dict:
|
||||
"""上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
@@ -720,7 +987,11 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
height = int(extras.get("height") or 0)
|
||||
file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0)
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
model_config = (
|
||||
existing_task.model_config
|
||||
if existing_task is not None and existing_task.model_config_id
|
||||
else resolve_digest_model_config(preferred_id=model_config_id)
|
||||
)
|
||||
if model_config is None:
|
||||
if source_path:
|
||||
Path(source_path).unlink(missing_ok=True)
|
||||
@@ -765,7 +1036,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
if quote.meta.get("rate"):
|
||||
request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]}
|
||||
|
||||
if project is not None:
|
||||
if existing_task is not None:
|
||||
task = existing_task
|
||||
existing_req = dict(task.request_payload or {})
|
||||
request_payload = {**existing_req, **request_payload}
|
||||
task.request_payload = request_payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
elif project is not None:
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
@@ -831,18 +1108,25 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
_fail_digest_task(task, reservation, str(exc))
|
||||
raise
|
||||
|
||||
cover_key, cover_url, video_key, video_url = "", "", "", ""
|
||||
existing_req = dict(task.request_payload or {})
|
||||
cover_key = str(existing_req.get("cover_key") or "")
|
||||
cover_url = str(existing_req.get("cover_url") or "")
|
||||
video_key = str(existing_req.get("video_key") or "")
|
||||
video_url = str(existing_req.get("video_url") or "")
|
||||
if project is None:
|
||||
if not cover_key:
|
||||
try:
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
|
||||
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
|
||||
logger.warning("video digest cover upload failed", exc_info=True)
|
||||
try:
|
||||
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
|
||||
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
|
||||
logger.warning("video digest cover upload failed", exc_info=True)
|
||||
try:
|
||||
video_key, video_url = _store_digest_video(
|
||||
stored_key, stored_url = _store_digest_video(
|
||||
team=team,
|
||||
path=source_path,
|
||||
suffix=str(extras.get("suffix") or ".mp4"),
|
||||
)
|
||||
if stored_key:
|
||||
video_key, video_url = stored_key, stored_url
|
||||
except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播
|
||||
logger.warning("video digest source upload failed", exc_info=True)
|
||||
if source_path:
|
||||
@@ -851,13 +1135,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
|
||||
shots = shot_count(digest)
|
||||
request_payload = {
|
||||
**(task.request_payload or {}),
|
||||
**existing_req,
|
||||
**request_payload,
|
||||
"shot_count": shots,
|
||||
"cover_key": cover_key,
|
||||
"cover_url": cover_url,
|
||||
"video_key": video_key,
|
||||
"video_url": video_url,
|
||||
"cover_key": cover_key or existing_req.get("cover_key") or "",
|
||||
"cover_url": cover_url or existing_req.get("cover_url") or "",
|
||||
"video_key": video_key or existing_req.get("video_key") or "",
|
||||
"video_url": video_url or existing_req.get("video_url") or "",
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
Reference in New Issue
Block a user