From a2d7a59182e273710532f13c71709527b55969e0 Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Tue, 2 Jun 2026 14:28:26 +0800 Subject: [PATCH] ffmpeg video cover add file --- video-gen-api/.env | 9 ++ .../5d2db654417b_增加生成图片的比例像素.py | 25 ++-- .../versions/aa17f8a032c9_your_message.py | 102 +++++++++++++--- .../versions/baf54bec4cb5_数据库修改.py | 110 +++++++++++++++--- .../dc1fbd112814_删除生成表的输入输出.py | 17 ++- video-gen-api/app/api/v1/admin.py | 5 + video-gen-api/app/api/v1/generation.py | 20 +++- video-gen-api/app/config.py | 11 ++ .../app/models/chat_generation_task.py | 1 + video-gen-api/app/models/generation_record.py | 1 + video-gen-api/app/schemas/generation.py | 1 + video-gen-api/app/schemas/generation_ai.py | 5 + .../app/services/generation_ai_service.py | 2 + .../services/generation_download_service.py | 13 +++ video-gen-api/app/services/video_queue.py | 8 ++ .../app/tasks/generation_download_tasks.py | 2 + 16 files changed, 284 insertions(+), 48 deletions(-) diff --git a/video-gen-api/.env b/video-gen-api/.env index abae6612..f2683a13 100644 --- a/video-gen-api/.env +++ b/video-gen-api/.env @@ -34,6 +34,7 @@ ENCRYPTION_KEY=dGhpc19pc18zMl9ieXRlX2tleV9mb3JfYWVzXzI1NiE= STORAGE_TYPE=local STORAGE_LOCAL_PATH=./storage/generate/videos STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images +STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers # Captcha CAPTCHA_ENABLED=true @@ -46,3 +47,11 @@ RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3 RESOURCE_SIGN_EXPIRE_SECONDS=300 RESOURCE_SIGN_ARG_EXPIRE=exp RESOURCE_SIGN_ARG_SIGNATURE=sign + +# FFMPEG + COVER +FFMPEG_BIN=/usr/bin/ffmpeg +VIDEO_COVER_SEEK_TIME=00:00:01 +VIDEO_COVER_FALLBACK_SEEK_TIME=00:00:00 +VIDEO_COVER_WIDTH=720 +VIDEO_COVER_TIMEOUT_SECONDS=15 +VIDEO_COVER_FORMAT=jpg \ No newline at end of file diff --git a/video-gen-api/alembic/versions/5d2db654417b_增加生成图片的比例像素.py b/video-gen-api/alembic/versions/5d2db654417b_增加生成图片的比例像素.py index 3454eb15..b0979ddf 100755 --- a/video-gen-api/alembic/versions/5d2db654417b_增加生成图片的比例像素.py +++ b/video-gen-api/alembic/versions/5d2db654417b_增加生成图片的比例像素.py @@ -17,15 +17,24 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = inspector.get_columns(table_name) + return any(col["name"] == column_name for col in columns) + + def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('generation_records', sa.Column('image_proportion', sa.String(length=8), nullable=True)) - op.add_column('generation_records', sa.Column('image_px', sa.String(length=10), nullable=True)) - # ### end Alembic commands ### + if not _has_column("generation_records", "image_proportion"): + op.add_column('generation_records', sa.Column('image_proportion', sa.String(length=8), nullable=True)) + + if not _has_column("generation_records", "image_px"): + op.add_column('generation_records', sa.Column('image_px', sa.String(length=10), nullable=True)) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('generation_records', 'image_px') - op.drop_column('generation_records', 'image_proportion') - # ### end Alembic commands ### + if _has_column("generation_records", "image_px"): + op.drop_column('generation_records', 'image_px') + + if _has_column("generation_records", "image_proportion"): + op.drop_column('generation_records', 'image_proportion') \ No newline at end of file diff --git a/video-gen-api/alembic/versions/aa17f8a032c9_your_message.py b/video-gen-api/alembic/versions/aa17f8a032c9_your_message.py index e657ef46..7417e6a0 100644 --- a/video-gen-api/alembic/versions/aa17f8a032c9_your_message.py +++ b/video-gen-api/alembic/versions/aa17f8a032c9_your_message.py @@ -11,29 +11,95 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = 'aa17f8a032c9' -down_revision: Union[str, None] = '8623aa4bf3a1' +revision: str = "aa17f8a032c9" +down_revision: Union[str, None] = "8623aa4bf3a1" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = inspector.get_columns(table_name) + return any(column.get("name") == column_name for column in columns) + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(index.get("name") == index_name for index in indexes) + + +def _add_column_if_not_exists(table_name: str, column: sa.Column) -> None: + if not _has_column(table_name, column.name): + op.add_column(table_name, column) + + +def _drop_column_if_exists(table_name: str, column_name: str) -> None: + if _has_column(table_name, column_name): + op.drop_column(table_name, column_name) + + +def _create_index_if_not_exists( + index_name: str, + table_name: str, + columns: list[str], + unique: bool = False, +) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_exists(index_name: str, table_name: str) -> None: + if _has_index(table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('chat_generation_tasks', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True)) - op.create_index(op.f('ix_chat_generation_tasks_deleted_at'), 'chat_generation_tasks', ['deleted_at'], unique=False) - op.add_column('generation_records', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True)) - op.create_index(op.f('ix_generation_records_deleted_at'), 'generation_records', ['deleted_at'], unique=False) - op.add_column('projects', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True)) - op.create_index(op.f('ix_projects_deleted_at'), 'projects', ['deleted_at'], unique=False) - # ### end Alembic commands ### + _add_column_if_not_exists( + "chat_generation_tasks", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + _create_index_if_not_exists( + "ix_chat_generation_tasks_deleted_at", + "chat_generation_tasks", + ["deleted_at"], + unique=False, + ) + + _add_column_if_not_exists( + "generation_records", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + _create_index_if_not_exists( + "ix_generation_records_deleted_at", + "generation_records", + ["deleted_at"], + unique=False, + ) + + _add_column_if_not_exists( + "projects", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + _create_index_if_not_exists( + "ix_projects_deleted_at", + "projects", + ["deleted_at"], + unique=False, + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_projects_deleted_at'), table_name='projects') - op.drop_column('projects', 'deleted_at') - op.drop_index(op.f('ix_generation_records_deleted_at'), table_name='generation_records') - op.drop_column('generation_records', 'deleted_at') - op.drop_index(op.f('ix_chat_generation_tasks_deleted_at'), table_name='chat_generation_tasks') - op.drop_column('chat_generation_tasks', 'deleted_at') - # ### end Alembic commands ### + _drop_index_if_exists("ix_projects_deleted_at", "projects") + _drop_column_if_exists("projects", "deleted_at") + + _drop_index_if_exists("ix_generation_records_deleted_at", "generation_records") + _drop_column_if_exists("generation_records", "deleted_at") + + _drop_index_if_exists( + "ix_chat_generation_tasks_deleted_at", + "chat_generation_tasks", + ) + _drop_column_if_exists("chat_generation_tasks", "deleted_at") \ No newline at end of file diff --git a/video-gen-api/alembic/versions/baf54bec4cb5_数据库修改.py b/video-gen-api/alembic/versions/baf54bec4cb5_数据库修改.py index 42e2e5cb..5fcd0c78 100644 --- a/video-gen-api/alembic/versions/baf54bec4cb5_数据库修改.py +++ b/video-gen-api/alembic/versions/baf54bec4cb5_数据库修改.py @@ -17,23 +17,101 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +TABLE_NAME = "credit_ratios" + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(index.get("name") == index_name for index in indexes) + + +def _has_foreign_key(table_name: str, fk_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + foreign_keys = inspector.get_foreign_keys(table_name) + return any(fk.get("name") == fk_name for fk in foreign_keys) + + +def _create_index_if_not_exists( + index_name: str, + table_name: str, + columns: list[str], + unique: bool = False, +) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_exists(index_name: str, table_name: str) -> None: + if _has_index(table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_index(op.f('ix_credit_ratios_gen_type'), 'credit_ratios', ['gen_type'], unique=False) - op.create_index('ix_credit_ratios_gen_type_engine_resolution', 'credit_ratios', ['gen_type', 'model_config_id', 'resolution'], unique=False) - op.create_index('ix_credit_ratios_gen_type_resolution', 'credit_ratios', ['gen_type', 'resolution'], unique=False) - op.create_index(op.f('ix_credit_ratios_model_config_id'), 'credit_ratios', ['model_config_id'], unique=False) - op.create_index(op.f('ix_credit_ratios_resolution'), 'credit_ratios', ['resolution'], unique=False) - op.drop_constraint(op.f('credit_ratios_model_config_id_fkey'), 'credit_ratios', type_='foreignkey') - # ### end Alembic commands ### + # 索引存在才不重复创建,避免 DuplicateTable + _create_index_if_not_exists( + "ix_credit_ratios_gen_type", + TABLE_NAME, + ["gen_type"], + unique=False, + ) + + _create_index_if_not_exists( + "ix_credit_ratios_gen_type_engine_resolution", + TABLE_NAME, + ["gen_type", "model_config_id", "resolution"], + unique=False, + ) + + _create_index_if_not_exists( + "ix_credit_ratios_gen_type_resolution", + TABLE_NAME, + ["gen_type", "resolution"], + unique=False, + ) + + _create_index_if_not_exists( + "ix_credit_ratios_model_config_id", + TABLE_NAME, + ["model_config_id"], + unique=False, + ) + + _create_index_if_not_exists( + "ix_credit_ratios_resolution", + TABLE_NAME, + ["resolution"], + unique=False, + ) + + # 外键存在才删除,避免 UndefinedObject + if _has_foreign_key(TABLE_NAME, "credit_ratios_model_config_id_fkey"): + op.drop_constraint( + "credit_ratios_model_config_id_fkey", + TABLE_NAME, + type_="foreignkey", + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_foreign_key(op.f('credit_ratios_model_config_id_fkey'), 'credit_ratios', 'model_configs', ['model_config_id'], ['id']) - op.drop_index(op.f('ix_credit_ratios_resolution'), table_name='credit_ratios') - op.drop_index(op.f('ix_credit_ratios_model_config_id'), table_name='credit_ratios') - op.drop_index('ix_credit_ratios_gen_type_resolution', table_name='credit_ratios') - op.drop_index('ix_credit_ratios_gen_type_engine_resolution', table_name='credit_ratios') - op.drop_index(op.f('ix_credit_ratios_gen_type'), table_name='credit_ratios') - # ### end Alembic commands ### + # 注意: + # 如果 model_config_id 已经改成 ImageEngine / VideoEngine 的 id, + # 恢复到 model_configs 外键可能因为历史数据不匹配而失败。 + # 当前业务是删除外键,所以 downgrade 这里只做尽量安全处理。 + + if not _has_foreign_key(TABLE_NAME, "credit_ratios_model_config_id_fkey"): + op.create_foreign_key( + "credit_ratios_model_config_id_fkey", + TABLE_NAME, + "model_configs", + ["model_config_id"], + ["id"], + ) + + _drop_index_if_exists("ix_credit_ratios_resolution", TABLE_NAME) + _drop_index_if_exists("ix_credit_ratios_model_config_id", TABLE_NAME) + _drop_index_if_exists("ix_credit_ratios_gen_type_resolution", TABLE_NAME) + _drop_index_if_exists("ix_credit_ratios_gen_type_engine_resolution", TABLE_NAME) + _drop_index_if_exists("ix_credit_ratios_gen_type", TABLE_NAME) \ No newline at end of file diff --git a/video-gen-api/alembic/versions/dc1fbd112814_删除生成表的输入输出.py b/video-gen-api/alembic/versions/dc1fbd112814_删除生成表的输入输出.py index 820a3774..90786054 100755 --- a/video-gen-api/alembic/versions/dc1fbd112814_删除生成表的输入输出.py +++ b/video-gen-api/alembic/versions/dc1fbd112814_删除生成表的输入输出.py @@ -16,16 +16,25 @@ down_revision: Union[str, None] = '5d2db654417b' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = inspector.get_columns(table_name) + return any(col["name"] == column_name for col in columns) def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('generation_records', 'request_data') - op.drop_column('generation_records', 'response_data') + if _has_column('generation_records', 'request_data'): + op.drop_column('generation_records', 'request_data') + if _has_column('generation_records', 'response_data'): + op.drop_column('generation_records', 'response_data') # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column('generation_records', sa.Column('response_data', sa.TEXT(), autoincrement=False, nullable=True)) - op.add_column('generation_records', sa.Column('request_data', sa.TEXT(), autoincrement=False, nullable=True)) + if not _has_column('generation_records', 'response_data'): + op.add_column('generation_records', sa.Column('response_data', sa.TEXT(), autoincrement=False, nullable=True)) + if not _has_column('generation_records', 'request_data'): + op.add_column('generation_records', sa.Column('request_data', sa.TEXT(), autoincrement=False, nullable=True)) # ### end Alembic commands ### diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 23f72876..acf799f8 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -1018,6 +1018,7 @@ async def admin_list_generation_records( "resolution": record.resolution, "status": record.status, "video_url": build_resource_signed_url(record.video_url) if record.video_url else '', + "video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '', "references": refs, "credits_cost": record.credits_cost or 0, "text_credits_cost": record.text_credits_cost or 0, @@ -1064,6 +1065,8 @@ async def admin_update_generation_status( record.status = new_status if body.get("video_url"): record.video_url = body["video_url"] + if body.get("video_cover_url"): + record.video_cover_url = body["video_cover_url"] if new_status == "completed": record.generated_at = datetime.now() await db.flush() @@ -1123,6 +1126,8 @@ async def admin_generate_video( record.credits_cost = (record.credits_cost or 0) + video_credits record.status = "generating" record.error_message = None + record.video_url = None + record.video_cover_url = None await db.flush() try: diff --git a/video-gen-api/app/api/v1/generation.py b/video-gen-api/app/api/v1/generation.py index 9ead3b15..c796d196 100644 --- a/video-gen-api/app/api/v1/generation.py +++ b/video-gen-api/app/api/v1/generation.py @@ -34,6 +34,7 @@ from app.services.resource_accounting_service import ( safe_file_size, ) from app.services.resource_signed_url_service import build_resource_signed_url +from app.services.video_cover_service import async_create_video_cover_for_local_video from app.utils.id_gen import generate_id from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError @@ -78,6 +79,7 @@ def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRec image_px=record.image_px, status=record.status, video_url=build_resource_signed_url(record.video_url) if record.video_url else '', + video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '', image_url=build_resource_signed_url(record.image_url) if record.image_url else '', references=refs, text_credits_cost=round(record.text_credits_cost or 0.00, 2), @@ -345,6 +347,8 @@ async def generate( record.credits_cost = round(video_credits, 2) record.status = "generating" record.error_message = None + record.video_url = None + record.video_cover_url = None await db.flush() try: @@ -422,6 +426,8 @@ async def retry_generation( record.status = "generating" record.error_message = None + record.video_url = None + record.video_cover_url = None await db.flush() try: @@ -556,9 +562,19 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db) if settings.STORAGE_TYPE == "local" and remote_url: try: from app.services.video_gen import download_video - dest = os.path.join(settings.STORAGE_LOCAL_PATH, f"{record.id}.mp4") + date_dir = datetime.now().strftime("%Y/%m/%d") + dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir) + os.makedirs(dest_dir, exist_ok=True) + dest = os.path.join(dest_dir, f"{record.id}.mp4") await download_video(remote_url, dest) - record.video_url = f"/generate/videos/{record.id}.mp4" + record.video_url = f"/generate/videos/{date_dir}/{record.id}.mp4" + cover_url, _cover_storage_path = await async_create_video_cover_for_local_video( + record_id=record.id, + video_path=dest, + date_dir=date_dir, + log_prefix=f"SeedanceCallback视频封面生成 record_id={record.id}", + ) + record.video_cover_url = cover_url storage_path = dest file_size_bytes = safe_file_size(dest) except Exception as e: diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py index 0c6d46ba..00c50746 100644 --- a/video-gen-api/app/config.py +++ b/video-gen-api/app/config.py @@ -47,8 +47,19 @@ class Settings(BaseSettings): STORAGE_TYPE: str = "local" STORAGE_LOCAL_PATH: str = "./storage/generate/videos" STORAGE_IMAGE_LOCAL_PATH: str = "./storage/generate/images" + STORAGE_VIDEO_COVER_LOCAL_PATH: str = "./storage/generate/covers" UPLOAD_LOCAL_PATH: str = "./storage/uploads" + # 本地视频封面截帧配置。 + # 说明: + # - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。 + # - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。 + FFMPEG_BIN: str = "" + VIDEO_COVER_SEEK_TIME: str = "00:00:01" + VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00" + VIDEO_COVER_WIDTH: int = 720 + VIDEO_COVER_TIMEOUT_SECONDS: int = 15 + VIDEO_COVER_FORMAT: str = "jpg" CAPTCHA_ENABLED: bool = True diff --git a/video-gen-api/app/models/chat_generation_task.py b/video-gen-api/app/models/chat_generation_task.py index ce5fb3a7..da17fd3f 100644 --- a/video-gen-api/app/models/chat_generation_task.py +++ b/video-gen-api/app/models/chat_generation_task.py @@ -44,6 +44,7 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin): remote_result_url: Mapped[str | None] = mapped_column(Text, nullable=True) image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) video_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True) engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/video-gen-api/app/models/generation_record.py b/video-gen-api/app/models/generation_record.py index c201ddfc..cb8fdb2b 100644 --- a/video-gen-api/app/models/generation_record.py +++ b/video-gen-api/app/models/generation_record.py @@ -28,6 +28,7 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin): status: Mapped[str] = mapped_column(String(32), default="prompt_optimized") video_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True) image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) media_references: Mapped[str | None] = mapped_column(Text, nullable=True) video_url_expires_at: Mapped[datetime | None] = mapped_column( diff --git a/video-gen-api/app/schemas/generation.py b/video-gen-api/app/schemas/generation.py index 83885fe5..51d71ce5 100644 --- a/video-gen-api/app/schemas/generation.py +++ b/video-gen-api/app/schemas/generation.py @@ -63,6 +63,7 @@ class GenerationRecordOut(BaseModel): image_px: str | None = None status: str video_url: str | None = None + video_cover_url: str | None = None image_url: str | None = None references: list[dict] | None = None text_credits_cost: float = 0.0 diff --git a/video-gen-api/app/schemas/generation_ai.py b/video-gen-api/app/schemas/generation_ai.py index a0f3f323..f0faec23 100644 --- a/video-gen-api/app/schemas/generation_ai.py +++ b/video-gen-api/app/schemas/generation_ai.py @@ -269,6 +269,7 @@ class GenerationAITaskOut(BaseModel): "remote_result_url": None, "image_url": "https://example.com/result.png", "video_url": None, + "video_cover_url": None, "engine_id": "engine_xxx", "engine_snapshot": { "engine_type": "image", @@ -344,6 +345,7 @@ class GenerationAITaskOut(BaseModel): ) image_url: str | None = Field(None, description="最终图片地址。图片任务完成后通常有值") video_url: str | None = Field(None, description="最终视频地址。视频任务完成后通常有值") + video_cover_url: str | None = Field(None, description="视频封面图片地址。视频任务完成且封面截帧成功后通常有值") engine_id: str | None = Field(None, description="本次任务使用的生成引擎ID") engine_snapshot: dict | None = Field( None, @@ -391,6 +393,7 @@ class GenerationAITaskListOut(BaseModel): "remote_result_url": None, "image_url": "https://example.com/result.png", "video_url": None, + "video_cover_url": None, "engine_id": "engine_xxx", "engine_snapshot": {}, "credits_cost": 10.0, @@ -487,6 +490,7 @@ class GenerationAIRecordHistoryItemOut(BaseModel): "remote_result_url": None, "image_url": "https://example.com/result.png", "video_url": None, + "video_cover_url": None, "engine_id": None, "engine_snapshot": None, "credits_cost": 10.0, @@ -544,6 +548,7 @@ class GenerationAIRecordHistoryItemOut(BaseModel): ) image_url: str | None = Field(None, description="最终图片地址。图片记录完成后通常有值") video_url: str | None = Field(None, description="最终视频地址。视频记录完成后通常有值") + video_cover_url: str | None = Field(None, description="视频封面图片地址。视频记录完成且封面截帧成功后通常有值") engine_id: str | None = Field( None, description="兼容新历史结构的引擎ID字段。旧 generation_records 未保存该字段,固定为 null", diff --git a/video-gen-api/app/services/generation_ai_service.py b/video-gen-api/app/services/generation_ai_service.py index f8a27cdb..a129f7e4 100644 --- a/video-gen-api/app/services/generation_ai_service.py +++ b/video-gen-api/app/services/generation_ai_service.py @@ -335,6 +335,7 @@ def record_to_out(task: ChatGenerationTask) -> GenerationAITaskOut: # remote_result_url=task.remote_result_url, image_url=build_resource_signed_url(task.image_url) if task.image_url else "", video_url=build_resource_signed_url(task.video_url) if task.video_url else "", + video_cover_url=build_resource_signed_url(task.video_cover_url) if task.video_cover_url else "", engine_id=task.engine_id, engine_snapshot=snapshot, credits_cost=task.credits_cost or 0.0, @@ -486,6 +487,7 @@ def generation_record_to_history_out( remote_result_url=None, image_url=build_resource_signed_url(record.image_url) if record.image_url else '', video_url=build_resource_signed_url(record.video_url) if record.video_url else '', + video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '', engine_id=None, engine_snapshot=None, credits_cost=record.credits_cost or 0.0, diff --git a/video-gen-api/app/services/generation_download_service.py b/video-gen-api/app/services/generation_download_service.py index 8730a1d9..74e8beb1 100644 --- a/video-gen-api/app/services/generation_download_service.py +++ b/video-gen-api/app/services/generation_download_service.py @@ -9,6 +9,7 @@ from app.models.chat_generation_task import ChatGenerationTask from app.services.image_gen import download_image from app.services.provider_limit import provider_limit from app.services.resource_accounting_service import safe_file_size +from app.services.video_cover_service import create_video_cover_for_local_video from app.services.video_gen import download_video @@ -19,6 +20,8 @@ class DownloadedGenerationResult: file_size_bytes: int resource_type: str storage_type: str = "local" + cover_url: str | None = None + cover_storage_path: str | None = None async def download_generation_result(record: ChatGenerationTask) -> DownloadedGenerationResult: @@ -44,9 +47,19 @@ async def download_generation_result(record: ChatGenerationTask) -> DownloadedGe dest = os.path.join(dest_dir, f"{record.id}.mp4") async with provider_limit("result_download", settings.RESULT_DOWNLOAD_MAX_CONCURRENCY): await download_video(record.remote_result_url, dest) + + cover_url, cover_storage_path = create_video_cover_for_local_video( + record_id=record.id, + video_path=dest, + date_dir=date_dir, + log_prefix=f"ChatGenerationTask视频封面生成 task_id={record.id}", + ) + return DownloadedGenerationResult( url=f"/generate/videos/{date_dir}/{record.id}.mp4", storage_path=dest, file_size_bytes=safe_file_size(dest), resource_type="video", + cover_url=cover_url, + cover_storage_path=cover_storage_path, ) diff --git a/video-gen-api/app/services/video_queue.py b/video-gen-api/app/services/video_queue.py index 493bbc48..8d4dca26 100644 --- a/video-gen-api/app/services/video_queue.py +++ b/video-gen-api/app/services/video_queue.py @@ -14,6 +14,7 @@ from app.services.resource_accounting_service import ( record_generation_record_generated_resource, safe_file_size, ) +from app.services.video_cover_service import create_video_cover_for_local_video from app.config import settings logger = logging.getLogger("videogen") @@ -132,6 +133,13 @@ class TaskQueue: dest = os.path.join(dest_dir, f"{record_id}.mp4") await download_video(file_url, dest) record.video_url = f"/generate/videos/{date_dir}/{record_id}.mp4" + cover_url, _cover_storage_path = create_video_cover_for_local_video( + record_id=record_id, + video_path=dest, + date_dir=date_dir, + log_prefix=f"GenerationRecord视频封面生成 record_id={record_id}", + ) + record.video_cover_url = cover_url storage_path = dest file_size_bytes = safe_file_size(dest) except Exception as e: diff --git a/video-gen-api/app/tasks/generation_download_tasks.py b/video-gen-api/app/tasks/generation_download_tasks.py index d26ec3b4..e459a966 100644 --- a/video-gen-api/app/tasks/generation_download_tasks.py +++ b/video-gen-api/app/tasks/generation_download_tasks.py @@ -119,6 +119,7 @@ async def _run(task_id: str): task.image_url = downloaded.url else: task.video_url = downloaded.url + task.video_cover_url = downloaded.cover_url task.status = "completed" task.pipeline_stage = "done" @@ -144,6 +145,7 @@ async def _run(task_id: str): to_stage="done", detail={ "resource_url": downloaded.url, + "video_cover_url": downloaded.cover_url, "file_size_bytes": downloaded.file_size_bytes, }, )