拆镜复刻开发完成

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
@@ -0,0 +1,161 @@
"""add shot replicate tables
Revision ID: dbb11c0b5a0a
Revises: 9ac2212e1b8e
Create Date: 2026-06-11 14:59:26.871902
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'dbb11c0b5a0a'
down_revision: Union[str, None] = '9ac2212e1b8e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('shot_replicate_task_sets',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('title', sa.String(length=160), nullable=True),
sa.Column('video_url', sa.String(length=512), nullable=False),
sa.Column('video_path', sa.String(length=512), nullable=False),
sa.Column('video_duration_seconds', sa.Float(), nullable=False),
sa.Column('status', sa.String(length=32), nullable=False),
sa.Column('analysis_status', sa.String(length=32), nullable=False),
sa.Column('split_status', sa.String(length=32), nullable=False),
sa.Column('original_video_content', sa.Text(), nullable=True),
sa.Column('original_video_category', sa.String(length=160), nullable=True),
sa.Column('original_video_audience', sa.Text(), nullable=True),
sa.Column('ai_suggestion_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('analysis_raw_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('analysis_result_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('segment_count', sa.Integer(), nullable=False),
sa.Column('completed_segment_count', sa.Integer(), nullable=False),
sa.Column('failed_segment_count', sa.Integer(), nullable=False),
sa.Column('analysis_error_message', sa.Text(), nullable=True),
sa.Column('split_error_message', sa.Text(), nullable=True),
sa.Column('idempotency_key', sa.String(length=64), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_shot_replicate_task_sets_analysis_status', 'shot_replicate_task_sets', ['analysis_status'], unique=False)
op.create_index('idx_shot_replicate_task_sets_split_status', 'shot_replicate_task_sets', ['split_status'], unique=False)
op.create_index('idx_shot_replicate_task_sets_status', 'shot_replicate_task_sets', ['status'], unique=False)
op.create_index('idx_shot_replicate_task_sets_user_created', 'shot_replicate_task_sets', ['user_id', 'created_at'], unique=False)
op.create_index(op.f('ix_shot_replicate_task_sets_analysis_status'), 'shot_replicate_task_sets', ['analysis_status'], unique=False)
op.create_index(op.f('ix_shot_replicate_task_sets_deleted_at'), 'shot_replicate_task_sets', ['deleted_at'], unique=False)
op.create_index(op.f('ix_shot_replicate_task_sets_idempotency_key'), 'shot_replicate_task_sets', ['idempotency_key'], unique=False)
op.create_index(op.f('ix_shot_replicate_task_sets_split_status'), 'shot_replicate_task_sets', ['split_status'], unique=False)
op.create_index(op.f('ix_shot_replicate_task_sets_status'), 'shot_replicate_task_sets', ['status'], unique=False)
op.create_index(op.f('ix_shot_replicate_task_sets_user_id'), 'shot_replicate_task_sets', ['user_id'], unique=False)
op.create_index('uq_shot_replicate_task_sets_user_idempotency', 'shot_replicate_task_sets', ['user_id', 'idempotency_key'], unique=True, postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL'))
op.create_table('shot_replicate_segments',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('task_set_id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('segment_index', sa.Integer(), nullable=False),
sa.Column('source_mode', sa.String(length=32), nullable=False),
sa.Column('start_second', sa.Float(), nullable=False),
sa.Column('end_second', sa.Float(), nullable=False),
sa.Column('duration_seconds', sa.Float(), nullable=False),
sa.Column('time_node', sa.String(length=64), nullable=False),
sa.Column('split_status', sa.String(length=32), nullable=False),
sa.Column('analysis_status', sa.String(length=32), nullable=False),
sa.Column('replicate_status', sa.String(length=32), nullable=False),
sa.Column('segment_video_url', sa.String(length=512), nullable=True),
sa.Column('segment_video_path', sa.String(length=512), nullable=True),
sa.Column('original_video_content', sa.Text(), nullable=True),
sa.Column('original_video_category', sa.String(length=160), nullable=True),
sa.Column('original_video_audience', sa.Text(), nullable=True),
sa.Column('segment_content', sa.Text(), nullable=True),
sa.Column('segment_category', sa.String(length=160), nullable=True),
sa.Column('segment_audience', sa.Text(), nullable=True),
sa.Column('analysis_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('ai_suggestion_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('module_project_id', sa.String(length=32), nullable=True),
sa.Column('split_celery_task_id', sa.String(length=160), nullable=True),
sa.Column('split_enqueued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('split_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('split_lease_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('split_next_retry_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('split_retry_count', sa.Integer(), nullable=False),
sa.Column('split_last_error', sa.Text(), nullable=True),
sa.Column('split_completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('analysis_error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['task_set_id'], ['shot_replicate_task_sets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_shot_replicate_segments_analysis_status', 'shot_replicate_segments', ['analysis_status'], unique=False)
op.create_index('idx_shot_replicate_segments_project', 'shot_replicate_segments', ['module_project_id'], unique=False)
op.create_index('idx_shot_replicate_segments_replicate_status', 'shot_replicate_segments', ['replicate_status'], unique=False)
op.create_index('idx_shot_replicate_segments_source_mode', 'shot_replicate_segments', ['source_mode'], unique=False)
op.create_index('idx_shot_replicate_segments_split_status', 'shot_replicate_segments', ['split_status'], unique=False)
op.create_index('idx_shot_replicate_segments_task_set', 'shot_replicate_segments', ['task_set_id', 'segment_index'], unique=False)
op.create_index('idx_shot_replicate_segments_user_created', 'shot_replicate_segments', ['user_id', 'created_at'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_analysis_status'), 'shot_replicate_segments', ['analysis_status'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_deleted_at'), 'shot_replicate_segments', ['deleted_at'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_module_project_id'), 'shot_replicate_segments', ['module_project_id'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_replicate_status'), 'shot_replicate_segments', ['replicate_status'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_segment_index'), 'shot_replicate_segments', ['segment_index'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_source_mode'), 'shot_replicate_segments', ['source_mode'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_split_celery_task_id'), 'shot_replicate_segments', ['split_celery_task_id'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_split_lease_until'), 'shot_replicate_segments', ['split_lease_until'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_split_next_retry_at'), 'shot_replicate_segments', ['split_next_retry_at'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_split_status'), 'shot_replicate_segments', ['split_status'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_task_set_id'), 'shot_replicate_segments', ['task_set_id'], unique=False)
op.create_index(op.f('ix_shot_replicate_segments_user_id'), 'shot_replicate_segments', ['user_id'], unique=False)
op.create_index('uq_shot_replicate_segments_task_set_index_active', 'shot_replicate_segments', ['task_set_id', 'segment_index'], unique=True, postgresql_where=sa.text('deleted_at IS NULL'))
op.drop_column('user_oauth_app', 'count')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user_oauth_app', sa.Column('count', sa.BIGINT(), autoincrement=False, nullable=False, comment='应用最大可以授权多少个用户'))
op.drop_index('uq_shot_replicate_segments_task_set_index_active', table_name='shot_replicate_segments', postgresql_where=sa.text('deleted_at IS NULL'))
op.drop_index(op.f('ix_shot_replicate_segments_user_id'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_task_set_id'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_split_status'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_split_next_retry_at'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_split_lease_until'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_split_celery_task_id'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_source_mode'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_segment_index'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_replicate_status'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_module_project_id'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_deleted_at'), table_name='shot_replicate_segments')
op.drop_index(op.f('ix_shot_replicate_segments_analysis_status'), table_name='shot_replicate_segments')
op.drop_index('idx_shot_replicate_segments_user_created', table_name='shot_replicate_segments')
op.drop_index('idx_shot_replicate_segments_task_set', table_name='shot_replicate_segments')
op.drop_index('idx_shot_replicate_segments_split_status', table_name='shot_replicate_segments')
op.drop_index('idx_shot_replicate_segments_source_mode', table_name='shot_replicate_segments')
op.drop_index('idx_shot_replicate_segments_replicate_status', table_name='shot_replicate_segments')
op.drop_index('idx_shot_replicate_segments_project', table_name='shot_replicate_segments')
op.drop_index('idx_shot_replicate_segments_analysis_status', table_name='shot_replicate_segments')
op.drop_table('shot_replicate_segments')
op.drop_index('uq_shot_replicate_task_sets_user_idempotency', table_name='shot_replicate_task_sets', postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL'))
op.drop_index(op.f('ix_shot_replicate_task_sets_user_id'), table_name='shot_replicate_task_sets')
op.drop_index(op.f('ix_shot_replicate_task_sets_status'), table_name='shot_replicate_task_sets')
op.drop_index(op.f('ix_shot_replicate_task_sets_split_status'), table_name='shot_replicate_task_sets')
op.drop_index(op.f('ix_shot_replicate_task_sets_idempotency_key'), table_name='shot_replicate_task_sets')
op.drop_index(op.f('ix_shot_replicate_task_sets_deleted_at'), table_name='shot_replicate_task_sets')
op.drop_index(op.f('ix_shot_replicate_task_sets_analysis_status'), table_name='shot_replicate_task_sets')
op.drop_index('idx_shot_replicate_task_sets_user_created', table_name='shot_replicate_task_sets')
op.drop_index('idx_shot_replicate_task_sets_status', table_name='shot_replicate_task_sets')
op.drop_index('idx_shot_replicate_task_sets_split_status', table_name='shot_replicate_task_sets')
op.drop_index('idx_shot_replicate_task_sets_analysis_status', table_name='shot_replicate_task_sets')
op.drop_table('shot_replicate_task_sets')
# ### end Alembic commands ###
+2
View File
@@ -16,6 +16,7 @@ from app.api.v1.video_engines import router as video_engines_router
from app.api.v1.image_engines import router as image_engines_router
from app.api.v1.generation_ai import router as generation_ai_router
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
from app.api.v1.shot_replicate import router as shot_replicate_router
from app.api.v1.test import router as test_router
from app.api.v1.user_oauth import router as user_oauth_router
from app.api.v1.user_oauth_app import router as user_oauth_app_router
@@ -37,6 +38,7 @@ api_router.include_router(video_engines_router)
api_router.include_router(image_engines_router)
api_router.include_router(generation_ai_router)
api_router.include_router(hot_opening_replicate_router)
api_router.include_router(shot_replicate_router)
api_router.include_router(test_router)
api_router.include_router(user_oauth_router)
api_router.include_router(user_oauth_app_router)
@@ -1,10 +1,14 @@
from __future__ import annotations
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.hot_opening_replicate import ModuleCodeEnum
from app.schemas.hot_opening_replicate import (
HotOpeningActionOut,
HotOpeningDeleteOut,
@@ -35,14 +39,94 @@ from app.services.hot_opening_replicate_service import (
update_hot_opening_material_input,
update_hot_opening_video_prompt_schema,
)
from app.services.module_generation_log_service import log_module_error
from app.tasks.celery_app import celery_app
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
def _safe_user_id(user: object | None) -> str | None:
"""从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。"""
if user is None:
return None
try:
value = getattr(user, "__dict__", {}).get("id")
if value is not None:
return str(value)
except Exception:
pass
try:
identity = sa_inspect(user).identity
if identity:
return str(identity[0])
except Exception:
pass
return None
def _safe_user_is_admin(user: object | None) -> bool:
"""安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。"""
if user is None:
return False
try:
data = getattr(user, "__dict__", {})
if "is_admin" in data:
return bool(data.get("is_admin"))
except Exception:
pass
return False
def _user_context(user: object | None) -> SimpleNamespace:
return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user))
router = APIRouter(
prefix="/hot-opening-replications",
tags=["hot-opening-replications"],
)
def _log_api_error(
*,
event_type: str,
current_user: User | None = None,
project_id: str | None = None,
step_id: str | None = None,
message: str | None = None,
exc: BaseException | None = None,
detail: dict | None = None,
) -> None:
log_module_error(
module=MODULE,
event_type=event_type,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail=detail,
exc=exc,
)
def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None:
current_user = local_values.get("current_user")
project_id = local_values.get("project_id_value") or local_values.get("project_id")
step_id = local_values.get("step_id_value") or local_values.get("step_id")
req = local_values.get("req")
detail = {"request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None}
_log_api_error(
event_type="API_REQUEST_FAILED",
current_user=current_user if isinstance(current_user, User) else None,
project_id=str(project_id) if project_id else None,
step_id=str(step_id) if step_id else None,
message=message,
detail=detail,
exc=exc,
)
async def _reload_project_detail(
db: AsyncSession,
current_user: User,
@@ -52,7 +136,7 @@ async def _reload_project_detail(
project = await _get_project_for_user(
db,
project_id=project_id,
user=current_user,
user=_user_context(current_user),
for_update=False,
populate_existing=True,
)
@@ -72,14 +156,33 @@ async def _mark_dispatch_failed_and_raise(
try:
await mark_hot_opening_step_dispatch_failed(
db,
current_user=current_user,
current_user=_user_context(current_user),
project_id=project_id,
step_id=step_id,
error_message=message,
)
await db.commit()
except Exception:
except Exception as exc:
await db.rollback()
_log_api_error(
event_type="CELERY_DISPATCH_MARK_FAILED",
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery 投递失败后标记步骤失败也失败",
detail={"dispatch_error": message},
exc=exc,
)
log_module_error(
module=MODULE,
event_type="CELERY_DISPATCH_FAILED",
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail={"reason": "celery_dispatch_failed"},
error=message,
)
raise HTTPException(status_code=503, detail=message)
@@ -118,6 +221,7 @@ async def create_task(
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"创建爆款开头复刻项目失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
return await _reload_project_detail(db, current_user, project_id_value)
@@ -185,6 +289,7 @@ async def update_material(
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}")
return HotOpeningActionOut(
@@ -228,6 +333,7 @@ async def update_image_prompt(
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}")
return HotOpeningActionOut(
@@ -300,6 +406,14 @@ async def generate_image_prompt(
):
_ = req
if celery_app is None:
_log_api_error(
event_type="CELERY_DISABLED",
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
detail={"api": "hot_opening_replicate"},
)
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
@@ -312,6 +426,7 @@ async def generate_image_prompt(
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"图片提词任务创建失败: {exc}")
raise HTTPException(status_code=500, detail=f"图片提词任务创建失败: {exc}")
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
@@ -354,6 +469,14 @@ async def generate_image(
db: AsyncSession = Depends(get_db),
):
if celery_app is None:
_log_api_error(
event_type="CELERY_DISABLED",
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
detail={"api": "hot_opening_replicate"},
)
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
@@ -369,6 +492,7 @@ async def generate_image(
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"图片生成任务创建失败: {exc}")
raise HTTPException(status_code=500, detail=f"图片生成任务创建失败: {exc}")
from app.tasks.generation_create_tasks import chatapi_create_generation_task
@@ -411,6 +535,14 @@ async def generate_video_prompt(
db: AsyncSession = Depends(get_db),
):
if celery_app is None:
_log_api_error(
event_type="CELERY_DISABLED",
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
detail={"api": "hot_opening_replicate"},
)
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
@@ -423,6 +555,7 @@ async def generate_video_prompt(
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"视频提词任务创建失败: {exc}")
raise HTTPException(status_code=500, detail=f"视频提词任务创建失败: {exc}")
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
@@ -466,6 +599,14 @@ async def generate_video(
db: AsyncSession = Depends(get_db),
):
if celery_app is None:
_log_api_error(
event_type="CELERY_DISABLED",
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
detail={"api": "hot_opening_replicate"},
)
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
@@ -481,6 +622,7 @@ async def generate_video(
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"视频生成任务创建失败: {exc}")
raise HTTPException(status_code=500, detail=f"视频生成任务创建失败: {exc}")
from app.tasks.generation_create_tasks import chatapi_create_generation_task
+668
View File
@@ -0,0 +1,668 @@
from __future__ import annotations
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.shot_replicate import ModuleCodeEnum
from app.schemas.shot_replicate import (
ShotReplicateActionOut,
ShotReplicateDeleteOut,
ShotReplicateGenerateImagePromptRequest,
ShotReplicateGenerateImageRequest,
ShotReplicateGenerateVideoPromptRequest,
ShotReplicateGenerateVideoRequest,
ShotReplicateImagePromptUpdateRequest,
ShotReplicateMaterialUpdateRequest,
ShotReplicateSpecOut,
ShotReplicateTaskDetailOut,
ShotReplicateVideoPromptSchemaUpdateRequest,
ShotSegmentDetailOut,
ShotSegmentListOut,
ShotSegmentReplicationCreateRequest,
ShotSplitByAIOut,
ShotSplitByAIRequest,
ShotSplitCustomOut,
ShotSplitCustomRequest,
ShotTaskSetCreate,
ShotTaskSetDetailOut,
ShotTaskSetListOut,
)
from app.services.shot_replicate_flow_service import (
_get_project_for_user,
create_shot_replicate_project_from_segment,
delete_shot_replicate_project,
generate_image_from_prompt,
generate_video_from_prompt,
mark_shot_replicate_step_dispatch_failed,
project_to_detail_out,
submit_image_prompt_optimize,
submit_video_prompt_optimize,
update_shot_replicate_image_prompt,
update_shot_replicate_material_input,
update_shot_replicate_video_prompt_schema,
)
from app.services.shot_replicate_taskset_service import (
create_custom_segment,
create_segments_by_ai,
create_task_set,
get_segment_for_user,
list_segments,
list_task_sets,
segment_detail,
task_set_detail,
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.tasks.celery_app import celery_app
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
def _safe_user_id(user: object | None) -> str | None:
"""从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。"""
if user is None:
return None
try:
value = getattr(user, "__dict__", {}).get("id")
if value is not None:
return str(value)
except Exception:
pass
try:
identity = sa_inspect(user).identity
if identity:
return str(identity[0])
except Exception:
pass
return None
def _safe_user_is_admin(user: object | None) -> bool:
"""安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。"""
if user is None:
return False
try:
data = getattr(user, "__dict__", {})
if "is_admin" in data:
return bool(data.get("is_admin"))
except Exception:
pass
return False
def _user_context(user: object | None) -> SimpleNamespace:
return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user))
router = APIRouter(prefix="/shot-replications", tags=["shot-replications"])
def _log_api_error(
*,
event_type: str,
current_user: User | None = None,
project_id: str | None = None,
step_id: str | None = None,
message: str | None = None,
exc: BaseException | None = None,
detail: dict | None = None,
) -> None:
log_module_error(
module=MODULE,
event_type=event_type,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail=detail,
exc=exc,
)
def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None:
current_user = local_values.get("current_user")
project_id = local_values.get("project_id_value") or local_values.get("project_id") or local_values.get("task_set_id") or local_values.get("task_set_id_value")
step_id = local_values.get("step_id_value") or local_values.get("step_id") or local_values.get("segment_id") or local_values.get("segment_id_value")
req = local_values.get("req")
detail = {"api": local_values.get("__name__"), "request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None}
_log_api_error(
event_type="API_REQUEST_FAILED",
current_user=current_user if isinstance(current_user, User) else None,
project_id=str(project_id) if project_id else None,
step_id=str(step_id) if step_id else None,
message=message,
detail=detail,
exc=exc,
)
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
project = await _get_project_for_user(
db,
project_id=project_id,
user=_user_context(current_user),
for_update=False,
populate_existing=True,
)
return await project_to_detail_out(db, project)
async def _mark_dispatch_failed_and_raise(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str | None,
message: str,
) -> None:
if step_id:
try:
await mark_shot_replicate_step_dispatch_failed(
db,
current_user=_user_context(current_user),
project_id=project_id,
step_id=step_id,
error_message=message,
)
await db.commit()
except Exception as exc:
await db.rollback()
_log_api_error(
event_type="CELERY_DISPATCH_MARK_FAILED",
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery 投递失败后标记步骤失败也失败",
detail={"dispatch_error": message},
exc=exc,
)
log_module_error(
module=MODULE,
event_type="CELERY_DISPATCH_FAILED",
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail={"reason": "celery_dispatch_failed"},
error=message,
)
raise HTTPException(status_code=503, detail=message)
@router.get(
"/spec",
response_model=ShotReplicateSpecOut,
summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明",
)
async def get_spec():
return ShotReplicateSpecOut()
@router.post(
"/task-sets",
response_model=ShotTaskSetDetailOut,
summary="创建拆镜总任务集并异步分析原视频",
)
async def create_shot_task_set(
req: ShotTaskSetCreate = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
task_set = await create_task_set(db, current_user=current_user, req=req)
task_set_id = task_set.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
if celery_app:
try:
from app.tasks.shot_replicate_tasks import analyze_original_video
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
# 分析任务投递失败时保留总任务,前端可稍后通过恢复/重试处理。
_log_api_error(
event_type="CELERY_DISPATCH_FAILED",
current_user=current_user,
project_id=task_set_id,
message=f"拆镜分析任务投递失败: {exc}",
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc,
)
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
@router.get(
"/task-sets",
response_model=ShotTaskSetListOut,
summary="查询拆镜总任务集列表",
)
async def list_shot_task_sets(
status: str | None = Query(None, description="总任务状态,见 ShotTaskSetStatusEnum"),
analysis_status: str | None = Query(None, description="分析状态,见 ShotAnalysisStatusEnum"),
split_status: str | None = Query(None, description="拆镜状态,见 ShotSplitStatusEnum"),
keyword: str | None = Query(None, description="标题/内容关键词"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_task_sets(
db,
current_user=current_user,
status=status,
analysis_status=analysis_status,
split_status=split_status,
keyword=keyword,
page=page,
page_size=page_size,
)
@router.get(
"/task-sets/{task_set_id}",
response_model=ShotTaskSetDetailOut,
summary="获取拆镜总任务集详情",
)
async def get_shot_task_set(
task_set_id: str = Path(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
@router.post(
"/task-sets/{task_set_id}/split-by-ai",
response_model=ShotSplitByAIOut,
summary="按 AI 建议方案异步拆镜",
)
async def split_by_ai(
task_set_id: str = Path(...),
req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
out = await create_segments_by_ai(db, current_user=current_user, task_set_id=task_set_id, req=req)
segment_ids = [item.id for item in out.segments]
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"按 AI 建议拆镜失败: {exc}")
raise HTTPException(status_code=500, detail=f"按 AI 建议拆镜失败: {exc}")
if celery_app:
from app.tasks.shot_replicate_tasks import split_one_segment
for segment_id in segment_ids:
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
return out
@router.post(
"/task-sets/{task_set_id}/split-custom",
response_model=ShotSplitCustomOut,
summary="按用户自定义开始/结束秒异步拆单条片段",
)
async def split_custom(
task_set_id: str = Path(...),
req: ShotSplitCustomRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
out = await create_custom_segment(db, current_user=current_user, task_set_id=task_set_id, req=req)
segment_id = out.segment.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"自定义拆镜失败: {exc}")
raise HTTPException(status_code=500, detail=f"自定义拆镜失败: {exc}")
if celery_app:
from app.tasks.shot_replicate_tasks import split_one_segment
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
return out
@router.get(
"/task-sets/{task_set_id}/segments",
response_model=ShotSegmentListOut,
summary="查询拆镜片段列表",
)
async def list_task_set_segments(
task_set_id: str = Path(...),
source_mode: str | None = Query(None, description="ai_suggestion/custom"),
split_status: str | None = Query(None, description="拆镜状态"),
analysis_status: str | None = Query(None, description="片段分析状态"),
replicate_status: str | None = Query(None, description="复刻状态"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_segments(
db,
current_user=current_user,
task_set_id=task_set_id,
source_mode=source_mode,
split_status=split_status,
analysis_status=analysis_status,
replicate_status=replicate_status,
page=page,
page_size=page_size,
)
@router.get(
"/segments/{segment_id}",
response_model=ShotSegmentDetailOut,
summary="获取拆镜片段详情",
)
async def get_segment(
segment_id: str = Path(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await segment_detail(db, current_user=current_user, segment_id=segment_id)
@router.post(
"/segments/{segment_id}/replication-projects",
response_model=ShotReplicateActionOut,
summary="将拆镜片段创建为拆镜复刻项目",
)
async def create_replication_project_from_segment(
segment_id: str = Path(...),
req: ShotSegmentReplicationCreateRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req)
project_id = project.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建拆镜复刻项目失败: {exc}")
return ShotReplicateActionOut(
message="已从拆镜片段创建复刻项目,素材视频已锁定",
project_id=project_id,
step_id=None,
detail=await _reload_project_detail(db, current_user, project_id),
)
@router.get(
"/projects/{project_id}",
response_model=ShotReplicateTaskDetailOut,
summary="获取拆镜复刻项目详情",
)
async def get_project(
project_id: str = Path(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await _reload_project_detail(db, current_user, project_id)
@router.put(
"/projects/{project_id}/material",
response_model=ShotReplicateActionOut,
summary="修改拆镜复刻素材信息,素材视频不允许修改",
)
async def update_material(
project_id: str = Path(...),
req: ShotReplicateMaterialUpdateRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project_id_value, step_id_value = await update_shot_replicate_material_input(db, current_user=current_user, project_id=project_id, req=req)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}")
return ShotReplicateActionOut(message="素材输入已修改,素材视频保持锁定", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.put(
"/projects/{project_id}/steps/{step_id}/image-prompt",
response_model=ShotReplicateActionOut,
summary="直接修改图片 AI 优化提词",
)
async def update_image_prompt(
project_id: str = Path(...),
step_id: str = Path(...),
req: ShotReplicateImagePromptUpdateRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_shot_replicate_image_prompt(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}")
return ShotReplicateActionOut(message="图片 AI 提词已修改,后续步骤已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.put(
"/projects/{project_id}/steps/{step_id}/video-prompt-schema",
response_model=ShotReplicateActionOut,
summary="修改视频 AI 提词 JSON schema",
)
async def update_video_prompt_schema(
project_id: str = Path(...),
step_id: str = Path(...),
req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_shot_replicate_video_prompt_schema(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改视频 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改视频 AI 提词失败: {exc}")
return ShotReplicateActionOut(message="视频 AI 提词 schema 已修改,第5步视频生成已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/generate-image-prompt",
response_model=ShotReplicateActionOut,
summary="生成图片 AI 提词",
)
async def generate_image_prompt(
project_id: str = Path(...),
req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
try:
if celery_app:
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}")
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/generate-image",
response_model=ShotReplicateActionOut,
summary="根据图片 AI 提词生成图片",
)
async def generate_image(
project_id: str = Path(...),
req: ShotReplicateGenerateImageRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, req=req)
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交图片生成失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交图片生成失败: {exc}")
try:
if celery_app:
from app.tasks.generation_create_tasks import chatapi_create_generation_task
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片生成任务投递失败: {exc}")
return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/generate-video-prompt",
response_model=ShotReplicateActionOut,
summary="生成视频 AI 提词 JSON schema",
)
async def generate_video_prompt(
project_id: str = Path(...),
req: ShotReplicateGenerateVideoPromptRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
try:
if celery_app:
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}")
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/generate-video",
response_model=ShotReplicateActionOut,
summary="根据视频 AI 提词生成视频",
)
async def generate_video(
project_id: str = Path(...),
req: ShotReplicateGenerateVideoRequest = Body(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, req=req)
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交视频生成失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交视频生成失败: {exc}")
try:
if celery_app:
from app.tasks.generation_create_tasks import chatapi_create_generation_task
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频生成任务投递失败: {exc}")
return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.delete(
"/projects/{project_id}",
response_model=ShotReplicateDeleteOut,
summary="软删除拆镜复刻项目",
)
async def delete_project(
project_id: str = Path(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
out = await delete_shot_replicate_project(db, current_user=current_user, project_id=project_id)
await db.commit()
return out
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"删除拆镜复刻项目失败: {exc}")
raise HTTPException(status_code=500, detail=f"删除拆镜复刻项目失败: {exc}")
+56
View File
@@ -87,6 +87,13 @@ class Settings(BaseSettings):
# ChatAPI async generation pipeline settings
CELERY_BROKER_URL: str = ""
CELERY_RESULT_BACKEND: str = ""
# Celery async 兼容配置。
# single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。
# direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。
CELERY_ASYNC_RUNNER_MODE: str = "single_loop"
CELERY_DB_USE_NULLPOOL: bool = False
CELERY_STARTUP_RECOVERY_ENABLED: bool = True
CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 180
CHATAPI_VIDEO_FPS: float = 0.5
CHATAPI_ASYNC_MAX_RETRIES: int = 3
@@ -123,6 +130,22 @@ class Settings(BaseSettings):
DOWNLOAD_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:download:active"
DOWNLOAD_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:download:active_index"
# Celery 生成链路 / provider poll 容灾配置。
# 说明:
# - 不新增 Celery worker;恢复任务仍投递到 gen_result_download。
# - worker_ready 每个 worker 都会尝试抢启动恢复锁,只有抢到锁的 worker 投递恢复任务。
# - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。
GENERATION_RECOVERY_BATCH_SIZE: int = 100
GENERATION_RECOVERY_MAX_ROUNDS: int = 5
POLL_RECOVERY_BATCH_SIZE: int = 100
POLL_TASK_LEASE_SECONDS: int = 5 * 60
POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60
POLL_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:poll:active"
POLL_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:poll:active_index"
CELERY_STARTUP_RECOVERY_LOCK_KEY: str = "vg:celery:startup_recovery_lock"
CELERY_STARTUP_RECOVERY_LOCK_TTL_SECONDS: int = 120
RESOURCE_SIGN_SECRET: str = "resource-signature-secret-key-for-API-authentication"
RESOURCE_SIGN_EXPIRE_SECONDS: int = 60
RESOURCE_SIGN_ARG_EXPIRE: str = "exp"
@@ -134,5 +157,38 @@ class Settings(BaseSettings):
HOT_OPENING_DEFAULT_VIDEO_RESOLUTION: str = "480p"
HOT_OPENING_DEFAULT_TARGET_PLATFORM: str = "抖音"
# 拆镜复刻配置。
# 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 180
SHOT_ANALYSIS_TEMPERATURE: float = 0.1
SHOT_ANALYSIS_MAX_TOKENS: int = 5000
SHOT_ANALYSIS_VIDEO_FPS: float = 1.0
SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB: int = 45
SHOT_SEGMENT_LOCAL_PATH: str = "./storage/uploads/shot_segments"
SHOT_SEGMENT_URL_PREFIX: str = "/uploads/shot_segments"
SHOT_SPLIT_MIN_SECONDS: float = 2
SHOT_SPLIT_MAX_SECONDS: float = 15
SHOT_SPLIT_END_TOLERANCE_SECONDS: float = 0.5
SHOT_DURATION_TOLERANCE_SECONDS: float = 1.0
SHOT_FFMPEG_TIMEOUT_SECONDS: int = 120
SHOT_FFPROBE_TIMEOUT_SECONDS: int = 20
FFPROBE_BIN: str = ""
# 继续复用 gen_result_download 队列,但限制 ffmpeg 并发,避免拖慢 Chat 下载。
SHOT_SPLIT_MAX_CONCURRENT: int = 1
SHOT_SPLIT_MAX_RETRY_COUNT: int = 3
SHOT_SPLIT_RETRY_BACKOFF_SECONDS: int = 30
SHOT_SPLIT_LEASE_SECONDS: int = 10 * 60
SHOT_SPLIT_PENDING_TIMEOUT_SECONDS: int = 5 * 60
SHOT_SPLIT_RECOVERY_BATCH_SIZE: int = 50
SHOT_SPLIT_LOCK_KEY_PREFIX: str = "vg:shot_replicate:split:lock"
SHOT_SPLIT_SEMAPHORE_KEY_PREFIX: str = "vg:shot_replicate:split:semaphore"
SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4
SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16"
SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p"
SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM: str = "抖音"
settings = Settings()
+1
View File
@@ -1,3 +1,4 @@
from app.enums.common import *
from app.enums.hot_opening_replicate import *
from app.enums.video_prompt_schema import *
from app.enums.shot_replicate import *
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from enum import StrEnum
class ModuleCodeEnum(StrEnum):
"""拆镜复刻模块编码。"""
SHOT_REPLICATE = "shot_replicate"
class ShotReplicateStepCodeEnum(StrEnum):
"""拆镜复刻复用通用模块步骤编码。"""
MATERIAL_INPUT = "material_input"
IMAGE_PROMPT_OPTIMIZE = "image_prompt_optimize"
IMAGE_GENERATE = "image_generate"
VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize"
VIDEO_GENERATE = "video_generate"
class ShotReplicateGenerationModeEnum(StrEnum):
"""复用 ChatGenerationTask 时使用的 generation_mode。"""
SHOT_REPLICATE = "shot_replicate"
class ShotReplicateStepIOSchemaVersionEnum(StrEnum):
"""拆镜复刻子任务 input_json/output_json 结构版本。"""
V1 = "shot_replicate_step_io_v1"
class ShotTaskSetStatusEnum(StrEnum):
"""拆镜总任务集状态。"""
PENDING_ANALYSIS = "pending_analysis"
ANALYZING = "analyzing"
ANALYSIS_COMPLETED = "analysis_completed"
ANALYSIS_FAILED = "analysis_failed"
SPLITTING = "splitting"
SPLIT_COMPLETED = "split_completed"
PARTIAL_FAILED = "partial_failed"
FAILED = "failed"
DELETED = "deleted"
class ShotAnalysisStatusEnum(StrEnum):
"""原视频/片段视频分析状态。"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
class ShotSplitStatusEnum(StrEnum):
"""ffmpeg 拆镜状态。"""
NONE = "none"
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
RETRY_WAITING = "retry_waiting"
class ShotSegmentSourceModeEnum(StrEnum):
"""拆镜片段来源。"""
AI_SUGGESTION = "ai_suggestion"
CUSTOM = "custom"
class ShotSegmentAnalysisStatusEnum(StrEnum):
"""拆镜片段分析状态。"""
NOT_REQUIRED = "not_required"
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
class ShotSegmentReplicateStatusEnum(StrEnum):
"""拆镜片段进入复刻流程后的状态。"""
NOT_STARTED = "not_started"
PROJECT_CREATED = "project_created"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
+3
View File
@@ -23,6 +23,8 @@ from app.models.user_resource_month_stat import UserResourceMonthStat
from app.models.user_resource_total_stat import UserResourceTotalStat
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp
@@ -37,5 +39,6 @@ __all__ = [
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
"ModuleGenerationProject", "ModuleGenerationStep",
"ShotReplicateTaskSet", "ShotReplicateSegment",
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
]
+13 -8
View File
@@ -4,6 +4,7 @@ import os
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.pool import NullPool
from app.config import settings
@@ -18,14 +19,18 @@ engine_kwargs = {
"pool_pre_ping": True,
}
# SQLite 本地调试时不要乱塞 pool_size/max_overflowPostgreSQL/asyncpg 才建议配置
# SQLite 本地调试时不要乱塞 pool_size/max_overflowPostgreSQL/asyncpg 才建议配置
# 默认保持 Celery 连接池复用;只有显式开启 CELERY_DB_USE_NULLPOOL=true 时才降级 NullPool。
if _is_celery_process() and not settings.DATABASE_URL.startswith("sqlite"):
engine_kwargs.update(
pool_size=settings.CELERY_DB_POOL_SIZE,
max_overflow=settings.CELERY_DB_MAX_OVERFLOW,
pool_timeout=settings.CELERY_DB_POOL_TIMEOUT,
pool_recycle=settings.CELERY_DB_POOL_RECYCLE,
)
if bool(getattr(settings, "CELERY_DB_USE_NULLPOOL", False)):
engine_kwargs.update(poolclass=NullPool)
else:
engine_kwargs.update(
pool_size=settings.CELERY_DB_POOL_SIZE,
max_overflow=settings.CELERY_DB_MAX_OVERFLOW,
pool_timeout=settings.CELERY_DB_POOL_TIMEOUT,
pool_recycle=settings.CELERY_DB_POOL_RECYCLE,
)
engine = create_async_engine(settings.DATABASE_URL, **engine_kwargs)
@@ -63,4 +68,4 @@ async def init_database() -> None:
async def close_database() -> None:
await engine.dispose()
await engine.dispose()
@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
_JSON_TYPE = JSON().with_variant(JSONB, "postgresql")
class ShotReplicateSegment(Base, TimestampMixin, SoftDeleteMixin):
"""拆镜复刻片段表。"""
__tablename__ = "shot_replicate_segments"
__table_args__ = (
Index("idx_shot_replicate_segments_task_set", "task_set_id", "segment_index"),
Index("idx_shot_replicate_segments_user_created", "user_id", "created_at"),
Index("idx_shot_replicate_segments_source_mode", "source_mode"),
Index("idx_shot_replicate_segments_split_status", "split_status"),
Index("idx_shot_replicate_segments_analysis_status", "analysis_status"),
Index("idx_shot_replicate_segments_replicate_status", "replicate_status"),
Index("idx_shot_replicate_segments_project", "module_project_id"),
Index(
"uq_shot_replicate_segments_task_set_index_active",
"task_set_id",
"segment_index",
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
task_set_id: Mapped[str] = mapped_column(
String(32), ForeignKey("shot_replicate_task_sets.id", ondelete="CASCADE"), index=True, nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
segment_index: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
source_mode: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
start_second: Mapped[float] = mapped_column(Float, nullable=False)
end_second: Mapped[float] = mapped_column(Float, nullable=False)
duration_seconds: Mapped[float] = mapped_column(Float, nullable=False)
time_node: Mapped[str] = mapped_column(String(64), nullable=False)
split_status: Mapped[str] = mapped_column(String(32), default="pending", index=True, nullable=False)
analysis_status: Mapped[str] = mapped_column(String(32), default="pending", index=True, nullable=False)
replicate_status: Mapped[str] = mapped_column(String(32), default="not_started", index=True, nullable=False)
segment_video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
segment_video_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
original_video_content: Mapped[str | None] = mapped_column(Text, nullable=True)
original_video_category: Mapped[str | None] = mapped_column(String(160), nullable=True)
original_video_audience: Mapped[str | None] = mapped_column(Text, nullable=True)
segment_content: Mapped[str | None] = mapped_column(Text, nullable=True)
segment_category: Mapped[str | None] = mapped_column(String(160), nullable=True)
segment_audience: Mapped[str | None] = mapped_column(Text, nullable=True)
analysis_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True)
ai_suggestion_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True)
module_project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
split_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
split_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
split_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
split_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
split_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
split_retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
split_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
split_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
analysis_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import Float, ForeignKey, Index, Integer, JSON, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
_JSON_TYPE = JSON().with_variant(JSONB, "postgresql")
class ShotReplicateTaskSet(Base, TimestampMixin, SoftDeleteMixin):
"""拆镜复刻总任务集。"""
__tablename__ = "shot_replicate_task_sets"
__table_args__ = (
Index("idx_shot_replicate_task_sets_user_created", "user_id", "created_at"),
Index("idx_shot_replicate_task_sets_status", "status"),
Index("idx_shot_replicate_task_sets_analysis_status", "analysis_status"),
Index("idx_shot_replicate_task_sets_split_status", "split_status"),
Index(
"uq_shot_replicate_task_sets_user_idempotency",
"user_id",
"idempotency_key",
unique=True,
postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
title: Mapped[str | None] = mapped_column(String(160), nullable=True)
video_url: Mapped[str] = mapped_column(String(512), nullable=False)
video_path: Mapped[str] = mapped_column(String(512), nullable=False)
video_duration_seconds: Mapped[float] = mapped_column(Float, nullable=False)
status: Mapped[str] = mapped_column(String(32), default="pending_analysis", index=True, nullable=False)
analysis_status: Mapped[str] = mapped_column(String(32), default="pending", index=True, nullable=False)
split_status: Mapped[str] = mapped_column(String(32), default="none", index=True, nullable=False)
original_video_content: Mapped[str | None] = mapped_column(Text, nullable=True)
original_video_category: Mapped[str | None] = mapped_column(String(160), nullable=True)
original_video_audience: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_suggestion_json: Mapped[list[Any] | dict[str, Any] | None] = mapped_column(_JSON_TYPE, nullable=True)
analysis_raw_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True)
analysis_result_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True)
segment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
completed_segment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
failed_segment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
analysis_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
split_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+734
View File
@@ -0,0 +1,734 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.schemas.common import NaiveDatetimeOptional
SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS: dict[str, str] = {
"pending": "已创建但未进入流程",
"waiting_user": "等待用户手动触发下一步",
"processing": "当前有步骤处理中",
"completed": "总任务完成",
"failed": "总任务失败",
"cancelled": "总任务取消",
}
SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS: dict[str, str] = {
"pending": "子任务待处理",
"waiting_user": "等待用户确认或触发",
"processing": "子任务处理中",
"completed": "子任务完成",
"failed": "子任务失败",
"cancelled": "子任务取消",
}
SHOT_REPLICATE_STEP_DESCRIPTIONS: list[dict[str, Any]] = [
{"step_index": 1, "step_code": "material_input", "name": "素材输入"},
{"step_index": 2, "step_code": "image_prompt_optimize", "name": "图片 AI 提词"},
{"step_index": 3, "step_code": "image_generate", "name": "图片生成"},
{"step_index": 4, "step_code": "video_prompt_optimize", "name": "视频 AI 提词 JSON schema"},
{"step_index": 5, "step_code": "video_generate", "name": "视频生成"},
]
SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION = "shot_replicate_step_io_v1"
SHOT_REPLICATE_STEP_IO_EXAMPLES: dict[str, dict[str, Any]] = {
"material_input": {
"input_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "material_input",
"source": {"source_step_id": None, "parent_step_id": None},
"payload": {
"material_video_url": "https://example.com/source.mp4",
"material_image_url": "https://example.com/product.png",
"source_project_name": "参考素材项目名称",
"target_project_name": "新项目名称",
"core_content_point": "50字以内核心内容点",
},
"context": {},
},
"output_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "material_input",
"status": "completed",
"payload": {},
"result": {"accepted": True, "message": "素材输入已提交", "next_step_code": "image_prompt_optimize"},
"usage": {},
"error": {},
},
},
"image_prompt_optimize": {
"input_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "image_prompt_optimize",
"source": {"source_step_id": "第1步素材输入ID", "parent_step_id": "第1步素材输入ID"},
"payload": {"source_step_id": "第1步素材输入ID"},
"context": {},
},
"output_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "image_prompt_optimize",
"status": "completed",
"payload": {
"optimized_prompt": "图片生成提示词",
"prompt": "兼容字段,同 optimized_prompt",
"original_prompt": "后端拼接的图片提词原始需求",
"references": [{"type": "video|image", "url": "...", "name": "..."}],
},
"result": {},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"text_credits_cost": 0,
"credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge",
},
"error": {},
},
},
"image_generate": {
"input_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "image_generate",
"source": {"source_step_id": "第2步图片提词ID", "parent_step_id": "第2步图片提词ID"},
"payload": {
"engine_id": "图片引擎ID",
"params": {"image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"},
"prompt": "图片生成提示词",
"media_references": [{"type": "image", "url": "新产品图片", "name": "新产品图片"}],
},
"context": {},
},
"output_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "image_generate",
"status": "completed",
"payload": {},
"result": {"result_image_url": "/generate/images/xxx.png", "chat_task_id": "ChatGenerationTask ID"},
"usage": {},
"error": {},
},
},
"video_prompt_optimize": {
"input_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "video_prompt_optimize",
"source": {"source_step_id": "第3步图片生成ID", "parent_step_id": "第3步图片生成ID"},
"payload": {
"source_step_id": "第3步图片生成ID",
"video_config": {"engine_id": "视频引擎ID", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"},
"target_platform": "抖音",
},
"context": {},
},
"output_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "video_prompt_optimize",
"status": "completed",
"payload": {
"prompt_schema": {"任务基础信息": {}, "最终提示词": {}},
"final_prompt": "展示用最终视频提示词",
"params_used_for_prompt": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"},
"target_platform": "抖音",
},
"result": {},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"text_credits_cost": 0,
"credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge",
},
"error": {},
},
},
"video_generate": {
"input_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "video_generate",
"source": {"source_step_id": "第4步视频提词ID", "parent_step_id": "第4步视频提词ID"},
"payload": {
"engine_id": "视频引擎ID",
"params": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"},
"prompt_schema": {"任务基础信息": {}, "最终提示词": {}},
"final_prompt": "展示用最终提示词",
"media_references": [{"type": "image", "url": "第3步生成图片", "name": "新项目图片"}],
},
"context": {},
},
"output_json": {
"schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION,
"step_code": "video_generate",
"status": "completed",
"payload": {},
"result": {
"result_video_url": "/generate/videos/xxx.mp4",
"result_video_cover_url": "/generate/covers/xxx.jpg",
"chat_task_id": "ChatGenerationTask ID",
},
"usage": {},
"error": {},
},
},
}
class ShotReplicateTaskCreate(BaseModel):
"""创建拆镜复刻总任务项目请求体。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"material_video_url": "https://example.com/source.mp4",
"material_image_url": "https://example.com/product.png",
"source_project_name": "参考素材项目名称",
"target_project_name": "新项目名称",
"core_content_point": "突出产品能帮助用户认识附近新朋友",
"idempotency_key": "frontend-submit-uuid-001",
}
}
)
material_video_url: str = Field(..., min_length=1, description="素材视频链接,参考素材,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
material_image_url: str = Field(..., min_length=1, description="素材图片链接,新产品图片,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
source_project_name: str = Field(..., min_length=1, max_length=20, description="视频素材内容项目名称")
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称")
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成的项目核心内容点,最多50字")
idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键。只用于 module_generation_projects,不用于 ChatGenerationTask")
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")
@classmethod
def _strip_required(cls, value: str) -> str:
value = str(value or "").strip()
if not value:
raise ValueError("字段不能为空")
return value
class ShotReplicateMaterialUpdateRequest(BaseModel):
"""修改拆镜复刻第1步素材输入请求体。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"material_video_url": "https://example.com/new-source.mp4",
"material_image_url": "https://example.com/new-product.png",
"source_project_name": "新的参考素材项目名称",
"target_project_name": "新的生成项目名称",
"core_content_point": "新的50字以内核心内容点",
}
}
)
material_video_url: str | None = Field(None, min_length=1, description="素材视频链接,未传则沿用旧值")
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值")
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before")
@classmethod
def _strip_optional(cls, value: str | None) -> str | None:
if value is None:
return None
value = str(value).strip()
if not value:
raise ValueError("字段不能为空字符串")
return value
@model_validator(mode="after")
def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest":
if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")):
raise ValueError("至少需要传入一个需要修改的字段")
return self
class ShotReplicateStepUpdate(BaseModel):
"""修改拆镜复刻子任务请求体。"""
material_video_url: str | None = Field(None, description="修改第1步素材视频链接")
material_image_url: str | None = Field(None, description="修改第1步素材图片链接")
source_project_name: str | None = Field(None, max_length=20, description="修改第1步视频素材内容项目名称")
target_project_name: str | None = Field(None, max_length=20, description="修改第1步生成项目名称")
core_content_point: str | None = Field(None, max_length=50, description="修改第1步生成项目核心内容点,最多50字")
prompt: str | None = Field(None, description="修改第2步图片提词或第4步视频最终提词")
prompt_schema: dict[str, Any] | None = Field(None, description="修改第4步视频提词 JSON schema。只对视频提词步骤有意义")
input_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 input_json.payload")
output_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 output_json.payload")
class ShotReplicateImagePromptUpdateRequest(BaseModel):
"""直接修改第2步图片 AI 优化提词请求体。
本接口不调用 AI、不扣积分;保存后会软删除第3、4、5步当前有效任务。
"""
model_config = ConfigDict(
extra="ignore",
json_schema_extra={"example": {"prompt": "用户手动修改后的图片生成提示词"}},
)
prompt: str = Field(..., min_length=1, description="用户手动修改后的图片生成提示词,不能为空")
@field_validator("prompt", mode="before")
@classmethod
def _strip_prompt(cls, value: str) -> str:
value = str(value or "").strip()
if not value:
raise ValueError("图片提示词不能为空")
return value
class ShotReplicateVideoPromptSchemaUpdateRequest(BaseModel):
"""修改第4步视频 AI 提词 JSON schema 请求体。
前端提交的 prompt_schema 只作为 patch:服务端会锁定视频时长、比例、清晰度、帧率、推荐分辨率、
动作/镜头/动态时间规划数组长度和时间段、输出规格限制、质量控制、合规控制、schema_version、schema_usage。
最终提示词允许修改,但保存前会清洗视频参数。
"""
model_config = ConfigDict(
extra="ignore",
json_schema_extra={
"example": {
"prompt_schema": {
"业务属性": {"产品名称": "脱单交友APP", "行动引导": "立即下载"},
"最终提示词": {"主提示词": "脱单交友APP推广短视频,突出认识附近新朋友和高效匹配"},
}
}
},
)
prompt_schema: dict[str, Any] = Field(..., description="前端修改后的视频提词 JSON schema。后端只按白名单回填允许修改字段")
@field_validator("prompt_schema")
@classmethod
def _validate_schema(cls, value: dict[str, Any]) -> dict[str, Any]:
if not isinstance(value, dict) or not value:
raise ValueError("prompt_schema 必须是非空 JSON 对象")
return value
class ShotReplicateGenerateImagePromptRequest(BaseModel):
"""手动生成第2步图片 AI 提词请求体。当前无需请求参数。"""
model_config = ConfigDict(extra="ignore")
class ShotReplicateGenerateImageRequest(BaseModel):
"""根据图片提词生成新项目图片请求体。
ChatGenerationTask.idempotency_key 由后端自动生成,接口不再接收前端幂等键。
"""
model_config = ConfigDict(
extra="ignore",
json_schema_extra={"example": {"engine_id": "image_engine_xxx", "image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"}},
)
engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎")
image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。为空使用引擎默认值")
image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。为空使用默认值")
image_px: str | None = Field(None, description="图片像素尺寸,例如 2048x2048。为空时按引擎支持尺寸自动匹配")
class ShotReplicateGenerateVideoPromptRequest(BaseModel):
"""手动生成第4步视频 AI 提词请求体。
视频时长、比例、分辨率集中在本步骤确定;第5步生成视频只选择视频引擎。
"""
model_config = ConfigDict(
extra="ignore",
json_schema_extra={"example": {"engine_id": "video_engine_xxx", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p", "target_platform": "抖音"}},
)
engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎")
duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_DURATION")
aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RATIO")
resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION")
target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM")
class ShotReplicateGenerateVideoRequest(BaseModel):
"""根据视频提词生成最终视频请求体。
只选择视频生成引擎。duration / aspect_ratio / resolution 从第4步视频提词优化结果读取。
ChatGenerationTask.original_prompt / optimized_prompt 都写入第4步生成的 prompt_schema JSON 字符串。
"""
model_config = ConfigDict(extra="ignore", json_schema_extra={"example": {"engine_id": "video_engine_xxx"}})
engine_id: str | None = Field(None, description="视频生成引擎ID。为空优先使用第4步视频提词时选择的 engine_id,再为空使用最高优先级启用视频引擎")
class ShotReplicateStepOut(BaseModel):
id: str = Field(..., description="子任务ID")
project_id: str = Field(..., description="总任务项目ID,即 module_generation_projects.id")
module: str = Field(..., description="模块标识,例如 shot_replicate")
step_index: int = Field(..., description="步骤序号:1素材输入、2图片提词、3图片生成、4视频提词、5视频生成")
step_code: str = Field(..., description="步骤编码:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate")
status: str = Field(..., description="步骤状态:pending/waiting_user/processing/completed/failed/cancelled")
version: int = Field(..., description="步骤版本号。重新生成或修改上游步骤后 version+1")
is_current: bool = Field(..., description="是否当前有效步骤。旧步骤会软删除且 is_current=false")
parent_step_id: str | None = Field(None, description="上一个步骤ID")
source_step_id: str | None = Field(None, description="当前步骤基于哪个上游步骤生成")
chat_task_id: str | None = Field(None, description="关联的 ChatGenerationTask ID。第3步图片生成、第5步视频生成有值")
input: dict[str, Any] | None = Field(None, description=f"步骤输入 JSON,统一 schema_version={SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION}")
output: dict[str, Any] | None = Field(None, description=f"步骤输出 JSON,统一 schema_version={SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION}")
error_message: str | None = Field(None, description="步骤错误信息")
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
completed_at: NaiveDatetimeOptional = Field(None, description="完成时间")
class ShotReplicateMaterialOut(BaseModel):
material_step_id: str | None = Field(None, description="第1步素材输入子任务ID")
material_video_url: str | None = Field(None, description="素材视频链接")
material_image_url: str | None = Field(None, description="素材图片链接")
source_project_name: str | None = Field(None, description="视频素材内容项目名称")
target_project_name: str | None = Field(None, description="生成项目名称")
core_content_point: str | None = Field(None, description="生成项目核心内容点")
class ShotReplicateImageGenerationOut(BaseModel):
prompt_step_id: str | None = Field(None, description="第2步图片 AI 提词子任务ID")
generate_step_id: str | None = Field(None, description="第3步图片生成子任务ID")
prompt: str | None = Field(None, description="图片优化提词")
engine_id: str | None = Field(None, description="图片生成引擎ID")
engine_name: str | None = Field(None, description="图片生成引擎名称")
params: dict[str, Any] | None = Field(None, description="图片生成参数")
chat_task_id: str | None = Field(None, description="图片生成 ChatGenerationTask ID")
status: str | None = Field(None, description="图片生成状态")
result_image_url: str | None = Field(None, description="新项目图片 URL")
error_message: str | None = Field(None, description="图片生成错误信息")
class ShotReplicateVideoGenerationOut(BaseModel):
prompt_step_id: str | None = Field(None, description="第4步视频 AI 提词子任务ID")
generate_step_id: str | None = Field(None, description="第5步视频生成子任务ID")
prompt_schema: dict[str, Any] | None = Field(None, description="视频提词 JSON schema。第5步 ChatGenerationTask 原始提词会使用该 JSON 字符串")
final_prompt: str | None = Field(None, description="视频最终提词,仅用于前端展示")
prompt_params: dict[str, Any] | None = Field(None, description="第4步生成视频提词时使用的视频配置,例如 duration、aspect_ratio、resolution")
engine_id: str | None = Field(None, description="视频生成引擎ID")
engine_name: str | None = Field(None, description="视频生成引擎名称")
params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步")
chat_task_id: str | None = Field(None, description="视频生成 ChatGenerationTask ID")
status: str | None = Field(None, description="视频生成状态")
result_video_url: str | None = Field(None, description="最终视频 URL")
result_video_cover_url: str | None = Field(None, description="最终视频封面 URL")
error_message: str | None = Field(None, description="视频生成错误信息")
class ShotReplicateTaskDetailOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id")
module: str = Field(..., description="模块标识,拆镜复刻固定为 shot_replicate")
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled")
current_step_code: str | None = Field(None, description="当前所处步骤编码")
final_image_url: str | None = Field(None, description="最终新项目图片 URL")
final_video_url: str | None = Field(None, description="最终视频 URL")
final_video_cover_url: str | None = Field(None, description="最终视频封面 URL")
error_message: str | None = Field(None, description="总任务错误信息")
material: ShotReplicateMaterialOut = Field(default_factory=ShotReplicateMaterialOut, description="素材和项目描述信息")
image_generation: ShotReplicateImageGenerationOut = Field(default_factory=ShotReplicateImageGenerationOut, description="图片提词、图片引擎参数和图片结果")
video_generation: ShotReplicateVideoGenerationOut = Field(default_factory=ShotReplicateVideoGenerationOut, description="视频提词、视频引擎参数和视频结果")
steps: list[ShotReplicateStepOut] = Field(default_factory=list, description="当前有效子任务列表")
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
completed_at: NaiveDatetimeOptional = Field(None, description="完成时间")
class ShotReplicateTaskListItemOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id")
module: str = Field(..., description="模块标识")
title: str | None = Field(None, description="项目标题")
status: str = Field(..., description="总任务状态")
current_step_code: str | None = Field(None, description="当前步骤")
target_project_name: str | None = Field(None, description="生成项目名称,来源于第1步素材输入")
final_image_url: str | None = Field(None, description="最终图片 URL")
final_video_url: str | None = Field(None, description="最终视频 URL")
error_message: str | None = Field(None, description="错误信息")
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
completed_at: NaiveDatetimeOptional = Field(None, description="完成时间")
class ShotReplicateTaskListOut(BaseModel):
total: int = Field(..., description="总数量")
items: list[ShotReplicateTaskListItemOut] = Field(default_factory=list, description="列表数据")
class ShotReplicateActionOut(BaseModel):
message: str = Field(..., description="操作结果提示")
project_id: str = Field(..., description="总任务项目ID")
step_id: str | None = Field(None, description="本次创建或修改的子任务ID")
next_step_id: str | None = Field(None, description="兼容字段:当前接口不自动生成下下个任务,一般为空")
detail: ShotReplicateTaskDetailOut | None = Field(None, description="操作后的总任务详情")
class ShotReplicateDeleteOut(BaseModel):
message: str = Field(..., description="删除结果提示")
project_id: str = Field(..., description="被软删除的总任务项目ID")
deleted: bool = Field(..., description="是否已软删除")
class ShotReplicateSpecOut(BaseModel):
project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS, description="总任务状态说明")
step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS, description="子任务状态说明")
steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS, description="5个固定步骤说明")
step_io_schema_version: str = Field(default=SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本")
step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例")
# ========================
# 拆镜总任务集 / 片段 API Schema
# ========================
SHOT_TASK_SET_STATUS_DESCRIPTIONS: dict[str, str] = {
"pending_analysis": "已创建,等待原视频分析",
"analyzing": "原视频分析中",
"analysis_completed": "原视频分析完成",
"analysis_failed": "原视频分析失败",
"splitting": "拆镜处理中",
"split_completed": "拆镜全部完成",
"partial_failed": "部分片段失败",
"failed": "总任务失败",
"deleted": "已软删",
}
SHOT_ANALYSIS_STATUS_DESCRIPTIONS: dict[str, str] = {
"pending": "待分析",
"processing": "分析中",
"completed": "分析完成",
"failed": "分析失败",
}
SHOT_SPLIT_STATUS_DESCRIPTIONS: dict[str, str] = {
"none": "尚未拆镜",
"pending": "待拆镜",
"processing": "拆镜中",
"completed": "拆镜完成",
"failed": "拆镜失败",
"retry_waiting": "等待恢复重试",
}
SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS: dict[str, str] = {
"ai_suggestion": "AI 建议拆镜",
"custom": "用户自定义拆镜",
}
SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS: dict[str, str] = {
"not_required": "不需要单独分析,通常用于 AI 建议拆镜",
"pending": "等待片段分析",
"processing": "片段分析中",
"completed": "片段分析完成",
"failed": "片段分析失败",
}
SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS: dict[str, str] = {
"not_started": "未进入复刻流程",
"project_created": "已创建复刻项目",
"processing": "复刻流程处理中",
"completed": "复刻流程完成",
"failed": "复刻流程失败",
}
class ShotAISuggestionOut(BaseModel):
index: int = Field(..., description="AI 建议序号,从1开始")
start_second: float = Field(..., description="拆镜开始秒")
end_second: float = Field(..., description="拆镜结束秒")
duration_seconds: float = Field(..., description="片段时长")
time_node: str = Field(..., description="拆镜时间节点,例如 0-15秒")
content: str = Field(..., description="对应时间节点内的内容")
category: str = Field(..., description="片段分类")
audience: str = Field(..., description="片段受众人群")
class ShotTaskSetCreate(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"video_url": "/uploads/2026/06/11/demo.mp4",
"video_duration_seconds": 31.42,
"title": "游戏视频拆镜",
"idempotency_key": "frontend-shot-task-001",
}
}
)
video_url: str = Field(..., min_length=1, description="已有上传接口返回的视频链接。必须能反解到 storage/uploads 下文件")
video_duration_seconds: float = Field(..., gt=0, description="前端获取的视频时长秒数,允许浮点;后端会用 ffprobe 校验并以后端真实时长为准")
title: str | None = Field(None, max_length=160, description="拆镜总任务标题")
idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键")
@field_validator("video_url", "title", "idempotency_key", mode="before")
@classmethod
def _strip_optional_text(cls, value: str | None) -> str | None:
if value is None:
return None
value = str(value).strip()
if not value:
return None
return value
class ShotTaskSetListQuery(BaseModel):
status: str | None = Field(None, description="总任务状态筛选,见 ShotTaskSetStatusEnum")
analysis_status: str | None = Field(None, description="分析状态筛选,见 ShotAnalysisStatusEnum")
split_status: str | None = Field(None, description="拆镜状态筛选,见 ShotSplitStatusEnum")
keyword: str | None = Field(None, description="标题/内容关键词")
page: int = Field(1, ge=1, description="页码")
page_size: int = Field(20, ge=1, le=100, description="每页数量")
class ShotTaskSetOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
title: str | None = None
video_url: str
video_duration_seconds: float
status: str
analysis_status: str
split_status: str
original_video_content: str | None = None
original_video_category: str | None = None
original_video_audience: str | None = None
segment_count: int = 0
completed_segment_count: int = 0
failed_segment_count: int = 0
analysis_error_message: str | None = None
split_error_message: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
class ShotTaskSetListOut(BaseModel):
total: int
page: int
page_size: int
items: list[ShotTaskSetOut]
class ShotTaskSetDetailOut(ShotTaskSetOut):
ai_suggestions: list[ShotAISuggestionOut] = Field(default_factory=list)
analysis_result_json: dict[str, Any] | list[Any] | None = None
class ShotSplitByAIRequest(BaseModel):
selected_indices: list[int] | None = Field(None, description="指定 AI 建议序号。不传则全部拆")
replace_existing: bool = Field(False, description="是否软删旧 AI 建议片段后重新拆")
class ShotSplitCustomRequest(BaseModel):
start_second: float = Field(..., ge=0, description="自定义拆镜开始秒,允许浮点")
end_second: float = Field(..., gt=0, description="自定义拆镜结束秒,允许浮点,必须大于 start_second")
@model_validator(mode="after")
def _check_range(self) -> "ShotSplitCustomRequest":
if self.end_second <= self.start_second:
raise ValueError("end_second 必须大于 start_second")
return self
class ShotSegmentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
task_set_id: str
segment_index: int
segment_name: str | None = None
source_mode: str
start_second: float
end_second: float
duration_seconds: float
time_node: str
split_status: str
analysis_status: str
replicate_status: str
segment_video_url: str | None = None
original_video_content: str | None = None
original_video_category: str | None = None
original_video_audience: str | None = None
segment_content: str | None = None
segment_category: str | None = None
segment_audience: str | None = None
split_retry_count: int = 0
split_last_error: str | None = None
analysis_error_message: str | None = None
module_project_id: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
class ShotSegmentDetailOut(ShotSegmentOut):
analysis_json: dict[str, Any] | list[Any] | None = None
ai_suggestion_json: dict[str, Any] | list[Any] | None = None
class ShotSegmentListOut(BaseModel):
total: int
page: int
page_size: int
items: list[ShotSegmentOut]
class ShotSplitByAIOut(BaseModel):
task_set_id: str
status: str
split_status: str
created_segment_count: int
segments: list[ShotSegmentOut]
class ShotSplitCustomOut(BaseModel):
task_set_id: str
segment: ShotSegmentOut
class ShotSegmentReplicationCreateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"target_project_name": "新产品推广视频",
"core_content_point": "突出产品附近交友和快速脱单",
"material_image_url": "/uploads/2026/06/11/product.png",
"idempotency_key": "optional-key",
}
}
)
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称")
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字")
material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口")
idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键")
@field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before")
@classmethod
def _strip_text(cls, value: str | None) -> str | None:
if value is None:
return None
value = str(value).strip()
if not value:
return None
return value
class ShotReplicateSpecOut(BaseModel):
project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS)
step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS)
steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS)
step_io_schema_version: str = SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION
step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES)
task_set_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_TASK_SET_STATUS_DESCRIPTIONS)
analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_ANALYSIS_STATUS_DESCRIPTIONS)
split_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SPLIT_STATUS_DESCRIPTIONS)
segment_source_modes: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS)
segment_analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS)
segment_replicate_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS)
@@ -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
+3 -1
View File
@@ -10,7 +10,9 @@ try:
generation_poll_tasks,
generation_download_tasks,
generation_recovery_tasks,
hot_opening_replicate_tasks
hot_opening_replicate_tasks,
shot_replicate_tasks,
shot_replicate_flow_tasks
)
except Exception:
pass
+112 -14
View File
@@ -1,23 +1,35 @@
from __future__ import annotations
import asyncio
import os
import threading
from concurrent.futures import Future
from typing import Awaitable, TypeVar
from app.config import settings
T = TypeVar("T")
_thread_local = threading.local()
_single_loop_lock = threading.RLock()
_single_loop: asyncio.AbstractEventLoop | None = None
_single_loop_thread: threading.Thread | None = None
_single_loop_pid: int | None = None
_single_loop_ready: threading.Event | None = None
def _get_or_create_loop() -> asyncio.AbstractEventLoop:
"""
给当前进程/线程维护一个长期 event loop。
def _runner_mode() -> str:
mode = str(getattr(settings, "CELERY_ASYNC_RUNNER_MODE", "single_loop") or "single_loop").strip().lower()
if mode not in {"single_loop", "direct"}:
return "single_loop"
return mode
Linux prefork
每个 Celery 子进程通常单线程跑任务,这里相当于每个子进程一个长期 loop。
Windows -P threads
每个线程一个 loop,但注意 asyncpg pool 仍不适合跨线程共享;
Windows threads 模式建议继续用 NullPool 或只做本地调试。
def _get_or_create_thread_local_loop() -> asyncio.AbstractEventLoop:
"""兼容旧方案:当前线程持有一个长期 event loop。
仅作为降级模式使用。长期推荐 single_loop,避免 Windows threads 下
多线程 event loop 复用 asyncpg / redis.asyncio 连接对象。
"""
pid = os.getpid()
loop = getattr(_thread_local, "loop", None)
@@ -31,18 +43,104 @@ def _get_or_create_loop() -> asyncio.AbstractEventLoop:
return loop
def _single_loop_worker(loop: asyncio.AbstractEventLoop, ready: threading.Event) -> None:
asyncio.set_event_loop(loop)
ready.set()
loop.run_forever()
pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
if pending:
for task in pending:
task.cancel()
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
def _get_or_create_single_loop() -> asyncio.AbstractEventLoop:
"""获取当前 Celery 进程内唯一 async event loop。
Linux prefork:每个 Celery 子进程各自一个 loop。
Windows threads:同一 worker 进程内所有任务线程共享同一个 loop。
"""
global _single_loop, _single_loop_thread, _single_loop_pid, _single_loop_ready
pid = os.getpid()
with _single_loop_lock:
if (
_single_loop is not None
and not _single_loop.is_closed()
and _single_loop_thread is not None
and _single_loop_thread.is_alive()
and _single_loop_pid == pid
):
return _single_loop
# fork 后 pid 变化,必须丢弃父进程状态,重新创建子进程自己的 loop。
_single_loop = asyncio.new_event_loop()
_single_loop_pid = pid
_single_loop_ready = threading.Event()
_single_loop_thread = threading.Thread(
target=_single_loop_worker,
args=(_single_loop, _single_loop_ready),
name=f"celery-async-runner-{pid}",
daemon=True,
)
_single_loop_thread.start()
_single_loop_ready.wait(timeout=5)
return _single_loop
def run_async(coro: Awaitable[T]) -> T:
"""Celery 同步 task 调用异步协程的统一入口。
默认 single_loop 模式:
- 一个 Celery 子进程只有一个专用 event loop;
- 所有 asyncpg / redis.asyncio 操作都在这个 loop 内创建和使用;
- 避免 got Future attached to a different loop。
降级 direct 模式:
- 兼容旧的线程本地 loop 方案;
- 如果使用 direct,建议同时开启 CELERY_DB_USE_NULLPOOL=true。
"""
Celery 同步 task 调用异步协程的统一入口。
不使用 asyncio.run(),避免每个 task 结束时关闭 event loop。
"""
loop = _get_or_create_loop()
return loop.run_until_complete(coro)
if _runner_mode() == "direct":
loop = _get_or_create_thread_local_loop()
return loop.run_until_complete(coro)
loop = _get_or_create_single_loop()
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
if running_loop is loop:
raise RuntimeError("run_async() 不能在 Celery async_runner 的事件循环内部被同步调用")
future: Future[T] = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result()
def close_loop() -> None:
"""关闭当前进程内 async runner loop。"""
global _single_loop, _single_loop_thread, _single_loop_pid, _single_loop_ready
# 关闭 single_loop。
with _single_loop_lock:
loop = _single_loop
thread = _single_loop_thread
if loop is not None and not loop.is_closed() and thread is not None and thread.is_alive():
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=5)
_single_loop = None
_single_loop_thread = None
_single_loop_pid = None
_single_loop_ready = None
# 关闭 direct 降级模式的线程本地 loop。
loop = getattr(_thread_local, "loop", None)
if loop is not None and not loop.is_closed():
loop.close()
_thread_local.loop = None
_thread_local.pid = None
_thread_local.pid = None
+39 -5
View File
@@ -51,6 +51,12 @@ if broker_url:
"generation.download_generation_result_task": {"queue": "gen_result_download"},
"hot_opening.start_image_prompt_optimize": {"queue": "gen_chatapi_create"},
"hot_opening.start_video_prompt_optimize": {"queue": "gen_chatapi_create"},
"shot_replicate.analyze_original_video": {"queue": "gen_chatapi_create"},
"shot_replicate.analyze_custom_segment_video": {"queue": "gen_chatapi_create"},
"shot_replicate.split_one_segment": {"queue": "gen_result_download"},
"shot_replicate.start_image_prompt_optimize": {"queue": "gen_chatapi_create"},
"shot_replicate.start_video_prompt_optimize": {"queue": "gen_chatapi_create"},
"shot_replicate.recover_split_tasks_once": {"queue": "gen_result_download"},
"generation.recover_download_tasks_once": {"queue": "gen_result_download"},
"generation.recover_generation_tasks_once": {"queue": "gen_result_download"},
"app.tasks.cleanup.*": {"queue": "default"},
@@ -61,6 +67,18 @@ else:
celery_app = None
async def _try_acquire_startup_recovery_lock() -> bool:
"""任意 worker 启动时都可尝试抢恢复锁,避免依赖 hostname 命名。"""
from app.services.redis_registry_service import redis_acquire_lock
token = await redis_acquire_lock(
lock_key=settings.CELERY_STARTUP_RECOVERY_LOCK_KEY,
ttl_seconds=int(settings.CELERY_STARTUP_RECOVERY_LOCK_TTL_SECONDS or 120),
log_context="celery_startup_recovery",
)
return bool(token)
@worker_ready.connect
def on_worker_ready(sender=None, **kwargs):
"""Celery worker 启动时做一次容灾恢复。
@@ -68,13 +86,21 @@ def on_worker_ready(sender=None, **kwargs):
注意:
- 不启用 Celery beat。
- 不要求新增第四条启动命令。
- 只让 gen_result_download worker 投递恢复任务,避免三个 worker 同时重复扫描
- 不再依赖 worker hostname 是否包含 gen_result_download
- 所有 worker 都尝试抢 Redis 锁,只有抢到锁的 worker 投递恢复任务。
"""
if celery_app is None:
return
if not bool(getattr(settings, "CELERY_STARTUP_RECOVERY_ENABLED", True)):
logger.info("启动容灾恢复已关闭。CELERY_STARTUP_RECOVERY_ENABLED=false")
return
hostname = str(getattr(sender, "hostname", "") or "")
if "gen_result_download" not in hostname:
try:
if not run_async(_try_acquire_startup_recovery_lock()):
return
except Exception:
# Redis 不可用时不阻塞 worker 启动,避免影响稳定生成链路。
logger.exception("启动容灾恢复锁获取失败,已跳过本次自动恢复投递")
return
try:
@@ -82,6 +108,7 @@ def on_worker_ready(sender=None, **kwargs):
recover_download_tasks_once,
recover_generation_tasks_once,
)
from app.tasks.shot_replicate_tasks import recover_split_tasks_once
countdown = max(0, int(settings.DOWNLOAD_RECOVERY_STARTUP_DELAY_SECONDS or 0))
@@ -95,6 +122,13 @@ def on_worker_ready(sender=None, **kwargs):
queue="gen_result_download",
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
recover_split_tasks_once.apply_async(
countdown=countdown + 10,
queue="gen_result_download",
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
logger.info("启动容灾恢复任务已投递。countdown=%s", countdown)
except Exception:
logger.exception("启动容灾恢复任务投递失败")
@@ -110,14 +144,14 @@ def on_worker_process_init(**kwargs):
@worker_process_shutdown.connect
def on_worker_process_shutdown(**kwargs):
"""子进程退出前关闭连接池和 event loop。"""
"""子进程退出前关闭连接池、Redis 注册表连接和 event loop。"""
try:
run_async(engine.dispose())
except Exception:
pass
try:
from app.services.celery_download_recovery_service import close_registry_redis
from app.services.redis_registry_service import close_registry_redis
run_async(close_registry_redis())
except Exception:
@@ -11,9 +11,10 @@ from app.services.error_codes import extract_error_message
from app.services.generation_log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_provider_service import create_provider_task
from app.services.redis_registry_service import ensure_aware_utc
from app.tasks.celery_app import celery_app
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"}
def _get_first_value(obj: Any, *field_names: str) -> Optional[Any]:
@@ -69,7 +70,7 @@ def _build_optimized_prompt_by_params(task: ChatGenerationTask) -> str:
# 爆款开头复刻第5步的视频生成,original_prompt 已经是视频提词 JSON schema。
# 不能再追加“时长/比例/分辨率”中文参数,否则会污染 schema。
if generation_mode == "hot_opening_replicate" and gen_type == "video":
if generation_mode in {"hot_opening_replicate", "shot_replicate"} and gen_type == "video":
stripped = base_prompt.strip()
if stripped.startswith("{") or stripped.startswith("["):
return base_prompt
@@ -129,7 +130,8 @@ async def _run(task_id: str):
if task.status != "generating":
return
if task.deadline_at and datetime.now(timezone.utc) > task.deadline_at:
deadline_at = ensure_aware_utc(task.deadline_at)
if deadline_at and datetime.now(timezone.utc) > deadline_at:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
@@ -242,7 +244,11 @@ async def _run(task_id: str):
else:
from app.tasks.generation_poll_tasks import poll_generation_task
poll_generation_task.delay(task.id)
poll_generation_task.apply_async(
args=[task.id],
queue="gen_provider_poll",
countdown=0,
)
except Exception as exc:
try:
@@ -21,7 +21,7 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
from app.services.resource_accounting_service import record_chat_task_generated_resource
from app.tasks.celery_app import celery_app
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"}
DOWNLOAD_QUEUE = "gen_result_download"
DOWNLOAD_STAGE_QUEUED = "download_queued"
+170 -57
View File
@@ -1,6 +1,7 @@
from app.tasks.async_runner import run_async
import json
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
@@ -11,9 +12,21 @@ from app.services.error_codes import extract_error_message
from app.services.generation_log_service import log_task_event, log_provider_call
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_provider_service import poll_provider_task
from app.services.redis_registry_service import (
datetime_to_epoch,
ensure_aware_utc,
redis_remove_registry_item,
redis_upsert_registry_item,
utc_now,
)
from app.tasks.celery_app import celery_app
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:
return datetime.now(timezone.utc)
def _is_success(status: str) -> bool:
@@ -31,6 +44,86 @@ def _engine_snapshot(task: ChatGenerationTask) -> dict:
return {}
def _deadline_expired(task: ChatGenerationTask, now: datetime | None = None) -> bool:
deadline_at = ensure_aware_utc(task.deadline_at)
return bool(deadline_at and deadline_at <= (now or _now()))
def _poll_check_at(*, delay_seconds: int | float | None = None, now: datetime | None = None) -> datetime:
current_time = now or _now()
delay = int(delay_seconds or settings.CHATAPI_ASYNC_POLL_INTERVAL_SECONDS or 30)
grace = int(settings.POLL_TASK_QUEUE_TIMEOUT_SECONDS or 120)
return current_time + timedelta(seconds=max(1, delay) + max(0, grace))
def _poll_lease_until(now: datetime | None = None) -> datetime:
current_time = now or _now()
return current_time + timedelta(seconds=int(settings.POLL_TASK_LEASE_SECONDS or 300))
def _build_poll_active_payload(
task: ChatGenerationTask,
*,
stage: str,
reason: str,
next_poll_at: datetime | None = None,
check_at: datetime | None = None,
) -> dict[str, Any]:
current_time = utc_now()
checked_next_poll_at = ensure_aware_utc(next_poll_at)
checked_check_at = ensure_aware_utc(check_at)
return {
"task_id": task.id,
"provider_task_id": task.provider_task_id,
"seedance_task_id": task.seedance_task_id,
"generation_mode": task.generation_mode,
"gen_type": task.gen_type,
"stage": stage,
"queue": POLL_QUEUE,
"poll_count": int(task.poll_count or 0),
"retry_count": int(task.retry_count or 0),
"last_poll_at": datetime_to_epoch(task.last_poll_at) if task.last_poll_at else None,
"next_poll_at": datetime_to_epoch(checked_next_poll_at) if checked_next_poll_at else None,
"deadline_at": datetime_to_epoch(task.deadline_at) if task.deadline_at else None,
"check_at": datetime_to_epoch(checked_check_at) if checked_check_at else None,
"updated_at": datetime_to_epoch(current_time),
"reason": reason,
}
async def register_poll_active(
task: ChatGenerationTask,
*,
check_at: datetime,
reason: str,
next_poll_at: datetime | None = None,
) -> None:
payload = _build_poll_active_payload(
task,
stage=task.pipeline_stage or "",
reason=reason,
next_poll_at=next_poll_at,
check_at=check_at,
)
await redis_upsert_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,
log_context="poll_active",
)
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 _notify_finished(db, task: ChatGenerationTask) -> None:
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
@@ -55,6 +148,32 @@ async def _reload_task(db, task_id: str) -> ChatGenerationTask | None:
return result.scalar_one_or_none()
async def _mark_timeout(db, task: ChatGenerationTask, *, message: str = "任务轮询超时") -> None:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=message,
pipeline_stage="timeout",
)
await _notify_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")
async def _mark_failed(db, task: ChatGenerationTask, *, message: str, detail: Any = None) -> None:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=message,
pipeline_stage="failed",
)
await _notify_finished(db, task)
await db.commit()
await remove_poll_active(task.id)
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message, detail=detail)
async def _run(task_id: str):
async with async_session() as db:
result = await db.execute(select(ChatGenerationTask).where(
@@ -62,43 +181,37 @@ async def _run(task_id: str):
ChatGenerationTask.deleted_at.is_(None),
).with_for_update().limit(1))
task = result.scalar_one_or_none()
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
if not task:
await remove_poll_active(task_id)
return
if task.generation_mode not in ALLOWED_GENERATION_MODES:
await remove_poll_active(task.id)
return
# 只处理正在生成,且处于远程等待/轮询中的任务。
if task.status != "generating" or task.pipeline_stage not in ("waiting_remote", "polling"):
await remove_poll_active(task.id)
return
if task.deadline_at and datetime.now(timezone.utc) > task.deadline_at:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message="任务轮询超时",
pipeline_stage="timeout",
)
await _notify_finished(db, task)
await db.commit()
await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout")
if _deadline_expired(task):
await _mark_timeout(db, task, message="任务轮询超时")
return
if not (task.seedance_task_id or task.provider_task_id):
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message="缺少外部任务ID",
pipeline_stage="failed",
)
await _notify_finished(db, task)
await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
await _mark_failed(db, task, message="缺少外部任务ID")
return
# 标记本次正在轮询。
# 注意:pending 后会再改回 waiting_remote,避免任务长期卡在 polling
# 标记本次正在轮询,并登记 poll lease
# 如果 worker 在供应商接口调用过程中退出,启动恢复会在 lease 过期后重新投递
task.pipeline_stage = "polling"
task.poll_count = (task.poll_count or 0) + 1
task.last_poll_at = datetime.now(timezone.utc)
task.last_poll_at = _now()
await db.commit()
await register_poll_active(
task,
check_at=_poll_lease_until(task.last_poll_at),
reason="polling_lease",
)
try:
poll_result = await poll_provider_task(db, task)
@@ -134,20 +247,13 @@ async def _run(task_id: str):
task.provider_response_json = response_data
if not task.remote_result_url:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message="供应商任务成功但未返回结果URL",
pipeline_stage="failed",
)
await _notify_finished(db, task)
await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
await _mark_failed(db, task, message="供应商任务成功但未返回结果URL", detail=poll_result)
return
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", to_stage="result_ready")
@@ -158,34 +264,38 @@ async def _run(task_id: str):
if _is_failed(status):
task.provider_response_json = response_data
await mark_chat_generation_task_failed_and_refund_once(
await _mark_failed(
db,
task=task,
error_message=poll_result.get("error") or f"供应商任务失败: {status}",
pipeline_stage="failed",
task,
message=poll_result.get("error") or f"供应商任务失败: {status}",
detail=poll_result,
)
await _notify_finished(db, task)
await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message, detail=poll_result)
return
# 关键修改 1
# 供应商仍在 pending / running 时,把阶段从 polling 改回 waiting_remote。
# 这样数据库状态表示“等待下一次轮询”,不会长期停在 polling
# 同时可以降低重复 Celery 消息形成多条轮询链的概率。
# 同时登记下一次 poll activeCelery countdown 丢失时可由恢复任务拉起
task.pipeline_stage = "waiting_remote"
task.retry_count = 0
await db.commit()
await log_task_event(task, event_type="POLL_PENDING", message=f"status={status}")
delay_seconds = int(settings.CHATAPI_ASYNC_POLL_INTERVAL_SECONDS or 30)
next_poll_at = _now() + timedelta(seconds=max(1, delay_seconds))
await register_poll_active(
task,
check_at=_poll_check_at(delay_seconds=delay_seconds),
next_poll_at=next_poll_at,
reason="poll_pending_next",
)
poll_generation_task.apply_async(
args=[task.id],
countdown=settings.CHATAPI_ASYNC_POLL_INTERVAL_SECONDS,
queue=POLL_QUEUE,
countdown=delay_seconds,
)
except Exception as exc:
# 关键修改 2
# 异常后先 rollback,再重新查询 task,不继续使用 rollback 前的旧 ORM 对象。
try:
await db.rollback()
@@ -194,30 +304,33 @@ async def _run(task_id: str):
task = await _reload_task(db, task_id)
if not task:
await remove_poll_active(task_id)
return
task.retry_count = (task.retry_count or 0) + 1
if task.retry_count > settings.CHATAPI_ASYNC_MAX_RETRIES:
error_message = extract_error_message(exc, "轮询") if callable(extract_error_message) else str(exc)
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=error_message,
pipeline_stage="failed",
)
await _notify_finished(db, task)
await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
await _mark_failed(db, task, message=error_message)
else:
# 临时轮询异常时,不让任务停在 polling。
# 回到 waiting_remote,等待下一次重试轮询。
task.pipeline_stage = "waiting_remote"
await db.commit()
delay_seconds = int(settings.CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS or 30) * int(task.retry_count or 1)
next_poll_at = _now() + timedelta(seconds=max(1, delay_seconds))
await register_poll_active(
task,
check_at=_poll_check_at(delay_seconds=delay_seconds),
next_poll_at=next_poll_at,
reason="poll_exception_retry",
)
poll_generation_task.apply_async(
args=[task.id],
countdown=settings.CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS * task.retry_count,
queue=POLL_QUEUE,
countdown=delay_seconds,
)
@@ -233,4 +346,4 @@ else:
def apply_async(self, *args, **kwargs):
raise RuntimeError("Celery is disabled")
poll_generation_task = _DisabledTask()
poll_generation_task = _DisabledTask()
@@ -0,0 +1,42 @@
from __future__ import annotations
from app.models.base import async_session
from app.services.shot_replicate_flow_service import run_image_prompt_optimize, run_video_prompt_optimize
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
async def _run_image_prompt(project_id: str, step_id: str | None = None):
async with async_session() as db:
await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id)
await db.commit()
async def _run_video_prompt(project_id: str, step_id: str | None = None):
async with async_session() as db:
await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id)
await db.commit()
if celery_app:
@celery_app.task(name="shot_replicate.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None):
return run_async(_run_image_prompt(project_id, step_id))
@celery_app.task(name="shot_replicate.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None):
return run_async(_run_video_prompt(project_id, step_id))
else:
class _DisabledTask:
def delay(self, *args, **kwargs):
raise RuntimeError("Celery is disabled")
def apply_async(self, *args, **kwargs):
raise RuntimeError("Celery is disabled")
start_image_prompt_optimize = _DisabledTask()
start_video_prompt_optimize = _DisabledTask()
@@ -0,0 +1,505 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from app.config import settings
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
ShotSegmentAnalysisStatusEnum,
ShotSegmentSourceModeEnum,
ShotSplitStatusEnum,
ShotTaskSetStatusEnum,
)
from app.models.base import async_session
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
from app.services.redis_registry_service import redis_acquire_lock, redis_release_lock
from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
from app.services.shot_video_split_service import split_video_segment_async
from app.services.upload_video_asset_service import validate_split_range
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
logger = logging.getLogger("video_gen")
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
SPLIT_QUEUE = "gen_result_download"
ANALYSIS_QUEUE = "gen_chatapi_create"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _lease_until(now: datetime | None = None) -> datetime:
return (now or _now()) + timedelta(seconds=int(settings.SHOT_SPLIT_LEASE_SECONDS or 600))
def _retry_at(attempt: int, now: datetime | None = None) -> datetime:
base = int(settings.SHOT_SPLIT_RETRY_BACKOFF_SECONDS or settings.DOWNLOAD_TASK_RETRY_BACKOFF_SECONDS or 30)
return (now or _now()) + timedelta(seconds=max(1, base * max(1, attempt)))
async def _acquire_split_semaphore(segment_id: str) -> str | None:
"""简单 Redis 并发闸门:用固定槽位锁限制 ffmpeg 同时运行数量。"""
max_concurrent = max(1, int(settings.SHOT_SPLIT_MAX_CONCURRENT or 1))
ttl = int(settings.SHOT_SPLIT_LEASE_SECONDS or 600)
for slot in range(max_concurrent):
key = f"{settings.SHOT_SPLIT_SEMAPHORE_KEY_PREFIX}:{slot}"
token = await redis_acquire_lock(lock_key=key, ttl_seconds=ttl, token=segment_id, log_context="shot_split_semaphore")
if token:
return key
return None
async def _release_split_semaphore(lock_key: str | None, segment_id: str) -> None:
if lock_key:
await redis_release_lock(lock_key=lock_key, token=segment_id, log_context="shot_split_semaphore")
async def _run_analyze_original_video(task_set_id: str) -> None:
task_set_user_id: str | None = None
video_url: str | None = None
try:
async with async_session() as db:
result = await db.execute(
select(ShotReplicateTaskSet)
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
task_set = result.scalar_one_or_none()
if not task_set:
return
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
return
task_set_user_id = task_set.user_id
video_url = task_set.video_url
task_set.status = ShotTaskSetStatusEnum.ANALYZING.value
task_set.analysis_status = ShotAnalysisStatusEnum.PROCESSING.value
task_set.analysis_error_message = None
await db.commit()
log_module_event_file(
module=MODULE,
event_type="SHOT_ANALYSIS_STARTED",
project_id=task_set_id,
user_id=task_set_user_id,
message="原视频拆镜分析开始",
detail={"task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "full_breakdown"},
)
async with async_session() as db:
analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=task_set_user_id, mode="full_breakdown")
result = await db.execute(
select(ShotReplicateTaskSet)
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
task_set = result.scalar_one_or_none()
if not task_set:
await db.rollback()
return
result_json = analyzed.result
task_set.original_video_content = str(result_json.get("原视频内容") or "")
task_set.original_video_category = str(result_json.get("原视频分类") or "")
task_set.original_video_audience = str(result_json.get("原视频受众人群") or "")
task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or []
task_set.analysis_raw_json = analyzed.raw_response
task_set.analysis_result_json = result_json
task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
task_set.analysis_error_message = None
await db.commit()
log_module_prompt_event(
event_type="SHOT_ANALYSIS_SUCCESS",
project_id=task_set_id,
step_id=task_set_id,
user_id=task_set_user_id or "",
module=MODULE,
prompt_type="shot_video_analysis",
request=analyzed.usage.get("log_request") if isinstance(analyzed.usage, dict) else {},
response=analyzed.result,
token_usage=analyzed.usage,
)
log_module_event_file(
module=MODULE,
event_type="SHOT_ANALYSIS_SUCCESS",
project_id=task_set_id,
user_id=task_set_user_id,
message="原视频拆镜分析成功",
detail={"suggestion_count": len(analyzed.result.get("拆镜内容剖析") or []), "token_usage": analyzed.usage},
)
except Exception as exc:
async with async_session() as db:
result = await db.execute(
select(ShotReplicateTaskSet)
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
task_set = result.scalar_one_or_none()
if task_set:
task_set_user_id = task_set_user_id or task_set.user_id
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
task_set.analysis_error_message = str(exc)
await db.commit()
log_module_error(
module=MODULE,
event_type="SHOT_ANALYSIS_FAILED",
project_id=task_set_id,
user_id=task_set_user_id,
message="原视频拆镜分析失败",
detail={"task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "full_breakdown"},
exc=exc,
)
async def _run_analyze_custom_segment_video(segment_id: str) -> None:
user_id: str | None = None
task_set_id: str | None = None
video_url: str | None = None
try:
async with async_session() as db:
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment or not segment.segment_video_url:
return
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value:
return
user_id = segment.user_id
task_set_id = segment.task_set_id
video_url = segment.segment_video_url
segment.analysis_status = ShotSegmentAnalysisStatusEnum.PROCESSING.value
segment.analysis_error_message = None
await db.commit()
log_module_event_file(
module=MODULE,
event_type="SHOT_SEGMENT_ANALYSIS_STARTED",
project_id=task_set_id,
step_id=segment_id,
user_id=user_id,
message="自定义拆镜片段分析开始",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "summary_only"},
)
async with async_session() as db:
analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=user_id, mode="summary_only")
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment:
await db.rollback()
return
result_json = analyzed.result
segment.original_video_content = str(result_json.get("原视频内容") or "")
segment.original_video_category = str(result_json.get("原视频分类") or "")
segment.original_video_audience = str(result_json.get("原视频受众人群") or "")
segment.segment_content = segment.original_video_content
segment.segment_category = segment.original_video_category
segment.segment_audience = segment.original_video_audience
segment.analysis_json = result_json
segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
segment.analysis_error_message = None
await db.commit()
log_module_prompt_event(
event_type="SHOT_SEGMENT_ANALYSIS_SUCCESS",
project_id=task_set_id or segment_id,
step_id=segment_id,
user_id=user_id or "",
module=MODULE,
prompt_type="shot_segment_analysis",
request=analyzed.usage.get("log_request") if isinstance(analyzed.usage, dict) else {},
response=analyzed.result,
token_usage=analyzed.usage,
)
log_module_event_file(
module=MODULE,
event_type="SHOT_SEGMENT_ANALYSIS_SUCCESS",
project_id=task_set_id,
step_id=segment_id,
user_id=user_id,
message="自定义拆镜片段分析成功",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "token_usage": analyzed.usage},
)
except Exception as exc:
async with async_session() as db:
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
if segment:
user_id = user_id or segment.user_id
task_set_id = task_set_id or segment.task_set_id
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
segment.analysis_error_message = str(exc)
await db.commit()
log_module_error(
module=MODULE,
event_type="SHOT_SEGMENT_ANALYSIS_FAILED",
project_id=task_set_id,
step_id=segment_id,
user_id=user_id,
message="自定义拆镜片段分析失败",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "summary_only"},
exc=exc,
)
async def _run_split_one_segment(segment_id: str) -> None:
segment_lock_key = f"{settings.SHOT_SPLIT_LOCK_KEY_PREFIX}:{segment_id}"
segment_lock_token = await redis_acquire_lock(
lock_key=segment_lock_key,
ttl_seconds=int(settings.SHOT_SPLIT_LEASE_SECONDS or 600),
log_context="shot_split_segment_lock",
)
if not segment_lock_token:
return
semaphore_key: str | None = None
user_id: str | None = None
task_set_id: str | None = None
source_path: str | None = None
try:
semaphore_key = await _acquire_split_semaphore(segment_id)
if not semaphore_key:
log_module_event_file(
module=MODULE,
event_type="SHOT_SEGMENT_SPLIT_RETRY_WAITING",
step_id=segment_id,
message="拆镜 ffmpeg 并发闸门已满,稍后重试",
detail={"segment_id": segment_id, "reason": "semaphore_full"},
)
if celery_app:
split_one_segment.apply_async(args=[segment_id], queue=SPLIT_QUEUE, countdown=10, priority=settings.DOWNLOAD_TASK_PRIORITY_NORMAL)
return
async with async_session() as db:
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment:
return
user_id = segment.user_id
task_set_id = segment.task_set_id
task_set_result = await db.execute(
select(ShotReplicateTaskSet)
.where(ShotReplicateTaskSet.id == segment.task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
task_set = task_set_result.scalar_one_or_none()
if not task_set:
return
if segment.split_status == ShotSplitStatusEnum.COMPLETED.value and segment.segment_video_url:
return
validate_split_range(
start_second=segment.start_second,
end_second=segment.end_second,
video_duration_seconds=task_set.video_duration_seconds,
)
now = _now()
segment.split_status = ShotSplitStatusEnum.PROCESSING.value
segment.split_started_at = now
segment.split_lease_until = _lease_until(now)
segment.split_retry_count = int(segment.split_retry_count or 0) + 1
segment.split_next_retry_at = None
segment.split_last_error = None
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
await db.commit()
source_path = task_set.video_path
date_dir = (segment.created_at or now).strftime("%Y/%m/%d")
start_second = segment.start_second
end_second = segment.end_second
attempt = segment.split_retry_count
log_module_event_file(
module=MODULE,
event_type="SHOT_SEGMENT_SPLIT_STARTED",
project_id=task_set_id,
step_id=segment_id,
user_id=user_id,
message="拆镜片段 ffmpeg 切割开始",
detail={
"segment_id": segment_id,
"task_set_id": task_set_id,
"source_path": source_path,
"start_second": start_second,
"end_second": end_second,
"attempt": attempt,
},
)
split_result = await split_video_segment_async(
source_path=source_path,
segment_id=segment_id,
start_second=start_second,
end_second=end_second,
date_dir=date_dir,
)
async with async_session() as db:
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment:
return
segment.segment_video_url = split_result.url
segment.segment_video_path = split_result.path
segment.split_status = ShotSplitStatusEnum.COMPLETED.value
segment.split_completed_at = _now()
segment.split_lease_until = None
segment.split_next_retry_at = None
segment.split_last_error = None
await refresh_task_set_split_summary(db, segment.task_set_id)
await db.commit()
log_module_event_file(
module=MODULE,
event_type="SHOT_SEGMENT_SPLIT_SUCCESS",
project_id=segment.task_set_id,
step_id=segment.id,
user_id=segment.user_id,
message="拆镜片段 ffmpeg 切割成功",
detail={
"segment_id": segment.id,
"task_set_id": segment.task_set_id,
"segment_video_url": split_result.url,
"segment_video_path": split_result.path,
"source_mode": segment.source_mode,
},
)
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value and celery_app:
analyze_custom_segment_video.apply_async(args=[segment.id], queue=ANALYSIS_QUEUE, countdown=0)
except Exception as exc:
next_retry_delay: int | None = None
final_failed = False
async with async_session() as db:
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment:
return
user_id = user_id or segment.user_id
task_set_id = task_set_id or segment.task_set_id
attempt = int(segment.split_retry_count or 0)
segment.split_last_error = str(exc)
segment.split_lease_until = None
if attempt >= int(settings.SHOT_SPLIT_MAX_RETRY_COUNT or 3):
segment.split_status = ShotSplitStatusEnum.FAILED.value
segment.split_next_retry_at = None
final_failed = True
else:
segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value
segment.split_next_retry_at = _retry_at(attempt)
await refresh_task_set_split_summary(db, segment.task_set_id)
await db.commit()
if segment.split_status == ShotSplitStatusEnum.RETRY_WAITING.value and celery_app:
next_retry_delay = max(1, int(((segment.split_next_retry_at or _now()) - _now()).total_seconds()))
split_one_segment.apply_async(args=[segment_id], queue=SPLIT_QUEUE, countdown=next_retry_delay, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER)
log_module_error(
module=MODULE,
event_type="SHOT_SEGMENT_SPLIT_FAILED" if final_failed else "SHOT_SEGMENT_SPLIT_RETRY_WAITING",
project_id=task_set_id,
step_id=segment_id,
user_id=user_id,
message="拆镜片段 ffmpeg 切割失败" if final_failed else "拆镜片段 ffmpeg 切割失败,等待重试",
detail={
"segment_id": segment_id,
"task_set_id": task_set_id,
"source_path": source_path,
"next_retry_delay_seconds": next_retry_delay,
"final_failed": final_failed,
},
exc=exc,
)
finally:
await _release_split_semaphore(semaphore_key, segment_id)
await redis_release_lock(lock_key=segment_lock_key, token=segment_lock_token, log_context="shot_split_segment_lock")
async def _run_recover_split_tasks_once() -> dict[str, Any]:
from app.services.shot_replicate_recovery_service import recover_shot_split_tasks_once
async with async_session() as db:
return await recover_shot_split_tasks_once(db)
if celery_app:
@celery_app.task(name="shot_replicate.analyze_original_video")
def analyze_original_video(task_set_id: str) -> None:
return run_async(_run_analyze_original_video(task_set_id))
@celery_app.task(name="shot_replicate.split_one_segment", bind=True, max_retries=0)
def split_one_segment(self, segment_id: str) -> None:
return run_async(_run_split_one_segment(segment_id))
@celery_app.task(name="shot_replicate.analyze_custom_segment_video")
def analyze_custom_segment_video(segment_id: str) -> None:
return run_async(_run_analyze_custom_segment_video(segment_id))
@celery_app.task(name="shot_replicate.recover_split_tasks_once")
def recover_split_tasks_once() -> dict[str, Any]:
return run_async(_run_recover_split_tasks_once())
else:
class _DisabledTask:
def delay(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("Celery is disabled")
def apply_async(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("Celery is disabled")
analyze_original_video = _DisabledTask()
split_one_segment = _DisabledTask()
analyze_custom_segment_video = _DisabledTask()
recover_split_tasks_once = _DisabledTask()