拆镜复刻开发完成

This commit is contained in:
2026-06-11 17:54:40 +08:00
parent a6b6e5f822
commit 74266a126c
34 changed files with 7105 additions and 470 deletions
@@ -1,103 +1,28 @@
# app/services/celery_download_recovery_service.py
from __future__ import annotations
import inspect
import json
import logging
from datetime import datetime, timezone
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Union
from app.config import settings
try:
from redis.exceptions import RedisError
except ImportError:
RedisError = RuntimeError # type: ignore[assignment]
from app.services.redis_registry_service import (
close_registry_redis,
datetime_to_epoch,
ensure_aware_utc,
get_registry_redis,
redis_get_due_registry_ids,
redis_get_registry_payloads,
redis_postpone_registry_item,
redis_remove_registry_item,
redis_upsert_registry_item,
utc_now,
)
logger = logging.getLogger("video_gen")
_redis_client: Optional[Any] = None
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def datetime_to_epoch(value: Optional[datetime]) -> int:
checked_value = ensure_aware_utc(value) or utc_now()
return int(checked_value.timestamp())
def _registry_redis_url() -> str:
return settings.CELERY_BROKER_URL or settings.REDIS_URL or ""
async def get_registry_redis() -> Optional[Any]:
global _redis_client
if _redis_client is not None:
return _redis_client
redis_url = _registry_redis_url()
if not redis_url:
return None
try:
from redis.asyncio import Redis
except ImportError as exc:
logger.warning(
"下载容灾 Redis 注册表不可用,redis 依赖未安装。error=%s",
exc,
)
return None
try:
redis_client = Redis.from_url(redis_url, decode_responses=True)
await redis_client.ping()
_redis_client = redis_client
return _redis_client
except (RedisError, OSError, RuntimeError) as exc:
logger.warning(
"下载容灾 Redis 注册表不可用,降级为仅 DB 容灾。error=%s",
exc,
)
_redis_client = None
return None
async def close_registry_redis() -> None:
global _redis_client
client = _redis_client
_redis_client = None
if client is None:
return
try:
close_method = getattr(client, "close", None)
if close_method is None:
return
close_result = close_method()
if inspect.isawaitable(close_result):
await close_result
except (RedisError, OSError, RuntimeError) as exc:
logger.debug(
"关闭下载容灾 Redis 注册表连接失败。error=%s",
exc,
)
# 说明:
# - 本文件保留旧函数名,作为下载容灾兼容层。
# - 底层 Redis Hash/ZSet 操作已迁移到 redis_registry_service.py。
# - 下载 active key、Redis URL 选择逻辑不变,避免影响已稳定下载模块。
def build_download_active_payload(
@@ -131,32 +56,12 @@ def build_download_active_payload(
"attempt": int(attempt or 0),
"queue": queue,
"priority": priority,
"enqueue_at": (
datetime_to_epoch(checked_enqueue_at)
if checked_enqueue_at
else None
),
"started_at": (
datetime_to_epoch(checked_started_at)
if checked_started_at
else None
),
"enqueue_at": datetime_to_epoch(checked_enqueue_at) if checked_enqueue_at else None,
"started_at": datetime_to_epoch(checked_started_at) if checked_started_at else None,
"updated_at": datetime_to_epoch(checked_updated_at),
"lease_until": (
datetime_to_epoch(checked_lease_until)
if checked_lease_until
else None
),
"next_retry_at": (
datetime_to_epoch(checked_next_retry_at)
if checked_next_retry_at
else None
),
"check_at": (
datetime_to_epoch(checked_check_at)
if checked_check_at
else None
),
"lease_until": datetime_to_epoch(checked_lease_until) if checked_lease_until else None,
"next_retry_at": datetime_to_epoch(checked_next_retry_at) if checked_next_retry_at else None,
"check_at": datetime_to_epoch(checked_check_at) if checked_check_at else None,
"reason": reason,
}
@@ -167,56 +72,23 @@ async def upsert_download_active(
payload: Dict[str, Any],
check_at: Optional[Union[datetime, int, float]],
) -> None:
redis = await get_registry_redis()
if redis is None:
return
if isinstance(check_at, datetime):
score = datetime_to_epoch(check_at)
elif check_at is None:
score = datetime_to_epoch(utc_now())
else:
score = int(float(check_at))
updated_payload = dict(payload)
updated_payload["check_at"] = score
try:
pipe: Any = redis.pipeline(transaction=True)
pipe.hset(
settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
record_id,
json.dumps(updated_payload, ensure_ascii=False, default=str),
)
pipe.zadd(
settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
{record_id: score},
)
await pipe.execute()
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"写入下载容灾 Redis 注册表失败。record_id=%s, error=%s",
record_id,
exc,
)
await redis_upsert_registry_item(
hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
item_id=record_id,
payload=payload,
check_at=check_at,
log_context="download_active",
)
async def remove_download_active(record_id: str) -> None:
redis = await get_registry_redis()
if redis is None:
return
try:
pipe: Any = redis.pipeline(transaction=True)
pipe.hdel(settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, record_id)
pipe.zrem(settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, record_id)
await pipe.execute()
except (RedisError, OSError, RuntimeError) as exc:
logger.warning(
"删除下载容灾 Redis 注册表失败。record_id=%s, error=%s",
record_id,
exc,
)
await redis_remove_registry_item(
hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
item_id=record_id,
log_context="download_active",
)
async def get_due_download_record_ids(
@@ -224,68 +96,22 @@ async def get_due_download_record_ids(
limit: Optional[int] = None,
now: Optional[datetime] = None,
) -> List[str]:
redis = await get_registry_redis()
if redis is None:
return []
batch_limit = int(limit or settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100)
score = datetime_to_epoch(now or utc_now())
try:
result = await redis.zrangebyscore(
settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
min="-inf",
max=score,
start=0,
num=batch_limit,
)
return [str(item) for item in result]
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"扫描下载容灾 Redis ZSet 失败。error=%s",
exc,
)
return []
return await redis_get_due_registry_ids(
zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
limit=limit or int(settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100),
now=now,
log_context="download_active",
)
async def get_download_active_payloads(
record_ids: Iterable[str],
) -> Dict[str, Dict[str, Any]]:
cleaned_record_ids = [str(item) for item in record_ids if item]
if not cleaned_record_ids:
return {}
redis = await get_registry_redis()
if redis is None:
return {}
try:
raw_values = await redis.hmget(
settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
cleaned_record_ids,
)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"读取下载容灾 Redis Hash 失败。error=%s",
exc,
)
return {}
result: Dict[str, Dict[str, Any]] = {}
for record_id, raw in zip(cleaned_record_ids, raw_values):
if not raw:
continue
try:
value = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if isinstance(value, dict):
result[record_id] = value
return result
return await redis_get_registry_payloads(
hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
item_ids=record_ids,
log_context="download_active",
)
async def postpone_download_active_check(
@@ -294,45 +120,14 @@ async def postpone_download_active_check(
payload: Optional[Dict[str, Any]] = None,
check_at: Optional[Union[datetime, int, float]] = None,
) -> None:
redis = await get_registry_redis()
if redis is None:
return
if check_at is None:
check_at = utc_now().timestamp() + int(settings.DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS or 300)
if isinstance(check_at, datetime):
score = datetime_to_epoch(check_at)
elif check_at is None:
score = datetime_to_epoch(utc_now()) + int(
settings.DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS or 300
)
else:
score = int(float(check_at))
try:
pipe: Any = redis.pipeline(transaction=True)
pipe.zadd(
settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
{record_id: score},
)
if payload is not None:
updated_payload = dict(payload)
updated_payload["check_at"] = score
updated_payload["updated_at"] = datetime_to_epoch(utc_now())
pipe.hset(
settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
record_id,
json.dumps(
updated_payload,
ensure_ascii=False,
default=str,
),
)
await pipe.execute()
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"刷新下载容灾 Redis 检查时间失败。record_id=%s, error=%s",
record_id,
exc,
)
await redis_postpone_registry_item(
hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
item_id=record_id,
payload=payload,
check_at=check_at,
log_context="download_active",
)
@@ -23,3 +23,15 @@ async def notify_chat_generation_task_finished(db: AsyncSession, task: ChatGener
await handle_chat_generation_task_completed(db, task)
elif task.status == "failed":
await handle_chat_generation_task_failed(db, task)
return
if task.generation_mode == "shot_replicate":
from app.services.shot_replicate_flow_service import (
handle_chat_generation_task_completed,
handle_chat_generation_task_failed,
)
if task.status == "completed":
await handle_chat_generation_task_completed(db, task)
elif task.status == "failed":
await handle_chat_generation_task_failed(db, task)
return
@@ -1,10 +1,11 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -16,13 +17,21 @@ from app.services.celery_download_recovery_service import (
postpone_download_active_check,
remove_download_active,
)
from app.services.generation_log_service import log_task_event
from app.services.generation_log_service import log_provider_call, log_task_event
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
from app.services.generation_provider_service import poll_provider_task
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.redis_registry_service import (
redis_get_due_registry_ids,
redis_get_registry_payloads,
redis_postpone_registry_item,
redis_remove_registry_item,
)
logger = logging.getLogger("video_gen")
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"}
POLL_QUEUE = "gen_provider_poll"
def _now() -> datetime:
@@ -58,6 +67,52 @@ def _is_final_task_state(task: ChatGenerationTask) -> bool:
)
def _is_success(status: str | None) -> bool:
return str(status or "").lower() in ("succeeded", "success", "completed", "done")
def _is_failed(status: str | None) -> bool:
return str(status or "").lower() in ("failed", "error", "canceled", "cancelled")
def _engine_snapshot(task: ChatGenerationTask) -> dict[str, Any]:
try:
value = json.loads(task.engine_snapshot_json or "{}")
return value if isinstance(value, dict) else {}
except Exception:
return {}
def _poll_queue_timeout_at(now: datetime | None = None) -> datetime:
current_time = now or _now()
return current_time + timedelta(seconds=int(settings.POLL_TASK_QUEUE_TIMEOUT_SECONDS or 120))
async def _remove_poll_active(task_id: str) -> None:
await redis_remove_registry_item(
hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
item_id=task_id,
log_context="poll_active",
)
async def _postpone_poll_active(
*,
task_id: str,
payload: dict[str, Any] | None = None,
check_at: datetime | int | float | None = None,
) -> None:
await redis_postpone_registry_item(
hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
item_id=task_id,
payload=payload,
check_at=check_at or _poll_queue_timeout_at(),
log_context="poll_active",
)
async def recover_one_download_task(
db: AsyncSession,
task: ChatGenerationTask,
@@ -221,7 +276,7 @@ async def recover_download_tasks_once(db: AsyncSession) -> dict[str, Any]:
select(ChatGenerationTask)
.where(
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]),
ChatGenerationTask.status == "generating",
ChatGenerationTask.remote_result_url.is_not(None),
ChatGenerationTask.pipeline_stage.in_(
@@ -249,134 +304,363 @@ async def recover_download_tasks_once(db: AsyncSession) -> dict[str, Any]:
return {"checked": len(checked_ids), "results": results}
async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""启动时生成链路容灾扫描。
async def _mark_timeout(
db: AsyncSession,
task: ChatGenerationTask,
*,
error_message: str = "任务超时",
) -> str:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=error_message,
pipeline_stage="timeout",
)
await notify_chat_generation_task_finished(db, task)
await db.commit()
await _remove_poll_active(task.id)
await log_task_event(
task,
event_type="TASK_TIMEOUT",
to_status="failed",
to_stage="timeout",
)
return "mark_timeout"
只在 Celery worker 启动时跑一次,不引入 beat,不新增第四条启动命令。
用于把 queued/creating/waiting_remote/polling/result_ready 等中间态重新投递到现有三个队列。
async def _mark_failed(
db: AsyncSession,
task: ChatGenerationTask,
*,
error_message: str,
event_type: str = "POLL_FAILED",
detail: Any = None,
) -> str:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=error_message,
pipeline_stage="failed",
)
await notify_chat_generation_task_finished(db, task)
await db.commit()
await _remove_poll_active(task.id)
await log_task_event(task, event_type=event_type, message=task.error_message, detail=detail)
return "mark_failed"
async def _try_final_poll_before_timeout(db: AsyncSession, task: ChatGenerationTask) -> str:
"""超时前最后查一次供应商,避免 Celery 中断导致本地假超时。
如果供应商已经成功,继续进入下载;如果仍 running 或查询失败,再按超时处理。
"""
from app.tasks.generation_download_tasks import enqueue_download_task
if not (task.provider_task_id or task.seedance_task_id):
return await _mark_timeout(db, task)
try:
poll_result = await poll_provider_task(db, task)
status = poll_result.get("status")
response_data = poll_result.get("response_data")
except Exception as exc:
await log_task_event(
task,
event_type="FINAL_POLL_BEFORE_TIMEOUT_ERROR",
message=str(exc),
)
return await _mark_timeout(db, task)
try:
provider_response = json.loads(response_data or "{}")
except Exception:
provider_response = {"raw": response_data}
snapshot = _engine_snapshot(task)
await log_provider_call(
task,
provider=snapshot.get("provider") or "ark",
api_type=f"{task.gen_type}_final_poll_before_timeout",
model=snapshot.get("model_name"),
engine_id=task.engine_id,
status="success",
provider_task_id=task.seedance_task_id or task.provider_task_id,
response_data=provider_response,
)
if _is_success(status):
if task.gen_type == "image":
task.remote_result_url = poll_result.get("image_url")
task.image_tokens_used = poll_result.get("image_tokens", 0) or 0
else:
task.remote_result_url = poll_result.get("video_url")
task.video_tokens_used = poll_result.get("video_tokens", 0) or 0
task.provider_response_json = response_data
if not task.remote_result_url:
return await _mark_failed(
db,
task,
error_message="供应商任务成功但未返回结果URL",
detail=poll_result,
)
task.pipeline_stage = "result_ready"
task.retry_count = 0
await db.commit()
await _remove_poll_active(task.id)
await log_task_event(
task,
event_type="POLL_SUCCESS_AFTER_TIMEOUT_RECOVERY",
to_stage="result_ready",
detail=poll_result,
)
await enqueue_download_task(db, task, recover=True, reason="final_poll_before_timeout_success")
return "recover_timeout_success_to_download"
if _is_failed(status):
task.provider_response_json = response_data
return await _mark_failed(
db,
task,
error_message=poll_result.get("error") or f"供应商任务失败: {status}",
detail=poll_result,
)
await log_task_event(
task,
event_type="FINAL_POLL_BEFORE_TIMEOUT_PENDING",
message=f"status={status}",
detail=poll_result,
)
return await _mark_timeout(db, task)
async def recover_one_generation_task(
db: AsyncSession,
task: ChatGenerationTask,
*,
payload: dict[str, Any] | None = None,
source: str = "startup_db",
) -> str:
from app.tasks.generation_create_tasks import chatapi_create_generation_task
from app.tasks.generation_download_tasks import enqueue_download_task
from app.tasks.generation_poll_tasks import poll_generation_task
from app.tasks.generation_poll_tasks import poll_generation_task, register_poll_active
current_time = _now()
results: dict[str, int] = {}
redis_payload = payload or {}
query_result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
ChatGenerationTask.status == "generating",
ChatGenerationTask.pipeline_stage.in_(
[
"queued",
"preparing",
"creating_provider_task",
"waiting_remote",
"polling",
"result_ready",
]
),
)
.order_by(ChatGenerationTask.updated_at.asc())
.limit(int(settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100))
.with_for_update(skip_locked=True)
)
tasks = query_result.scalars().all()
if not task:
return "skip_missing_task"
if task.generation_mode not in ALLOWED_GENERATION_MODES:
await _remove_poll_active(task.id)
return "clean_invalid_mode"
if _is_final_task_state(task):
await _remove_poll_active(task.id)
return "clean_final_state"
if task.status != "generating":
await _remove_poll_active(task.id)
return "clean_not_generating"
for task in tasks:
if task.deadline_at and _is_expired(task.deadline_at, current_time):
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message="任务超时",
pipeline_stage="timeout",
)
await notify_chat_generation_task_finished(db, task)
if task.deadline_at and _is_expired(task.deadline_at, current_time):
if task.pipeline_stage in ("waiting_remote", "polling"):
return await _try_final_poll_before_timeout(db, task)
return await _mark_timeout(db, task)
if task.pipeline_stage in ("queued", "preparing", "creating_provider_task"):
if task.provider_task_id or task.seedance_task_id:
task.pipeline_stage = "waiting_remote"
await db.commit()
await log_task_event(
task,
event_type="TASK_TIMEOUT",
to_status="failed",
to_stage="timeout",
event_type="GENERATION_RECOVERY_ENQUEUE",
message=f"{source} 发现创建阶段已存在供应商任务ID,恢复投递轮询队列",
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
)
action = "mark_timeout"
poll_generation_task.apply_async(args=[task.id], queue=POLL_QUEUE, countdown=0)
await register_poll_active(
task,
check_at=_poll_queue_timeout_at(),
reason=f"{source}_create_stage_has_provider_id",
)
return "recover_poll_from_create_stage"
elif task.pipeline_stage in ("queued", "preparing", "creating_provider_task"):
await log_task_event(
task,
event_type="GENERATION_RECOVERY_ENQUEUE",
message=f"{source} 发现创建阶段任务未完成,恢复投递创建队列",
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
)
chatapi_create_generation_task.apply_async(
args=[task.id],
queue="gen_chatapi_create",
countdown=0,
)
return "recover_create"
if task.pipeline_stage in ("waiting_remote", "polling"):
if task.remote_result_url:
await _remove_poll_active(task.id)
await enqueue_download_task(
db,
task,
recover=True,
reason=f"{source}_waiting_remote_has_result",
)
return "recover_waiting_has_result"
if task.provider_task_id or task.seedance_task_id:
await log_task_event(
task,
event_type="GENERATION_RECOVERY_ENQUEUE",
message="启动时发现创建阶段任务未完成,恢复投递创建队列",
detail={"pipeline_stage": task.pipeline_stage},
message=f"{source} 发现远程等待/轮询阶段任务未完成,恢复投递轮询队列",
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
)
chatapi_create_generation_task.apply_async(
task.pipeline_stage = "waiting_remote"
await db.commit()
poll_generation_task.apply_async(
args=[task.id],
queue="gen_chatapi_create",
queue=POLL_QUEUE,
countdown=0,
)
action = "recover_create"
await register_poll_active(
task,
check_at=_poll_queue_timeout_at(),
reason=f"{source}_recover_poll",
)
return "recover_poll"
elif task.pipeline_stage in ("waiting_remote", "polling"):
if task.remote_result_url:
await enqueue_download_task(
db,
task,
recover=True,
reason="startup_waiting_remote_has_result",
)
action = "recover_waiting_has_result"
elif task.provider_task_id or task.seedance_task_id:
await log_task_event(
task,
event_type="GENERATION_RECOVERY_ENQUEUE",
message="启动时发现远程等待/轮询阶段任务未完成,恢复投递轮询队列",
detail={"pipeline_stage": task.pipeline_stage},
)
task.pipeline_stage = "waiting_remote"
await db.commit()
poll_generation_task.apply_async(
args=[task.id],
queue="gen_provider_poll",
countdown=0,
)
action = "recover_poll"
else:
await log_task_event(
task,
event_type="GENERATION_RECOVERY_ENQUEUE",
message="启动时发现任务缺少供应商任务ID,恢复投递创建队列",
detail={"pipeline_stage": task.pipeline_stage},
)
task.pipeline_stage = "queued"
await db.commit()
chatapi_create_generation_task.apply_async(
args=[task.id],
queue="gen_chatapi_create",
countdown=0,
)
action = "recover_create_missing_provider_id"
await log_task_event(
task,
event_type="GENERATION_RECOVERY_ENQUEUE",
message=f"{source} 发现任务缺少供应商任务ID,恢复投递创建队列",
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
)
task.pipeline_stage = "queued"
await db.commit()
await _remove_poll_active(task.id)
chatapi_create_generation_task.apply_async(
args=[task.id],
queue="gen_chatapi_create",
countdown=0,
)
return "recover_create_missing_provider_id"
elif task.pipeline_stage == "result_ready":
if task.remote_result_url:
await enqueue_download_task(
db,
task,
recover=True,
reason="startup_generation_result_ready",
)
action = "recover_result_ready"
else:
action = "skip_result_ready_no_url"
if task.pipeline_stage == "result_ready":
await _remove_poll_active(task.id)
if task.remote_result_url:
await enqueue_download_task(
db,
task,
recover=True,
reason=f"{source}_generation_result_ready",
)
return "recover_result_ready"
return "skip_result_ready_no_url"
return f"skip_stage_{task.pipeline_stage}"
async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""启动时生成链路容灾扫描。
不新增 Celery beat,不新增 worker 命令;worker 启动时由 Redis 锁保证只投递一次。
恢复顺序:
1. Redis poll active_index 到期任务;
2. DB fallback 扫描 queued/creating/waiting_remote/polling/result_ready
3. 下载阶段仍由 recover_download_tasks_once 兜底。
"""
checked_ids: set[str] = set()
results: dict[str, int] = {}
due_poll_ids = await redis_get_due_registry_ids(
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
limit=int(settings.POLL_RECOVERY_BATCH_SIZE or settings.GENERATION_RECOVERY_BATCH_SIZE or 100),
log_context="poll_active",
)
poll_payloads = await redis_get_registry_payloads(
hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY,
item_ids=due_poll_ids,
log_context="poll_active",
)
for task_id in due_poll_ids:
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
task = result.scalar_one_or_none()
if task is None:
await _remove_poll_active(task_id)
action = "clean_missing_poll_task"
else:
action = f"skip_stage_{task.pipeline_stage}"
checked_ids.add(task.id)
action = await recover_one_generation_task(
db,
task,
payload=poll_payloads.get(task_id),
source="startup_poll_redis",
)
results[action] = results.get(action, 0) + 1
batch_size = int(settings.GENERATION_RECOVERY_BATCH_SIZE or settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100)
max_rounds = max(1, int(settings.GENERATION_RECOVERY_MAX_ROUNDS or 1))
total_db_checked = 0
for _round in range(max_rounds):
query_result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]),
ChatGenerationTask.status == "generating",
ChatGenerationTask.pipeline_stage.in_(
[
"queued",
"preparing",
"creating_provider_task",
"waiting_remote",
"polling",
"result_ready",
]
),
)
.order_by(ChatGenerationTask.updated_at.asc())
.limit(batch_size)
.with_for_update(skip_locked=True)
)
tasks = query_result.scalars().all()
if not tasks:
break
progressed_this_round = 0
for task in tasks:
if task.id in checked_ids:
continue
action = await recover_one_generation_task(
db,
task,
payload=None,
source="startup_db",
)
results[action] = results.get(action, 0) + 1
checked_ids.add(task.id)
total_db_checked += 1
progressed_this_round += 1
if len(tasks) < batch_size or progressed_this_round <= 0:
break
# 下载阶段单独跑 DB fallback。
download_result = await recover_download_tasks_once(db)
return {
"checked": len(tasks),
"checked": len(checked_ids),
"db_checked": total_db_checked,
"results": results,
"download_recovery": download_result,
}
@@ -180,7 +180,7 @@ async def mark_chat_generation_task_failed_and_refund_once(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]),
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
@@ -44,7 +44,7 @@ from app.services.generation_billing_service import charge_module_prompt_usage
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_task_factory_service import create_chat_generation_task_for_module
from app.services.hot_opening_video_prompt_service import build_final_video_prompt, optimize_hot_opening_video_prompt, patch_video_prompt_schema_from_client
from app.services.module_generation_log_service import log_module_event_file, log_module_prompt_event
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
from app.services.llm import optimize_prompt
from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.resource_signed_url_service import build_resource_signed_url
@@ -206,6 +206,28 @@ async def log_module_event(
)
def _log_project_error(
*,
project: ModuleGenerationProject | None,
event_type: str,
message: str,
exc: BaseException | None = None,
step: ModuleGenerationStep | None = None,
detail: dict[str, Any] | None = None,
) -> None:
log_module_error(
module=(project.module if project else MODULE),
event_type=event_type,
project_id=(project.id if project else None),
step_id=(step.id if step else None),
user_id=(project.user_id if project else None),
message=message,
detail=detail,
exc=exc,
)
async def _get_project_for_user(
db: AsyncSession,
*,
@@ -1039,6 +1061,17 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = f"图片 AI 提词生成失败: {exc}"
log_module_prompt_event(
event_type="module_prompt_error",
project_id=project.id,
step_id=step.id,
user_id=project.user_id,
module=project.module,
prompt_type=ModulePromptTypeEnum.IMAGE_PROMPT.value,
request=locals().get("request_log", {}),
error=str(exc),
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
return step
@@ -1317,6 +1350,17 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = f"视频 AI 提词生成失败: {exc}"
log_module_prompt_event(
event_type="module_prompt_error",
project_id=project.id,
step_id=step.id,
user_id=project.user_id,
module=project.module,
prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value,
request=locals().get("request_log", {}),
error=str(exc),
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
return step
@@ -1517,6 +1561,16 @@ async def mark_hot_opening_step_dispatch_failed(
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
log_module_error(
module=project.module,
event_type="CELERY_DISPATCH_FAILED",
project_id=project.id,
step_id=step.id,
user_id=project.user_id,
message=error_message,
detail={"reason": "celery_dispatch_failed", "chat_task_id": step.chat_task_id},
error=error_message,
)
await log_module_event(
db,
project=project,
@@ -628,7 +628,7 @@ async def optimize_hot_opening_video_prompt(
) -> tuple[dict[str, Any], str, dict[str, Any]]:
duration = int(video_config["duration"])
references = [
{"type": "video", "url": material_video_url},
{"type": "video", "url": _build_file_url_or_data_uri(material_video_url)},
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)},
]
client_schema = build_dynamic_schema(video_config)
@@ -3,12 +3,14 @@ from __future__ import annotations
import json
import os
import re
import traceback
from datetime import datetime
from typing import Any
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled
MAX_LOG_FIELD_LENGTH = 20000
MAX_TRACEBACK_LENGTH = 12000
MODULE_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "ModuleGeneration")
@@ -33,6 +35,23 @@ def _safe_dump_value(value: Any) -> Any:
return value
def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
"""构造统一异常日志 detail。日志方法必须吞异常,业务不能被日志影响。"""
detail: dict[str, Any] = dict(extra or {})
if exc is not None:
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
if len(tb) > MAX_TRACEBACK_LENGTH:
tb = tb[:MAX_TRACEBACK_LENGTH] + f"...<traceback_truncated:{len(tb) - MAX_TRACEBACK_LENGTH}>"
detail.update(
{
"exception_type": type(exc).__name__,
"exception_message": str(exc),
"traceback": tb,
}
)
return detail
def _append_module_log(module: str, entry: dict[str, Any]) -> None:
if not is_enabled():
return
@@ -92,10 +111,7 @@ def log_module_prompt_event(
token_usage: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
"""记录模块 AI 提词请求/响应到 JSONL 文件。
与模块事件共用同一个服务,但按 module 分目录,方便按模块排查。
"""
"""记录模块 AI 提词/分析请求响应到 JSONL 文件。"""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"log_type": "module_prompt",
@@ -123,8 +139,15 @@ def log_module_error(
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
exc: BaseException | None = None,
) -> None:
"""记录模块异常日志。"""
"""记录模块异常日志。
- 兼容原有 detail/error 参数。
- 新增 exc 后自动记录 exception_type、message、traceback。
- 日志写入失败会被底层吞掉,不影响主流程。
"""
merged_detail = build_exception_detail(exc, detail)
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"log_type": "module_error",
@@ -134,7 +157,7 @@ def log_module_error(
"step_id": step_id,
"user_id": user_id,
"message": message,
"detail": _safe_dump_value(detail or {}),
"error": error,
"detail": _safe_dump_value(merged_detail),
"error": error if error is not None else (str(exc) if exc is not None else None),
}
_append_module_log(module, entry)
@@ -0,0 +1,354 @@
# app/services/redis_registry_service.py
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import os
import threading
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Union
from app.config import settings
try:
from redis.exceptions import RedisError
except ImportError: # pragma: no cover - redis 未安装时降级
RedisError = RuntimeError # type: ignore[assignment]
logger = logging.getLogger("video_gen")
_redis_clients: Dict[tuple[int, int, int], Any] = {}
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def datetime_to_epoch(value: Optional[datetime]) -> int:
checked_value = ensure_aware_utc(value) or utc_now()
return int(checked_value.timestamp())
def normalize_registry_score(value: Optional[Union[datetime, int, float]]) -> int:
if isinstance(value, datetime):
return datetime_to_epoch(value)
if value is None:
return datetime_to_epoch(utc_now())
return int(float(value))
def registry_redis_url() -> str:
"""Celery 容灾注册表统一使用 Celery broker Redis。
不能改成只读 settings.REDIS_URL,否则线上 CELERY_BROKER_URL 使用独立
Redis DB 时,旧下载 active 注册表会被写到另一个库,导致恢复扫描失效。
"""
return settings.CELERY_BROKER_URL or settings.REDIS_URL or ""
def _is_supported_redis_url(redis_url: str) -> bool:
if not redis_url:
return False
lowered = redis_url.lower()
return lowered.startswith(("redis://", "rediss://", "unix://"))
async def get_registry_redis() -> Optional[Any]:
"""获取 Celery 容灾 Redis 连接。
重点:redis.asyncio 的连接/连接池绑定 event loop,不能跨 loop 复用。
Celery -P threads 或 worker_ready + task 线程混用时,如果使用单个全局
Redis 客户端,会触发 got Future attached to a different loop。
因此这里按 pid + thread_id + event_loop_id 缓存客户端,确保同一个客户端
只在创建它的事件循环里使用。Redis 不可用时返回 None,调用方降级为
DB fallback,不能影响生成主链路。
"""
redis_url = registry_redis_url()
if not _is_supported_redis_url(redis_url):
if redis_url:
logger.warning(
"Celery 容灾 Redis 注册表仅支持 redis/rediss/unix URL,当前 broker 不是 Redis,降级为 DB 容灾。url=%s",
redis_url,
)
return None
try:
from redis.asyncio import Redis
except ImportError as exc:
logger.warning(
"Celery 容灾 Redis 注册表不可用,redis 依赖未安装。error=%s",
exc,
)
return None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return None
client_key = (os.getpid(), threading.get_ident(), id(loop))
cached = _redis_clients.get(client_key)
if cached is not None:
return cached
try:
redis_client = Redis.from_url(redis_url, decode_responses=True)
await redis_client.ping()
_redis_clients[client_key] = redis_client
return redis_client
except (RedisError, OSError, RuntimeError) as exc:
logger.warning(
"Celery 容灾 Redis 注册表不可用,降级为仅 DB 容灾。error=%s",
exc,
)
_redis_clients.pop(client_key, None)
return None
async def close_registry_redis() -> None:
"""关闭当前进程内已缓存的 Redis 注册表连接。
关闭动作尽量只关闭当前 event loop 对应的客户端;如果调用方处于进程
退出阶段,则逐个尝试关闭,失败忽略,避免影响 worker 退出。
"""
if not _redis_clients:
return
try:
loop = asyncio.get_running_loop()
current_key = (os.getpid(), threading.get_ident(), id(loop))
items = [(current_key, _redis_clients.pop(current_key, None))]
except RuntimeError:
items = list(_redis_clients.items())
_redis_clients.clear()
for _, client in items:
if client is None:
continue
try:
close_method = getattr(client, "close", None) or getattr(client, "aclose", None)
if close_method is None:
continue
close_result = close_method()
if inspect.isawaitable(close_result):
await close_result
except (RedisError, OSError, RuntimeError) as exc:
logger.debug("关闭 Celery 容灾 Redis 注册表连接失败。error=%s", exc)
async def redis_upsert_registry_item(
*,
hash_key: str,
zset_key: str,
item_id: str,
payload: Dict[str, Any],
check_at: Optional[Union[datetime, int, float]],
log_context: str = "registry",
) -> None:
redis = await get_registry_redis()
if redis is None:
return
score = normalize_registry_score(check_at)
updated_payload = dict(payload)
updated_payload["check_at"] = score
updated_payload["updated_at"] = updated_payload.get("updated_at") or datetime_to_epoch(utc_now())
try:
pipe: Any = redis.pipeline(transaction=True)
pipe.hset(hash_key, item_id, json.dumps(updated_payload, ensure_ascii=False, default=str))
pipe.zadd(zset_key, {item_id: score})
await pipe.execute()
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"写入 Redis 注册表失败。context=%s, item_id=%s, error=%s",
log_context,
item_id,
exc,
)
async def redis_remove_registry_item(
*,
hash_key: str,
zset_key: str,
item_id: str,
log_context: str = "registry",
) -> None:
redis = await get_registry_redis()
if redis is None:
return
try:
pipe: Any = redis.pipeline(transaction=True)
pipe.hdel(hash_key, item_id)
pipe.zrem(zset_key, item_id)
await pipe.execute()
except (RedisError, OSError, RuntimeError) as exc:
logger.warning(
"删除 Redis 注册表失败。context=%s, item_id=%s, error=%s",
log_context,
item_id,
exc,
)
async def redis_get_due_registry_ids(
*,
zset_key: str,
limit: Optional[int] = None,
now: Optional[datetime] = None,
log_context: str = "registry",
) -> List[str]:
redis = await get_registry_redis()
if redis is None:
return []
batch_limit = int(limit or 100)
score = datetime_to_epoch(now or utc_now())
try:
result = await redis.zrangebyscore(
zset_key,
min="-inf",
max=score,
start=0,
num=batch_limit,
)
return [str(item) for item in result]
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning("扫描 Redis ZSet 失败。context=%s, error=%s", log_context, exc)
return []
async def redis_get_registry_payloads(
*,
hash_key: str,
item_ids: Iterable[str],
log_context: str = "registry",
) -> Dict[str, Dict[str, Any]]:
cleaned_item_ids = [str(item) for item in item_ids if item]
if not cleaned_item_ids:
return {}
redis = await get_registry_redis()
if redis is None:
return {}
try:
raw_values = await redis.hmget(hash_key, cleaned_item_ids)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning("读取 Redis Hash 失败。context=%s, error=%s", log_context, exc)
return {}
result: Dict[str, Dict[str, Any]] = {}
for item_id, raw in zip(cleaned_item_ids, raw_values):
if not raw:
continue
try:
value = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if isinstance(value, dict):
result[item_id] = value
return result
async def redis_postpone_registry_item(
*,
hash_key: str,
zset_key: str,
item_id: str,
payload: Optional[Dict[str, Any]] = None,
check_at: Optional[Union[datetime, int, float]] = None,
log_context: str = "registry",
) -> None:
redis = await get_registry_redis()
if redis is None:
return
score = normalize_registry_score(check_at)
try:
pipe: Any = redis.pipeline(transaction=True)
pipe.zadd(zset_key, {item_id: score})
if payload is not None:
updated_payload = dict(payload)
updated_payload["check_at"] = score
updated_payload["updated_at"] = datetime_to_epoch(utc_now())
pipe.hset(hash_key, item_id, json.dumps(updated_payload, ensure_ascii=False, default=str))
await pipe.execute()
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"刷新 Redis 注册表检查时间失败。context=%s, item_id=%s, error=%s",
log_context,
item_id,
exc,
)
async def redis_acquire_lock(
*,
lock_key: str,
ttl_seconds: int,
token: Optional[str] = None,
log_context: str = "lock",
) -> Optional[str]:
"""尝试获取 Redis 分布式锁。
返回 token 表示抢锁成功;返回 None 表示 Redis 不可用或锁已被其他 worker 持有。
"""
redis = await get_registry_redis()
if redis is None:
return None
lock_token = token or uuid.uuid4().hex
ttl = max(1, int(ttl_seconds or 60))
try:
acquired = await redis.set(lock_key, lock_token, nx=True, ex=ttl)
return lock_token if acquired else None
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning("获取 Redis 锁失败。context=%s, lock_key=%s, error=%s", log_context, lock_key, exc)
return None
async def redis_release_lock(
*,
lock_key: str,
token: str,
log_context: str = "lock",
) -> bool:
"""只释放 token 匹配的锁,避免误删其他 worker 新抢到的锁。"""
redis = await get_registry_redis()
if redis is None:
return False
script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
try:
released = await redis.eval(script, 1, lock_key, token)
return bool(released)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning("释放 Redis 锁失败。context=%s, lock_key=%s, error=%s", log_context, lock_key, exc)
return False
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.shot_replicate import ShotSplitStatusEnum
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary
from app.tasks.celery_app import celery_app
def _now() -> datetime:
return datetime.now(timezone.utc)
def _ensure_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 _expired(value: datetime | None, now: datetime | None = None) -> bool:
checked = _ensure_aware(value)
if checked is None:
return True
return checked <= (now or _now())
def _queue_timeout(segment: ShotReplicateSegment, now: datetime | None = None) -> bool:
enqueued_at = _ensure_aware(segment.split_enqueued_at)
if enqueued_at is None:
return True
return enqueued_at + timedelta(seconds=int(settings.SHOT_SPLIT_PENDING_TIMEOUT_SECONDS or 300)) <= (now or _now())
async def recover_one_split_segment(db: AsyncSession, segment: ShotReplicateSegment, *, source: str = "startup_db") -> str:
from app.tasks.shot_replicate_tasks import split_one_segment
if not segment:
return "skip_missing_segment"
if segment.deleted_at is not None:
return "skip_deleted"
if segment.split_status == ShotSplitStatusEnum.COMPLETED.value:
return "skip_completed"
if segment.split_status == ShotSplitStatusEnum.FAILED.value:
return "skip_failed"
current_time = _now()
should_recover = False
if segment.split_status == ShotSplitStatusEnum.PENDING.value:
should_recover = _queue_timeout(segment, current_time)
elif segment.split_status == ShotSplitStatusEnum.PROCESSING.value:
should_recover = _expired(segment.split_lease_until, current_time)
elif segment.split_status == ShotSplitStatusEnum.RETRY_WAITING.value:
should_recover = _expired(segment.split_next_retry_at, current_time)
if not should_recover:
return f"skip_{segment.split_status}_not_due"
if int(segment.split_retry_count or 0) >= int(settings.SHOT_SPLIT_MAX_RETRY_COUNT or 3):
segment.split_status = ShotSplitStatusEnum.FAILED.value
segment.split_last_error = segment.split_last_error or f"{source} 恢复时超过最大重试次数"
segment.split_lease_until = None
segment.split_next_retry_at = None
await refresh_task_set_split_summary(db, segment.task_set_id)
await db.commit()
return "mark_failed_max_retry"
segment.split_status = ShotSplitStatusEnum.PENDING.value
segment.split_enqueued_at = current_time
segment.split_lease_until = None
segment.split_next_retry_at = None
await refresh_task_set_split_summary(db, segment.task_set_id)
await db.commit()
if celery_app:
split_one_segment.apply_async(
args=[segment.id],
queue="gen_result_download",
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
countdown=0,
)
return f"recover_{source}"
async def recover_shot_split_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""拆镜 ffmpeg 任务容灾恢复。独立扫描 shot_replicate_segments,不复用 Chat 下载 active registry。"""
batch_size = int(settings.SHOT_SPLIT_RECOVERY_BATCH_SIZE or 50)
result = await db.execute(
select(ShotReplicateSegment)
.where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.split_status.in_(
[
ShotSplitStatusEnum.PENDING.value,
ShotSplitStatusEnum.PROCESSING.value,
ShotSplitStatusEnum.RETRY_WAITING.value,
]
),
)
.order_by(ShotReplicateSegment.updated_at.asc())
.limit(batch_size)
.with_for_update(skip_locked=True)
)
segments = list(result.scalars().all())
checked = 0
results: dict[str, int] = {}
touched_task_set_ids: set[str] = set()
for segment in segments:
action = await recover_one_split_segment(db, segment, source="startup_db")
checked += 1
touched_task_set_ids.add(segment.task_set_id)
results[action] = results.get(action, 0) + 1
for task_set_id in touched_task_set_ids:
await refresh_task_set_split_summary(db, task_set_id)
await db.commit()
return {"checked": checked, "results": results}
@@ -0,0 +1,529 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from app.config import settings
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
ShotSegmentAnalysisStatusEnum,
ShotSegmentReplicateStatusEnum,
ShotSegmentSourceModeEnum,
ShotSplitStatusEnum,
ShotTaskSetStatusEnum,
)
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.models.user import User
from app.schemas.shot_replicate import (
ShotAISuggestionOut,
ShotSegmentDetailOut,
ShotSegmentListOut,
ShotSegmentOut,
ShotSplitByAIOut,
ShotSplitByAIRequest,
ShotSplitCustomOut,
ShotSplitCustomRequest,
ShotTaskSetCreate,
ShotTaskSetDetailOut,
ShotTaskSetListOut,
ShotTaskSetOut,
)
from app.services.module_generation_log_service import log_module_event_file
from app.services.upload_video_asset_service import (
build_time_node,
validate_split_range,
validate_upload_video_asset,
)
from app.tasks.celery_app import celery_app
from app.utils.id_gen import generate_id
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
def _now() -> datetime:
return datetime.now(timezone.utc)
def _normalize_suggestions(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
normalized: list[dict[str, Any]] = []
for idx, item in enumerate(value, start=1):
if not isinstance(item, dict):
continue
start = item.get("拆镜开始秒")
end = item.get("拆镜结束秒")
try:
start_f = float(start)
end_f = float(end)
except Exception:
continue
if start_f < 0 or end_f <= start_f:
continue
normalized.append(
{
"index": idx,
"start_second": start_f,
"end_second": end_f,
"duration_seconds": round(end_f - start_f, 3),
"time_node": str(item.get("拆镜时间节点") or build_time_node(start_f, end_f)),
"content": str(item.get("对应时间节点内的内容") or ""),
"category": str(item.get("分类") or ""),
"audience": str(item.get("受众人群") or ""),
"raw": item,
}
)
return normalized
def _task_set_to_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetOut:
return ShotTaskSetOut.model_validate(task_set)
def _task_set_to_detail_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetDetailOut:
suggestions = [ShotAISuggestionOut(**{k: v for k, v in item.items() if k != "raw"}) for item in _normalize_suggestions(task_set.ai_suggestion_json)]
base = ShotTaskSetDetailOut.model_validate(task_set)
base.ai_suggestions = suggestions
return base
def _segment_to_out(segment: ShotReplicateSegment) -> ShotSegmentOut:
data = ShotSegmentOut.model_validate(segment)
data.segment_name = f"片段{segment.segment_index}"
return data
def _segment_to_detail_out(segment: ShotReplicateSegment) -> ShotSegmentDetailOut:
data = ShotSegmentDetailOut.model_validate(segment)
data.segment_name = f"片段{segment.segment_index}"
return data
async def get_task_set_for_user(
db: AsyncSession,
*,
task_set_id: str,
user: User,
for_update: bool = False,
) -> ShotReplicateTaskSet:
query = select(ShotReplicateTaskSet).where(
ShotReplicateTaskSet.id == task_set_id,
ShotReplicateTaskSet.deleted_at.is_(None),
)
if not user.is_admin:
query = query.where(ShotReplicateTaskSet.user_id == user.id)
if for_update:
query = query.with_for_update()
result = await db.execute(query.limit(1))
task_set = result.scalar_one_or_none()
if not task_set:
raise HTTPException(status_code=404, detail="拆镜总任务集不存在")
return task_set
async def get_segment_for_user(
db: AsyncSession,
*,
segment_id: str,
user: User,
for_update: bool = False,
) -> ShotReplicateSegment:
query = select(ShotReplicateSegment).where(
ShotReplicateSegment.id == segment_id,
ShotReplicateSegment.deleted_at.is_(None),
)
if not user.is_admin:
query = query.where(ShotReplicateSegment.user_id == user.id)
if for_update:
query = query.with_for_update()
result = await db.execute(query.limit(1))
segment = result.scalar_one_or_none()
if not segment:
raise HTTPException(status_code=404, detail="拆镜片段不存在")
return segment
async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTaskSetCreate) -> ShotReplicateTaskSet:
if req.idempotency_key:
existing_result = await db.execute(
select(ShotReplicateTaskSet).where(
ShotReplicateTaskSet.user_id == current_user.id,
ShotReplicateTaskSet.idempotency_key == req.idempotency_key,
ShotReplicateTaskSet.deleted_at.is_(None),
).limit(1)
)
existing = existing_result.scalar_one_or_none()
if existing:
return existing
asset = validate_upload_video_asset(req.video_url, req.video_duration_seconds)
task_set = ShotReplicateTaskSet(
id=generate_id(),
user_id=current_user.id,
title=req.title or "拆镜复刻任务",
video_url=asset.url,
video_path=str(asset.path),
video_duration_seconds=asset.duration_seconds,
status=ShotTaskSetStatusEnum.PENDING_ANALYSIS.value,
analysis_status=ShotAnalysisStatusEnum.PENDING.value,
split_status=ShotSplitStatusEnum.NONE.value,
segment_count=0,
completed_segment_count=0,
failed_segment_count=0,
idempotency_key=req.idempotency_key,
)
db.add(task_set)
await db.flush()
log_module_event_file(
module=MODULE,
event_type="SHOT_TASK_SET_CREATED",
project_id=task_set.id,
user_id=task_set.user_id,
message="创建拆镜总任务集",
detail={
"task_set_id": task_set.id,
"title": task_set.title,
"video_url": task_set.video_url,
"video_path": task_set.video_path,
"video_duration_seconds": task_set.video_duration_seconds,
"idempotency_key": task_set.idempotency_key,
},
)
return task_set
async def list_task_sets(
db: AsyncSession,
*,
current_user: User,
status: str | None = None,
analysis_status: str | None = None,
split_status: str | None = None,
keyword: str | None = None,
page: int = 1,
page_size: int = 20,
) -> ShotTaskSetListOut:
query = select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.deleted_at.is_(None))
if not current_user.is_admin:
query = query.where(ShotReplicateTaskSet.user_id == current_user.id)
if status:
query = query.where(ShotReplicateTaskSet.status == status)
if analysis_status:
query = query.where(ShotReplicateTaskSet.analysis_status == analysis_status)
if split_status:
query = query.where(ShotReplicateTaskSet.split_status == split_status)
if keyword:
like = f"%{keyword.strip()}%"
query = query.where(
(ShotReplicateTaskSet.title.ilike(like))
| (ShotReplicateTaskSet.original_video_content.ilike(like))
| (ShotReplicateTaskSet.original_video_category.ilike(like))
)
total_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = int(total_result.scalar() or 0)
rows = await db.execute(
query.order_by(ShotReplicateTaskSet.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return ShotTaskSetListOut(total=total, page=page, page_size=page_size, items=[_task_set_to_out(item) for item in rows.scalars().all()])
async def task_set_detail(db: AsyncSession, *, current_user: User, task_set_id: str) -> ShotTaskSetDetailOut:
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user)
return _task_set_to_detail_out(task_set)
async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int:
result = await db.execute(
select(func.max(ShotReplicateSegment.segment_index)).where(
ShotReplicateSegment.task_set_id == task_set_id,
ShotReplicateSegment.deleted_at.is_(None),
)
)
return int(result.scalar() or 0) + 1
async def refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None:
task_set_result = await db.execute(select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.id == task_set_id).with_for_update().limit(1))
task_set = task_set_result.scalar_one_or_none()
if not task_set:
return
result = await db.execute(
select(ShotReplicateSegment).where(
ShotReplicateSegment.task_set_id == task_set_id,
ShotReplicateSegment.deleted_at.is_(None),
)
)
segments = list(result.scalars().all())
total = len(segments)
completed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.COMPLETED.value])
failed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.FAILED.value])
task_set.segment_count = total
task_set.completed_segment_count = completed
task_set.failed_segment_count = failed
if total <= 0:
task_set.split_status = ShotSplitStatusEnum.NONE.value
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
return
old_status = task_set.status
old_split_status = task_set.split_status
if completed == total:
task_set.split_status = ShotSplitStatusEnum.COMPLETED.value
task_set.status = ShotTaskSetStatusEnum.SPLIT_COMPLETED.value
elif failed == total:
task_set.split_status = ShotSplitStatusEnum.FAILED.value
task_set.status = ShotTaskSetStatusEnum.FAILED.value
elif failed > 0:
task_set.split_status = ShotSplitStatusEnum.FAILED.value
task_set.status = ShotTaskSetStatusEnum.PARTIAL_FAILED.value
else:
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
if old_status != task_set.status or old_split_status != task_set.split_status:
log_module_event_file(
module=MODULE,
event_type="SHOT_SPLIT_STATUS_CHANGED",
project_id=task_set.id,
user_id=task_set.user_id,
message="拆镜总任务集拆分状态变更",
detail={
"task_set_id": task_set.id,
"from_status": old_status,
"to_status": task_set.status,
"from_split_status": old_split_status,
"to_split_status": task_set.split_status,
"segment_count": total,
"completed_segment_count": completed,
"failed_segment_count": failed,
},
)
async def create_segments_by_ai(
db: AsyncSession,
*,
current_user: User,
task_set_id: str,
req: ShotSplitByAIRequest,
) -> ShotSplitByAIOut:
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
if task_set.analysis_status != ShotAnalysisStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="原视频分析未完成,不能按 AI 建议拆镜")
suggestions = _normalize_suggestions(task_set.ai_suggestion_json)
if not suggestions:
raise HTTPException(status_code=400, detail="当前没有可用 AI 建议拆镜方案,请使用自定义拆镜")
if req.selected_indices:
selected_set = {int(x) for x in req.selected_indices}
suggestions = [item for item in suggestions if int(item["index"]) in selected_set]
if not suggestions:
raise HTTPException(status_code=400, detail="selected_indices 没有匹配到可用 AI 建议")
old_result = await db.execute(
select(ShotReplicateSegment).where(
ShotReplicateSegment.task_set_id == task_set.id,
ShotReplicateSegment.source_mode == ShotSegmentSourceModeEnum.AI_SUGGESTION.value,
ShotReplicateSegment.deleted_at.is_(None),
)
)
old_segments = list(old_result.scalars().all())
if old_segments and not req.replace_existing:
raise HTTPException(status_code=409, detail="已存在 AI 建议拆镜片段,如需重拆请传 replace_existing=true")
if old_segments and req.replace_existing:
now = _now()
for segment in old_segments:
segment.deleted_at = now
created: list[ShotReplicateSegment] = []
next_index = await _next_segment_index(db, task_set.id)
for item in suggestions:
start, end, duration = validate_split_range(
start_second=item["start_second"],
end_second=item["end_second"],
video_duration_seconds=task_set.video_duration_seconds,
)
segment = ShotReplicateSegment(
id=generate_id(),
task_set_id=task_set.id,
user_id=task_set.user_id,
segment_index=next_index,
source_mode=ShotSegmentSourceModeEnum.AI_SUGGESTION.value,
start_second=start,
end_second=end,
duration_seconds=duration,
time_node=build_time_node(start, end),
split_status=ShotSplitStatusEnum.PENDING.value,
analysis_status=ShotSegmentAnalysisStatusEnum.NOT_REQUIRED.value,
replicate_status=ShotSegmentReplicateStatusEnum.NOT_STARTED.value,
original_video_content=task_set.original_video_content,
original_video_category=task_set.original_video_category,
original_video_audience=task_set.original_video_audience,
segment_content=item.get("content"),
segment_category=item.get("category"),
segment_audience=item.get("audience"),
ai_suggestion_json=item.get("raw") or item,
split_enqueued_at=_now(),
split_celery_task_id=f"shot-split:{uuid.uuid4().hex}",
)
db.add(segment)
created.append(segment)
next_index += 1
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
await db.flush()
await refresh_task_set_split_summary(db, task_set.id)
await db.flush()
log_module_event_file(
module=MODULE,
event_type="SHOT_SPLIT_BY_AI_SUBMITTED",
project_id=task_set.id,
user_id=task_set.user_id,
message="按 AI 建议创建拆镜片段",
detail={
"task_set_id": task_set.id,
"selected_indices": req.selected_indices,
"replace_existing": req.replace_existing,
"created_segment_count": len(created),
"segment_ids": [segment.id for segment in created],
},
)
return ShotSplitByAIOut(
task_set_id=task_set.id,
status=task_set.status,
split_status=task_set.split_status,
created_segment_count=len(created),
segments=[_segment_to_out(segment) for segment in created],
)
async def create_custom_segment(
db: AsyncSession,
*,
current_user: User,
task_set_id: str,
req: ShotSplitCustomRequest,
) -> ShotSplitCustomOut:
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
start, end, duration = validate_split_range(
start_second=req.start_second,
end_second=req.end_second,
video_duration_seconds=task_set.video_duration_seconds,
)
next_index = await _next_segment_index(db, task_set.id)
segment = ShotReplicateSegment(
id=generate_id(),
task_set_id=task_set.id,
user_id=task_set.user_id,
segment_index=next_index,
source_mode=ShotSegmentSourceModeEnum.CUSTOM.value,
start_second=start,
end_second=end,
duration_seconds=duration,
time_node=build_time_node(start, end),
split_status=ShotSplitStatusEnum.PENDING.value,
analysis_status=ShotSegmentAnalysisStatusEnum.PENDING.value,
replicate_status=ShotSegmentReplicateStatusEnum.NOT_STARTED.value,
split_enqueued_at=_now(),
split_celery_task_id=f"shot-split:{uuid.uuid4().hex}",
)
db.add(segment)
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
await db.flush()
await refresh_task_set_split_summary(db, task_set.id)
await db.flush()
log_module_event_file(
module=MODULE,
event_type="SHOT_SPLIT_CUSTOM_SUBMITTED",
project_id=task_set.id,
step_id=segment.id,
user_id=task_set.user_id,
message="按用户自定义时间创建拆镜片段",
detail={
"task_set_id": task_set.id,
"segment_id": segment.id,
"start_second": start,
"end_second": end,
"duration_seconds": duration,
"time_node": segment.time_node,
},
)
return ShotSplitCustomOut(task_set_id=task_set.id, segment=_segment_to_out(segment))
async def enqueue_segment_split(segment_id: str, *, countdown: int | None = None, recover: bool = False) -> None:
if not celery_app:
return
from app.tasks.shot_replicate_tasks import split_one_segment
split_one_segment.apply_async(
args=[segment_id],
queue="gen_result_download",
countdown=countdown,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER if recover else settings.DOWNLOAD_TASK_PRIORITY_NORMAL,
)
async def list_segments(
db: AsyncSession,
*,
current_user: User,
task_set_id: str,
source_mode: str | None = None,
split_status: str | None = None,
analysis_status: str | None = None,
replicate_status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> ShotSegmentListOut:
await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user)
query = select(ShotReplicateSegment).where(
ShotReplicateSegment.task_set_id == task_set_id,
ShotReplicateSegment.deleted_at.is_(None),
)
if not current_user.is_admin:
query = query.where(ShotReplicateSegment.user_id == current_user.id)
if source_mode:
query = query.where(ShotReplicateSegment.source_mode == source_mode)
if split_status:
query = query.where(ShotReplicateSegment.split_status == split_status)
if analysis_status:
query = query.where(ShotReplicateSegment.analysis_status == analysis_status)
if replicate_status:
query = query.where(ShotReplicateSegment.replicate_status == replicate_status)
total_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = int(total_result.scalar() or 0)
rows = await db.execute(
query.order_by(ShotReplicateSegment.segment_index.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return ShotSegmentListOut(total=total, page=page, page_size=page_size, items=[_segment_to_out(item) for item in rows.scalars().all()])
async def segment_detail(db: AsyncSession, *, current_user: User, segment_id: str) -> ShotSegmentDetailOut:
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user)
return _segment_to_detail_out(segment)
@@ -0,0 +1,521 @@
from __future__ import annotations
import base64
import json
import mimetypes
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
from app.services.upload_video_asset_service import resolve_upload_video_path
from app.services.resource_signed_url_service import build_resource_signed_url
from app.utils.id_gen import generate_id
AnalysisMode = Literal["full_breakdown", "summary_only"]
@dataclass(slots=True)
class ShotVideoAnalysisResult:
result: dict[str, Any]
raw_response: dict[str, Any]
usage: dict[str, Any]
def _timeout_seconds() -> int:
return int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 180) or 180)
def _video_fps() -> float:
return float(getattr(settings, "SHOT_ANALYSIS_VIDEO_FPS", 1.0) or 1.0)
def _split_min_seconds() -> float:
return float(getattr(settings, "SHOT_SPLIT_MIN_SECONDS", 1) or 1)
def _split_max_seconds() -> float:
return float(getattr(settings, "SHOT_SPLIT_MAX_SECONDS", 120) or 120)
def _resolve_local_file_path(file_url: str) -> str:
if file_url.startswith("/uploads/") or file_url.startswith("uploads/"):
return str(resolve_upload_video_path(file_url))
return file_url
def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4") -> str:
if file_url.startswith(("http://", "https://", "data:")):
return file_url
file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400)
return f"{settings.BASE_URL}{file_url_sign}"
# file_path = _resolve_local_file_path(file_url)
# path = Path(file_path)
# if not path.exists():
# raise FileNotFoundError(f"视频文件不存在: {file_path}")
#
# max_mb = float(getattr(settings, "SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB", 45) or 45)
# size_mb = path.stat().st_size / 1024 / 1024
# if size_mb > max_mb:
# raise ValueError(f"本地视频文件过大: {size_mb:.2f} MB,当前限制 {max_mb:g} MB")
#
# mime = mimetypes.guess_type(str(path))[0] or fallback_mime
# with open(path, "rb") as f:
# b64 = base64.b64encode(f.read()).decode("utf-8")
# return f"data:{mime};base64,{b64}"
def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any]]:
real_url = build_file_url_or_data_uri(video_url)
content_parts = [
{
"type": "video_url",
"video_url": {
"url": real_url,
"fps": _video_fps(),
},
},
{"type": "text", "text": user_text},
]
log_content_parts = [
{
"type": "video_url",
"video_url": {
"url": video_url,
"fps": _video_fps(),
},
},
{"type": "text", "text": user_text},
]
return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts}
def build_video_analysis_system_prompt(*, mode: AnalysisMode) -> str:
if mode == "summary_only":
return """
你是专业的短视频内容分析师、广告素材拆解师。
你的任务:
1. 根据用户提供的视频附件,分析这个视频片段的整体内容。
2. 判断视频分类。
3. 判断视频可能面向的受众人群。
4. 必须输出严格 JSON 对象。
5. 不输出 Markdown。
6. 不输出解释文字。
7. 不返回 null,未知内容填“无”。
顶级字段只能包含:
- 原视频内容
- 原视频分类
- 原视频受众人群
- 拆镜内容剖析
summary_only 模式下“拆镜内容剖析”必须返回空数组。
安全规则:
1. 不要识别视频中人物身份。
2. 不要猜测真实姓名、联系方式、账号身份。
3. 如果视频是游戏录屏,只分析画面内容、玩法内容、玩家情绪表达、受众,不要编造不存在的剧情。
4. 如果视频包含广告内容,可以分析广告品类、目标用户、转化意图,但不要编造品牌信息。
""".strip()
return f"""
你是专业的短视频内容分析师、广告素材拆解师、视频分镜分析师。
你的任务:
1. 根据用户提供的视频附件,分析原视频整体内容。
2. 判断原视频分类。
3. 判断原视频可能面向的受众人群。
4. 对视频进行拆镜内容剖析。
5. 必须输出严格 JSON 对象。
6. 不输出 Markdown。
7. 不输出解释文字。
8. 不返回 null,未知内容填“无”。
顶级字段只能包含:
- 原视频内容
- 原视频分类
- 原视频受众人群
- 拆镜内容剖析
拆镜内容剖析必须是数组。
每个拆镜片段必须包含:
- 拆镜开始秒
- 拆镜结束秒
- 拆镜时间节点
- 对应时间节点内的内容
- 分类
- 受众人群
拆镜时间规则:
1. 拆镜开始秒必须是数字,例如 0、15、26。
2. 拆镜结束秒必须是数字,例如 15、26、31。
3. 拆镜时间节点必须由拆镜开始秒和拆镜结束秒组成,例如“0-15秒”。
4. 禁止输出“-15秒”这种缺少开始秒的时间节点。
5. 禁止输出“15-秒”这种缺少结束秒的时间节点。
6. 每个拆镜片段时长不能低于 {_split_min_seconds():g} 秒。
7. 每个拆镜片段时长不能高于 {_split_max_seconds():g} 秒。
8. 如果某段内容不足 {_split_min_seconds():g} 秒,不要单独拆出来。
9. 如果单个连续内容超过 {_split_max_seconds():g} 秒,需要按语义变化继续拆分。
10. 如果没有明显镜头变化、场景变化、人物动作变化、剧情变化、字幕重点变化或语义变化,不要强行剖析。
11. 如果无法可靠拆镜,则“拆镜内容剖析”返回空数组。
12. 拆镜时间必须从 0 秒或视频中实际可识别的开始时间开始,不允许出现负数。
13. 拆镜结束秒必须大于拆镜开始秒。
14. 拆镜片段必须按时间顺序排列。
安全规则:
1. 不要识别视频中人物身份。
2. 不要猜测真实姓名、联系方式、账号身份。
3. 如果视频是游戏录屏,只分析画面内容、玩法内容、玩家情绪表达、受众,不要编造不存在的剧情。
4. 如果视频包含广告内容,可以分析广告品类、目标用户、转化意图,但不要编造品牌信息。
""".strip()
def build_video_analysis_user_text(*, mode: AnalysisMode) -> str:
if mode == "summary_only":
payload = {
"任务": "请根据上传的视频片段附件,返回这个视频片段的内容分析 JSON。",
"输出JSON格式": {
"原视频内容": "概括这个视频片段整体内容,描述主要画面、主体、场景、动作、剧情或信息点",
"原视频分类": "判断视频类型,例如:游戏视频、产品广告视频、剧情视频、口播讲解视频、教程视频、生活记录视频等",
"原视频受众人群": "判断该片段更适合的人群",
"拆镜内容剖析": [],
},
"返回要求": ["只返回 JSON 对象", "不要返回 Markdown", "不要返回解释文字", "不要返回代码块", "不要返回 null,未知填无"],
}
return json.dumps(payload, ensure_ascii=False)
payload = {
"任务": "请根据上传的视频附件,返回原视频内容分析和拆镜内容剖析 JSON。",
"输出JSON格式": {
"原视频内容": "概括原视频整体内容,描述主要画面、人物/主体、场景、动作、剧情或信息点",
"原视频分类": "判断视频类型,例如:游戏视频、产品广告视频、剧情视频、口播讲解视频、教程视频、生活记录视频、直播切片视频、图文快闪视频等",
"原视频受众人群": "判断该视频更适合的人群,例如:游戏玩家、年轻娱乐用户、潜在购买用户、同城社交用户等",
"拆镜内容剖析": [
{
"拆镜开始秒": 0,
"拆镜结束秒": 15,
"拆镜时间节点": "0-15秒",
"对应时间节点内的内容": "描述这个时间片段内发生了什么",
"分类": "判断这个片段的内容分类,例如:开场吸引、冲突铺垫、玩法展示、卖点展示、情绪爆发、行动引导、结果展示等",
"受众人群": "判断这个片段主要吸引的人群",
}
],
},
"拆镜规则": [
f"每个拆镜片段时长必须大于等于 {_split_min_seconds():g}",
f"每个拆镜片段时长必须小于等于 {_split_max_seconds():g}",
"拆镜开始秒必须是数字",
"拆镜结束秒必须是数字",
"拆镜开始秒不能是负数",
"拆镜结束秒必须大于拆镜开始秒",
"拆镜时间节点必须等于:拆镜开始秒-拆镜结束秒秒",
"禁止输出“-15秒”",
"禁止输出“15-秒”",
"如果无法判断拆镜节点,拆镜内容剖析返回空数组",
],
"返回要求": ["只返回 JSON 对象", "不要返回 Markdown", "不要返回解释文字", "不要返回代码块", "不要返回 null,未知填无"],
}
return json.dumps(payload, ensure_ascii=False)
def strip_json_code_fence(text: str) -> str:
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip()
text = re.sub(r"```$", "", text).strip()
return text
def parse_model_json(content: str) -> dict[str, Any]:
cleaned = strip_json_code_fence(content)
data = json.loads(cleaned)
if not isinstance(data, dict):
raise ValueError(f"模型返回 JSON 不是对象类型: {type(data).__name__}")
return data
def get_message_content_or_raise(data: dict[str, Any]) -> str:
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"模型响应没有 choices: {json.dumps(data, ensure_ascii=False)}")
choice = choices[0]
finish_reason = choice.get("finish_reason")
if finish_reason == "length":
usage = data.get("usage", {})
raise RuntimeError(f"模型输出被长度限制截断,finish_reason={finish_reason}, usage={json.dumps(usage, ensure_ascii=False)}")
message = choice.get("message") or {}
content = message.get("content", "")
if not content:
raise RuntimeError(f"模型响应 content 为空: {json.dumps(data, ensure_ascii=False)}")
return content.strip()
def fill_none_with_wu(value: Any) -> Any:
if value is None:
return ""
if isinstance(value, str):
return value if value.strip() else ""
if isinstance(value, list):
return [fill_none_with_wu(item) for item in value]
if isinstance(value, dict):
return {k: fill_none_with_wu(v) for k, v in value.items()}
return value
def first_present(data: dict[str, Any], keys: list[str]) -> Any:
for key in keys:
if key in data:
return data.get(key)
return None
def parse_number(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
text = str(value or "").strip()
if not text:
return None
m = re.search(r"-?\d+(?:\.\d+)?", text)
return float(m.group(0)) if m else None
def parse_time_value_to_seconds(value: str) -> float | None:
value = str(value or "").strip()
if not value:
return None
m = re.match(r"^(\d+(?:\.\d+)?)\s*(?:秒|s)?$", value, flags=re.IGNORECASE)
if m:
return float(m.group(1))
parts = value.split(":")
if len(parts) in (2, 3) and all(re.match(r"^\d+(?:\.\d+)?$", p.strip()) for p in parts):
nums = [float(p.strip()) for p in parts]
if len(nums) == 2:
minute, second = nums
return minute * 60 + second
hour, minute, second = nums
return hour * 3600 + minute * 60 + second
return None
def parse_time_node_to_range(time_node: str) -> tuple[float, float] | None:
text = str(time_node or "").strip()
if not text:
return None
text = text.replace("", "-").replace("", "-").replace("", "-")
text = text.replace("", "-").replace("", "-").replace("~", "-").replace("", "-")
text = text.replace("", "").replace("时间段", "").replace("", ":")
m = re.search(r"(\d{1,2}:\d{1,2}(?::\d{1,2})?)\s*-\s*(\d{1,2}:\d{1,2}(?::\d{1,2})?)", text)
if m:
start = parse_time_value_to_seconds(m.group(1))
end = parse_time_value_to_seconds(m.group(2))
if start is not None and end is not None and end > start:
return start, end
m = re.search(r"(\d+(?:\.\d+)?)\s*(?:秒|s)?\s*-\s*(\d+(?:\.\d+)?)\s*(?:秒|s)?", text, flags=re.IGNORECASE)
if m:
start = float(m.group(1))
end = float(m.group(2))
if end > start:
return start, end
m = re.search(r"^\s*-\s*(\d+(?:\.\d+)?)\s*(?:秒|s)?\s*$", text, flags=re.IGNORECASE)
if m:
end = float(m.group(1))
if end > 0:
return 0.0, end
return None
def format_second(value: float) -> int | float:
checked = float(value)
return int(checked) if checked.is_integer() else round(checked, 2)
def normalize_time_node_by_range(start: float, end: float) -> str:
return f"{format_second(start)}-{format_second(end)}"
def ensure_result_schema(result: dict[str, Any]) -> dict[str, Any]:
final_result = {
"原视频内容": result.get("原视频内容", ""),
"原视频分类": result.get("原视频分类", ""),
"原视频受众人群": result.get("原视频受众人群", ""),
"拆镜内容剖析": result.get("拆镜内容剖析", []),
}
for key in ("原视频内容", "原视频分类", "原视频受众人群"):
if not isinstance(final_result[key], str):
final_result[key] = json.dumps(final_result[key], ensure_ascii=False)
if not isinstance(final_result["拆镜内容剖析"], list):
final_result["拆镜内容剖析"] = []
return final_result
def filter_and_normalize_breakdown(result: dict[str, Any], *, mode: AnalysisMode = "full_breakdown") -> dict[str, Any]:
if mode == "summary_only":
result["拆镜内容剖析"] = []
return result
breakdown = result.get("拆镜内容剖析")
if not isinstance(breakdown, list):
result["拆镜内容剖析"] = []
return result
normalized_items: list[dict[str, Any]] = []
for item in breakdown:
if not isinstance(item, dict):
continue
raw_start = first_present(item, ["拆镜开始秒", "开始秒", "起始秒", "开始时间", "起始时间", "start", "start_second", "start_seconds"])
raw_end = first_present(item, ["拆镜结束秒", "结束秒", "结束时间", "end", "end_second", "end_seconds"])
start = parse_number(raw_start)
end = parse_number(raw_end)
time_node = str(first_present(item, ["拆镜时间节点", "时间节点", "时间段", "镜头时间", "time_node", "time_range"]) or "").strip()
if start is None or end is None:
parsed_range = parse_time_node_to_range(time_node)
if parsed_range is None:
continue
start, end = parsed_range
if start is None or end is None or start < 0 or end < 0 or end <= start:
continue
duration = end - start
if duration < _split_min_seconds() or duration > _split_max_seconds():
continue
content = first_present(item, ["对应时间节点内的内容", "内容", "画面内容", "片段内容", "镜头内容", "content"]) or ""
category = first_present(item, ["分类", "片段分类", "内容分类", "镜头分类", "category"]) or ""
audience = first_present(item, ["受众人群", "目标受众", "片段受众", "镜头受众", "audience"]) or ""
normalized_items.append({
"拆镜开始秒": format_second(start),
"拆镜结束秒": format_second(end),
"拆镜时间节点": normalize_time_node_by_range(start, end),
"对应时间节点内的内容": str(content or ""),
"分类": str(category or ""),
"受众人群": str(audience or ""),
})
normalized_items.sort(key=lambda x: float(x.get("拆镜开始秒", 0)))
result["拆镜内容剖析"] = normalized_items
return result
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
result = await db.execute(
select(ModelConfig)
.where(ModelConfig.is_active == True)
.order_by(ModelConfig.priority.desc())
.limit(1)
)
return result.scalar_one_or_none()
def _int_usage(value: Any) -> int:
try:
return int(value or 0)
except Exception:
return 0
async def analyze_video_for_shot_split(
db: AsyncSession,
video_url: str,
*,
user_id: str | None = None,
mode: AnalysisMode = "full_breakdown",
) -> ShotVideoAnalysisResult:
"""调用模型完成拆镜/片段分析。
模型配置统一从 model_configs 表选择当前启用且 priority 最高的配置;
不再读取 SHOT_ANALYSIS_API_BASE / SHOT_ANALYSIS_API_KEY / SHOT_ANALYSIS_MODEL_NAME
也不再 fallback 到 SEEDANCE_*,避免拆镜分析走错通道。
"""
config = await _select_model_config(db)
if not config:
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
if not str(config.api_key or "").strip():
raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}")
if not str(config.api_base or "").strip():
raise RuntimeError(f"拆镜分析模型 API Base 为空: model_config_id={config.id}")
if not str(config.model_name or "").strip():
raise RuntimeError(f"拆镜分析模型名称为空: model_config_id={config.id}")
system_prompt = build_video_analysis_system_prompt(mode=mode)
user_text = build_video_analysis_user_text(mode=mode)
user_message, log_user_message = build_user_message(user_text, video_url)
request_data: dict[str, Any] = {
"model": config.model_name,
"messages": [
{"role": "system", "content": system_prompt},
user_message,
],
"max_tokens": int(getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or getattr(config, "max_tokens", 5000) or 5000),
"temperature": float(getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or getattr(config, "temperature", 0.1) or 0.1),
"response_format": {"type": "json_object"},
}
log_request_data: dict[str, Any] = {
**request_data,
"messages": [
{"role": "system", "content": system_prompt},
log_user_message,
],
"model_config_id": config.id,
"model_config_name": config.name,
"provider": config.provider,
"analysis_mode": mode,
}
url = f"{str(config.api_base).rstrip('/')}/chat/completions"
async with httpx.AsyncClient(timeout=_timeout_seconds()) as client:
response = await client.post(
url,
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
json=request_data,
)
if response.status_code >= 400:
raise RuntimeError(f"视频拆镜分析 API 请求失败: HTTP {response.status_code}: {response.text}")
raw = response.json()
content = get_message_content_or_raise(raw)
result = parse_model_json(content)
result = fill_none_with_wu(result)
result = ensure_result_schema(result)
result = filter_and_normalize_breakdown(result, mode=mode)
usage = raw.get("usage") or {}
token_usage = {
"input_tokens": _int_usage(usage.get("prompt_tokens") or usage.get("input_tokens")),
"output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")),
"total_tokens": _int_usage(usage.get("total_tokens")),
"finish_reason": ((raw.get("choices") or [{}])[0] or {}).get("finish_reason"),
"model_config_id": config.id,
"model_config_name": config.name,
"model": config.model_name,
"provider": config.provider,
"video_fps": _video_fps(),
"split_min_seconds": _split_min_seconds(),
"split_max_seconds": _split_max_seconds(),
"analysis_mode": mode,
"log_request": log_request_data,
}
if not token_usage["total_tokens"]:
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
db.add(
TokenUsage(
id=generate_id(),
model_config_id=config.id,
user_id=user_id,
input_tokens=token_usage["input_tokens"],
output_tokens=token_usage["output_tokens"],
total_tokens=token_usage["total_tokens"],
)
)
await db.flush()
return ShotVideoAnalysisResult(result=result, raw_response=raw, usage=token_usage)
@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from app.config import settings
from app.services.upload_video_asset_service import (
build_upload_url_from_path,
ensure_shot_segment_dir,
get_ffmpeg_bin,
)
@dataclass(slots=True)
class ShotSplitResult:
url: str
path: str
file_size_bytes: int
def _date_dir_from_segment_id(segment_id: str) -> str:
# 由调用方更适合按 created_at 传入;这里兜底按当前日期。
from datetime import datetime
return datetime.now().strftime("%Y/%m/%d")
def _safe_unlink(path: Path) -> None:
try:
if path.exists():
path.unlink()
except Exception:
pass
def split_video_segment(
*,
source_path: str | Path,
segment_id: str,
start_second: float,
end_second: float,
date_dir: str | None = None,
) -> ShotSplitResult:
"""使用 ffmpeg 拆出单个视频片段,输出到 storage/uploads/shot_segments。"""
source_path = Path(source_path)
if not source_path.exists():
raise RuntimeError(f"ffmpeg 拆镜失败:源视频不存在 {source_path}")
start = max(float(start_second), 0.0)
end = max(float(end_second), 0.0)
duration = end - start
if duration <= 0:
raise RuntimeError(
f"ffmpeg 拆镜失败:非法时间范围 start_second={start_second}, end_second={end_second}"
)
date_dir = date_dir or _date_dir_from_segment_id(segment_id)
output_dir = ensure_shot_segment_dir(date_dir)
output_path = output_dir / f"{segment_id}.mp4"
# 注意:
# 不能用 xxx.mp4.part,因为 ffmpeg 会按最后一个扩展名 .part 判断输出格式,导致:
# Unable to choose an output format
# 这里改为 xxx.part.mp4,让 ffmpeg 能识别 mp4 容器。
part_path = output_dir / f"{segment_id}.part.mp4"
_safe_unlink(part_path)
timeout = int(getattr(settings, "SHOT_FFMPEG_TIMEOUT_SECONDS", 120) or 120)
cmd = [
get_ffmpeg_bin(),
"-y",
# 先 seek 到起始秒,再按 duration 切割,避免 -to 在不同 ffmpeg 参数位置下语义不一致。
"-ss",
f"{start:.3f}",
"-i",
str(source_path),
"-t",
f"{duration:.3f}",
# 只取主视频流,音频可选,避免 map 0 把字幕/数据流带进去导致 mp4 封装失败。
"-map",
"0:v:0",
"-map",
"0:a:0?",
# 当前是拆镜片段,重编码更稳,避免关键帧不准导致片段首尾异常。
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
# 即使临时文件扩展名未来被改坏,也强制指定 mp4 muxer。
"-f",
"mp4",
str(part_path),
]
try:
completed = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
_safe_unlink(part_path)
raise RuntimeError(
f"ffmpeg 拆镜超时:timeout={timeout}s, start={start:.3f}, end={end:.3f}"
) from exc
if completed.returncode != 0:
_safe_unlink(part_path)
raise RuntimeError(
f"ffmpeg 拆镜失败: {completed.stderr.strip() or completed.stdout.strip()}"
)
if not part_path.exists() or part_path.stat().st_size <= 0:
_safe_unlink(part_path)
raise RuntimeError("ffmpeg 拆镜失败:输出文件为空")
os.replace(part_path, output_path)
return ShotSplitResult(
url=build_upload_url_from_path(output_path),
path=str(output_path),
file_size_bytes=output_path.stat().st_size,
)
async def split_video_segment_async(
*,
source_path: str | Path,
segment_id: str,
start_second: float,
end_second: float,
date_dir: str | None = None,
) -> ShotSplitResult:
"""异步拆镜入口。
ffmpeg 本身是同步阻塞命令,不能直接在 Celery 进程内唯一 event loop 中执行。
这里通过 asyncio.to_thread 跑同步拆镜函数,避免阻塞 asyncpg / Redis / HTTP 等异步任务。
"""
return await asyncio.to_thread(
split_video_segment,
source_path=source_path,
segment_id=segment_id,
start_second=start_second,
end_second=end_second,
date_dir=date_dir,
)
@@ -0,0 +1,211 @@
from __future__ import annotations
import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from fastapi import HTTPException
from app.config import settings
VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
@dataclass(slots=True)
class UploadVideoAsset:
url: str
path: Path
duration_seconds: float
def _project_root() -> Path:
return Path.cwd()
def _abs_path(value: str | Path) -> Path:
path = Path(value)
if not path.is_absolute():
path = _project_root() / path
return path.resolve()
def upload_root() -> Path:
return _abs_path(settings.UPLOAD_LOCAL_PATH)
def shot_segment_root() -> Path:
return _abs_path(getattr(settings, "SHOT_SEGMENT_LOCAL_PATH", "./storage/uploads/shot_segments"))
def _strip_base_url(url: str) -> str:
text = str(url or "").strip()
if not text:
return text
base_url = str(getattr(settings, "BASE_URL", "") or "").strip().rstrip("/")
if base_url and text.startswith(base_url + "/"):
return text[len(base_url):]
parsed = urlparse(text)
if parsed.scheme in ("http", "https"):
# 只接受本系统 BASE_URL 下的上传资源;外部 URL 不允许 ffmpeg 本地切片。
raise HTTPException(status_code=400, detail="拆镜源视频必须来自本系统上传接口,不能传外部 http/https URL")
return text
def _safe_relative_from_upload_url(url: str) -> str:
value = _strip_base_url(url)
value = value.split("?", 1)[0].split("#", 1)[0]
if value.startswith("/uploads/"):
rel = value.replace("/uploads/", "", 1)
elif value.startswith("uploads/"):
rel = value.replace("uploads/", "", 1)
else:
raise HTTPException(status_code=400, detail="拆镜源视频链接必须是 /uploads/ 下的上传资源")
rel = rel.lstrip("/")
if not rel or ".." in Path(rel).parts:
raise HTTPException(status_code=400, detail="上传视频路径非法")
return rel
def resolve_upload_video_path(video_url: str) -> Path:
rel = _safe_relative_from_upload_url(video_url)
root = upload_root()
path = (root / rel).resolve()
try:
path.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=400, detail="上传视频路径越界") from exc
if path.suffix.lower() not in VIDEO_EXTENSIONS:
raise HTTPException(status_code=400, detail="上传资源不是支持的视频格式")
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail=f"上传视频文件不存在: {video_url}")
return path
def build_upload_url_from_path(path: str | Path) -> str:
root = upload_root()
checked_path = _abs_path(path)
try:
rel = checked_path.relative_to(root).as_posix()
except ValueError as exc:
raise HTTPException(status_code=500, detail="生成上传资源 URL 失败:路径不在 uploads 目录下") from exc
return f"/uploads/{rel}"
def get_ffmpeg_bin() -> str:
return str(getattr(settings, "FFMPEG_BIN", "") or "ffmpeg")
def get_ffprobe_bin() -> str:
configured = str(getattr(settings, "FFPROBE_BIN", "") or "").strip()
if configured:
return configured
ffmpeg_bin = get_ffmpeg_bin()
if ffmpeg_bin.endswith("ffmpeg.exe"):
return ffmpeg_bin[:-10] + "ffprobe.exe"
if ffmpeg_bin.endswith("ffmpeg"):
return ffmpeg_bin[:-6] + "ffprobe"
return "ffprobe"
def probe_video_duration_seconds(video_path: str | Path) -> float:
path = _abs_path(video_path)
timeout = int(getattr(settings, "SHOT_FFPROBE_TIMEOUT_SECONDS", 20) or 20)
cmd = [
get_ffprobe_bin(),
"-v", "error",
"-show_entries", "format=duration",
"-of", "json",
str(path),
]
try:
completed = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
check=False,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"ffprobe 获取视频时长失败: {exc}") from exc
if completed.returncode != 0:
raise HTTPException(status_code=400, detail=f"ffprobe 获取视频时长失败: {completed.stderr.strip()}")
try:
data = json.loads(completed.stdout or "{}")
duration = float((data.get("format") or {}).get("duration") or 0)
except Exception as exc:
raise HTTPException(status_code=400, detail="ffprobe 返回的视频时长无法解析") from exc
if duration <= 0:
raise HTTPException(status_code=400, detail="视频时长无效")
return round(duration, 3)
def validate_upload_video_asset(video_url: str, frontend_duration_seconds: float | None = None) -> UploadVideoAsset:
path = resolve_upload_video_path(video_url)
real_duration = probe_video_duration_seconds(path)
if frontend_duration_seconds is not None and frontend_duration_seconds > 0:
tolerance = float(getattr(settings, "SHOT_DURATION_TOLERANCE_SECONDS", 1.0) or 1.0)
# 超出误差时以后端 ffprobe 为准,不拒绝,避免前端浮点或浏览器 metadata 偏差导致创建失败。
if abs(float(frontend_duration_seconds) - real_duration) <= tolerance:
real_duration = round(float(frontend_duration_seconds), 3)
return UploadVideoAsset(url=_strip_base_url(video_url), path=path, duration_seconds=real_duration)
def validate_split_range(*, start_second: float, end_second: float, video_duration_seconds: float) -> tuple[float, float, float]:
start = round(float(start_second), 3)
end = round(float(end_second), 3)
if start < 0:
raise HTTPException(status_code=400, detail="开始秒不能小于0")
if end <= start:
raise HTTPException(status_code=400, detail="结束秒必须大于开始秒")
tolerance = float(getattr(settings, "SHOT_SPLIT_END_TOLERANCE_SECONDS", 0.5) or 0.5)
if end > float(video_duration_seconds) + tolerance:
raise HTTPException(status_code=400, detail="结束秒不能超过视频总时长")
duration = round(end - start, 3)
min_seconds = float(getattr(settings, "SHOT_SPLIT_MIN_SECONDS", 1) or 1)
max_seconds = float(getattr(settings, "SHOT_SPLIT_MAX_SECONDS", 120) or 120)
if duration < min_seconds:
raise HTTPException(status_code=400, detail=f"拆镜片段不能低于 {min_seconds:g}")
if duration > max_seconds:
raise HTTPException(status_code=400, detail=f"拆镜片段不能超过 {max_seconds:g}")
return start, end, duration
def format_second(value: float) -> int | float:
checked = float(value)
if checked.is_integer():
return int(checked)
return round(checked, 2)
def build_time_node(start_second: float, end_second: float) -> str:
return f"{format_second(start_second)}-{format_second(end_second)}"
def ensure_shot_segment_dir(date_dir: str) -> Path:
root = shot_segment_root()
output_dir = (root / date_dir).resolve()
try:
output_dir.relative_to(root)
except ValueError as exc:
raise RuntimeError("拆镜输出目录越界") from exc
os.makedirs(output_dir, exist_ok=True)
return output_dir