feat(#632): 一键生成输出分辨率可配置 #749

Merged
auto-approve-bot merged 1 commits from feat/oneclick-resolution-config into develop 2026-07-23 10:27:37 +08:00
9 changed files with 87 additions and 6 deletions
+53
View File
@@ -0,0 +1,53 @@
"""#632 - 一键生成输出分辨率可配置
Revision ID: 051
Revises: 050
Create Date: 2026-07-23
Changes:
1. generation_tasks 表新增 resolution 字段,存储用户指定的输出分辨率(如 "1280x720"
2. 为空时使用默认值(1280x720)
"""
import sqlalchemy as sa
from alembic import context, op
revision = "051_generation_task_resolution"
down_revision = "050_video_shares"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column("resolution", sa.String(20), nullable=False, server_default=""),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "resolution")
@@ -59,6 +59,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
@@ -270,6 +271,7 @@ def create_generation_task(
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
video_title=request.video_title,
resolution=request.resolution,
auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max,
)
@@ -408,6 +410,7 @@ def retry_generation_task(
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
)
)
try:
+6
View File
@@ -46,6 +46,11 @@ class CreateGenerationTaskRequest(BaseModel):
le=5,
description="最大自动重试次数,0表示不自动重试,最大5次",
)
# ── 输出分辨率 ──
resolution: str = Field(
default="",
description="输出分辨率,格式为 WIDTHxHEIGHT,如 1280x720、1080x1920。为空使用默认 1280x720",
)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -74,6 +79,7 @@ class GenerationTaskResponse(BaseModel):
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
resolution: str = ""
status: str
progress: float
result_count: int
+11 -6
View File
@@ -1110,6 +1110,7 @@ def _load_task_info(task_id: str) -> dict | None:
"batch_id": getattr(gen_task, "batch_id", "") or "",
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
"video_title": getattr(gen_task, "video_title", "") or "",
"resolution": getattr(gen_task, "resolution", "") or "",
}
finally:
session.close()
@@ -1168,6 +1169,7 @@ def _render_video(
user_id: str,
temp_path: Path,
output_name: str,
resolution: str = "",
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
@@ -1199,15 +1201,17 @@ def _render_video(
list(template_config.keys()),
)
# 确保输出分辨率配置存在(一键生成默认横屏 1280x720
# RenderAdapter 从 plan.config.export.resolution 读取,
# 如果模板没有配置则用默认值,这里显式设置保持和旧逻辑一致
# 确保输出分辨率配置存在
# 优先级:用户指定 > 模板配置 > 默认 1280x720
plan_cfg = virtual_plan.config or {}
export_cfg = plan_cfg.get("export", {}) or {}
if not export_cfg.get("resolution"):
if resolution:
# 用户在 API 调用时指定的分辨率优先级最高
export_cfg["resolution"] = resolution
elif not export_cfg.get("resolution"):
export_cfg["resolution"] = f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}"
plan_cfg["export"] = export_cfg
virtual_plan.config = plan_cfg
plan_cfg["export"] = export_cfg
virtual_plan.config = plan_cfg
total_duration = sum(c.duration for c in virtual_clips)
logger.info(
@@ -1456,6 +1460,7 @@ def generate_video(self, task_id: str) -> dict:
user_id=user_id,
temp_path=temp_path,
output_name=output_name,
resolution=task_info.get("resolution", ""),
)
if gen_task:
@@ -34,6 +34,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
video_title=getattr(model, "video_title", "") or "",
resolution=getattr(model, "resolution", "") or "",
logs=model.logs or "[]",
created_at=model.created_at,
updated_at=model.updated_at,
@@ -70,6 +71,7 @@ class SQLAlchemyGenerationTaskRepository:
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
video_title=task.video_title or "",
resolution=task.resolution or "",
logs=task.logs,
created_at=task.created_at,
updated_at=task.updated_at,
@@ -230,6 +232,8 @@ class SQLAlchemyGenerationTaskRepository:
model.batch_id = task.batch_id or ""
if hasattr(model, "video_title"):
model.video_title = task.video_title or ""
if hasattr(model, "resolution"):
model.resolution = task.resolution or ""
model.logs = task.logs
self.session.commit()
return task
@@ -291,6 +291,7 @@ class GenerationTaskModel(Base):
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(36), nullable=False, default="", index=True)
video_title = Column(String(255), nullable=False, default="")
resolution = Column(String(20), nullable=False, default="")
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
logs = Column(Text, nullable=False, default="[]", server_default="[]")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+2
View File
@@ -22,6 +22,7 @@ class CreateGenerationTaskCommand:
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
resolution: str = ""
auto_retry_enabled: bool = False
auto_retry_max: int = 0
@@ -50,6 +51,7 @@ class CreateGenerationTaskUseCase:
asset_select_mode=command.asset_select_mode,
batch_id=command.batch_id,
video_title=command.video_title,
resolution=command.resolution,
auto_retry_enabled=command.auto_retry_enabled,
auto_retry_max=command.auto_retry_max,
)
+3
View File
@@ -91,6 +91,7 @@ class GenerationTask:
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
resolution: str = ""
logs: str = "[]"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -112,6 +113,7 @@ class GenerationTask:
asset_select_mode: str = "",
batch_id: str = "",
video_title: str = "",
resolution: str = "",
auto_retry_enabled: bool = False,
auto_retry_max: int = 0,
) -> "GenerationTask":
@@ -134,6 +136,7 @@ class GenerationTask:
asset_select_mode=asset_select_mode,
batch_id=batch_id,
video_title=video_title.strip(),
resolution=resolution.strip(),
auto_retry_enabled=auto_retry_enabled,
auto_retry_max=auto_retry_max,
)
+4
View File
@@ -73,6 +73,7 @@ class TestGenerationTaskCreate:
asset_select_mode="smart",
batch_id="batch-001",
video_title="测试视频",
resolution="1080x1920",
auto_retry_enabled=True,
auto_retry_max=3,
)
@@ -87,6 +88,7 @@ class TestGenerationTaskCreate:
assert task.asset_select_mode == "smart"
assert task.batch_id == "batch-001"
assert task.video_title == "测试视频"
assert task.resolution == "1080x1920"
assert task.auto_retry_enabled is True
assert task.auto_retry_max == 3
@@ -134,12 +136,14 @@ class TestGenerationTaskCreate:
strategy_id=" strat-789 ",
template_id=" tmpl-001 ",
video_title=" 测试视频 ",
resolution=" 1080x1920 ",
)
assert task.project_id == "proj-123"
assert task.asset_library_id == "lib-456"
assert task.strategy_id == "strat-789"
assert task.template_id == "tmpl-001"
assert task.video_title == "测试视频"
assert task.resolution == "1080x1920"
def test_create_default_empty_lists(self):
"""测试 None 列表默认化为空列表"""