ffmpeg video cover add file
This commit is contained in:
@@ -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
|
||||
@@ -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')
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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 ###
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user