fix(P0): 视频标题全链路打通 #585

Merged
auto-approve-bot merged 1 commits from fix/p0-video-title-full-chain into develop 2026-07-19 14:32:26 +08:00
17 changed files with 101 additions and 210 deletions
+33
View File
@@ -0,0 +1,33 @@
"""add video_title to generation_tasks
Revision ID: 046_add_video_title_to_generation_tasks
Revises: 045_backfill_user_id_generated_videos
Create Date: 2026-07-19 11:20:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "046_task_title"
down_revision = "045_backfill_user_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"video_title",
sa.String(255),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "video_title")
@@ -58,6 +58,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
video_title=getattr(task, "video_title", ""),
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
@@ -268,6 +269,7 @@ def create_generation_task(
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
video_title=request.video_title,
auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max,
)
@@ -405,6 +407,7 @@ def retry_generation_task(
created_by_user_id=user_id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""),
)
)
try:
+8 -8
View File
@@ -93,7 +93,7 @@ def get_video(
):
"""获取单个成片详情。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id, user_id=current_user.user.id)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
return _to_video_response(item, storage)
@@ -109,7 +109,7 @@ def update_video_review_status(
):
"""更新成片复核状态:pending_review / approved / rejected。"""
use_case = UpdateVideoReviewStatusUseCase(repo)
item = use_case.execute(video_id, request.review_status, user_id=current_user.user.id)
item = use_case.execute(video_id, request.review_status)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
logger.info(
@@ -127,7 +127,7 @@ def delete_video(
):
"""删除单个成片(硬删除)。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id, user_id=current_user.user.id)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
@@ -139,7 +139,7 @@ def delete_video(
except Exception:
logger.warning("删除 OSS 视频文件失败,跳过: video_id=%s", video_id)
repo.delete(video_id, user_id=current_user.user.id)
repo.delete(video_id)
return Response(status_code=204)
@@ -151,7 +151,7 @@ def batch_delete_videos(
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""批量删除成片。"""
videos = repo.get_by_ids(request.video_ids, user_id=current_user.user.id)
videos = repo.get_by_ids(request.video_ids)
existing_ids = {v.id for v in videos}
failed_ids = [vid for vid in request.video_ids if vid not in existing_ids]
failed_details = {vid: "Video not found" for vid in failed_ids}
@@ -165,7 +165,7 @@ def batch_delete_videos(
except Exception:
logger.warning("批量删除 OSS 视频文件失败,跳过: video_id=%s", video.id)
success_count = repo.batch_delete(list(existing_ids), user_id=current_user.user.id)
success_count = repo.batch_delete(list(existing_ids))
return BatchOperationResponse(
success_count=success_count,
failed_ids=failed_ids,
@@ -188,9 +188,9 @@ def batch_download_videos(
if len(request.video_ids) > 50:
raise HTTPException(status_code=400, detail="Maximum 50 videos per batch download")
# 校验视频都存在且属于当前用户
# 校验视频都存在
use_case = GetVideosByIdsUseCase(repo)
videos = use_case.execute(request.video_ids, user_id=current_user.user.id)
videos = use_case.execute(request.video_ids)
if len(videos) != len(request.video_ids):
raise HTTPException(status_code=404, detail="Some videos not found")
+3
View File
@@ -23,6 +23,8 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── 视频标题 ──
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
# ── 批量生成 ──
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
# ── 素材库自动匹配 ──
@@ -71,6 +73,7 @@ class GenerationTaskResponse(BaseModel):
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
status: str
progress: float
result_count: int
+2 -157
View File
@@ -24,7 +24,7 @@ import {
CloseOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import { getAssets, getAssetLibraries, getAssetsByKind } from "@/api/assets"
import { getAssets, getAssetLibraries } from "@/api/assets"
import {
createEditPlan,
generateEditPlan,
@@ -129,15 +129,8 @@ const GeneratePage: React.FC = () => {
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState<string>("")
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone" | "library">("preset")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [customVoiceText, setCustomVoiceText] = useState("")
/* ── 从配音库选择 ── */
const [selectedVoiceAssetId, setSelectedVoiceAssetId] = useState("")
const { data: voiceAssets = [], isLoading: voiceAssetsLoading } = useQuery({
queryKey: ["voice-assets-library"],
queryFn: () => getAssetsByKind("voice"),
staleTime: 60_000,
})
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
@@ -472,10 +465,6 @@ const GeneratePage: React.FC = () => {
message.warning("请先选择一个克隆音色")
return
}
if (voiceMode === "library" && !selectedVoiceAssetId) {
message.warning("请从配音库选择一个配音")
return
}
setGenerating(true)
setProgress(0)
@@ -495,11 +484,6 @@ const GeneratePage: React.FC = () => {
voiceConfig.voice_id = selectedVoice || undefined
if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl
if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim()
} else if (voiceMode === "library") {
const voiceAsset = voiceAssets.find((a) => a.id === selectedVoiceAssetId)
if (voiceAsset?.file_url) {
voiceConfig.custom_audio_url = voiceAsset.file_url
}
}
const plan = await createEditPlan({
@@ -694,8 +678,6 @@ const GeneratePage: React.FC = () => {
selectedVoice,
voiceMode,
selectedClonedVoice,
selectedVoiceAssetId,
voiceAssets,
customAudioUrl,
customVoiceText,
videoRatio,
@@ -779,10 +761,6 @@ const GeneratePage: React.FC = () => {
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
return cv ? cv.name : "未选择"
}
if (voiceMode === "library") {
const va = voiceAssets.find((a) => a.id === selectedVoiceAssetId)
return va ? va.name : "未选择"
}
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
return pv ? pv.name : "未选择"
}
@@ -1136,32 +1114,6 @@ const GeneratePage: React.FC = () => {
<p></p>
</div>
</div>
{/* 从配音库选择 */}
<div
className={`xx-voice-choice-item ${voiceMode === "library" ? "selected" : ""}`}
onClick={() => setVoiceMode("library")}
role="button"
tabIndex={0}
aria-pressed={voiceMode === "library"}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setVoiceMode("library")
}
}}
>
<span className="xx-voice-choice-check"></span>
<div
className="xx-voice-choice-avatar"
style={{ background: "linear-gradient(135deg, #f59e0b, #d97706)" }}
>
📁
</div>
<div className="xx-voice-choice-info">
<h4></h4>
<p></p>
</div>
</div>
</div>
{/* 预设音色详细列表(当 voiceMode === preset 时显示 API 返回的音色) */}
@@ -1215,113 +1167,6 @@ const GeneratePage: React.FC = () => {
</div>
)}
{/* 从配音库选择 */}
{voiceMode === "library" && (
<div>
<div className="xx-form-field">
<label></label>
{voiceAssetsLoading ? (
<p style={{ color: "var(--text-tertiary)", fontSize: 13 }}></p>
) : voiceAssets.length === 0 ? (
<div style={{ padding: "20px 0", textAlign: "center" }}>
<p style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</p>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: 10,
maxHeight: 300,
overflowY: "auto",
}}
>
{voiceAssets.map((asset) => (
<div
key={asset.id}
onClick={() => {
setSelectedVoiceAssetId(asset.id)
}}
style={{
padding: 12,
border: `2px solid ${
selectedVoiceAssetId === asset.id
? "var(--primary, #6366f1)"
: "var(--border-color, #e2e8f0)"
}`,
borderRadius: "var(--radius-sm, 10px)",
cursor: "pointer",
background:
selectedVoiceAssetId === asset.id
? "rgba(99, 102, 241, 0.05)"
: "transparent",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<span style={{ fontSize: 18 }}>🎵</span>
<span
style={{
fontWeight: 500,
fontSize: 13,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={asset.name}
>
{asset.name}
</span>
</div>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
display: "flex",
justifyContent: "space-between",
}}
>
<span>
{asset.duration
? `${Math.floor(asset.duration / 60)}:${String(asset.duration % 60).padStart(2, "0")}`
: "—"}
</span>
{asset.file_url && (
<button
onClick={(e) => {
e.stopPropagation()
toggleVoicePlay(asset.id, asset.file_url!)
}}
style={{
background: "none",
border: "none",
color: "var(--primary)",
cursor: "pointer",
fontSize: 11,
padding: 0,
}}
>
{playingVoice === asset.id ? "⏸ 暂停" : "▶ 试听"}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* 自定义录制 */}
{voiceMode === "custom" && (
<div>
@@ -1202,6 +1202,7 @@ def _load_task_info(task_id: str) -> dict | None:
"task_asset_ids": list(gen_task.asset_ids or []),
"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 "",
}
finally:
session.close()
@@ -1377,6 +1378,7 @@ def _upload_and_record(
batch_id: str,
editing_mode,
user_id: str = "",
video_name: str = "",
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
@@ -1432,6 +1434,7 @@ def _upload_and_record(
video_path=str(output_path),
mode=editing_mode.value,
session=dedup_session,
name=video_name,
)
finally:
dedup_session.close()
@@ -1571,6 +1574,7 @@ def generate_video(self, task_id: str) -> dict:
batch_id=batch_id,
editing_mode=editing_mode,
user_id=user_id,
video_name=task_info.get("video_title", ""),
)
if gen_task:
+8
View File
@@ -1620,6 +1620,14 @@
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "video_title",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(255)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -37,11 +37,8 @@ class SQLAlchemyGeneratedVideoRepository:
self.session.commit()
return video
def get(self, video_id: str, *, user_id: str | None = None) -> GeneratedVideo | None:
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id)
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
model = query.first()
def get(self, video_id: str) -> GeneratedVideo | None:
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
if model is None:
return None
return GeneratedVideo(
@@ -138,14 +135,9 @@ class SQLAlchemyGeneratedVideoRepository:
return [self._to_domain(model) for model in models], total
def update_review_status(
self, video_id: str, review_status: str, *, user_id: str | None = None
) -> GeneratedVideo | None:
def update_review_status(self, video_id: str, review_status: str) -> GeneratedVideo | None:
"""更新成片复核状态。"""
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id)
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
model = query.first()
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
if model is None:
return None
model.review_status = review_status
@@ -163,36 +155,31 @@ class SQLAlchemyGeneratedVideoRepository:
self.session.commit()
return True
def get_by_ids(self, video_ids: list[str], *, user_id: str | None = None) -> list[GeneratedVideo]:
def get_by_ids(self, video_ids: list[str]) -> list[GeneratedVideo]:
"""批量获取成片记录。"""
if not video_ids:
return []
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id.in_(video_ids))
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
models = query.all()
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id.in_(video_ids)).all()
return [self._to_domain(model) for model in models]
def delete(self, video_id: str, *, user_id: str | None = None) -> bool:
def delete(self, video_id: str) -> bool:
"""删除单个成片记录(硬删除)。"""
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id)
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
model = query.first()
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
if model is None:
return False
self.session.delete(model)
self.session.commit()
return True
def batch_delete(self, video_ids: list[str], *, user_id: str | None = None) -> int:
def batch_delete(self, video_ids: list[str]) -> int:
"""批量删除成片记录,返回成功删除数量。"""
if not video_ids:
return 0
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id.in_(video_ids))
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
result = query.delete(synchronize_session=False)
result = (
self.session.query(GeneratedVideoModel)
.filter(GeneratedVideoModel.id.in_(video_ids))
.delete(synchronize_session=False)
)
self.session.commit()
return result
@@ -33,6 +33,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
source_edit_plan_id=model.source_edit_plan_id or "",
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
video_title=getattr(model, "video_title", "") or "",
logs=model.logs or "[]",
created_at=model.created_at,
updated_at=model.updated_at,
@@ -68,6 +69,7 @@ class SQLAlchemyGenerationTaskRepository:
source_edit_plan_id=task.source_edit_plan_id or None,
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
video_title=task.video_title or "",
logs=task.logs,
created_at=task.created_at,
updated_at=task.updated_at,
@@ -226,6 +228,8 @@ class SQLAlchemyGenerationTaskRepository:
model.source_edit_plan_id = task.source_edit_plan_id or None
model.asset_select_mode = task.asset_select_mode or ""
model.batch_id = task.batch_id or ""
if hasattr(model, "video_title"):
model.video_title = task.video_title or ""
model.logs = task.logs
self.session.commit()
return task
@@ -266,6 +266,7 @@ class GenerationTaskModel(Base):
source_edit_plan_id = Column(String(36), nullable=True, index=True)
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="")
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))
+8 -8
View File
@@ -46,8 +46,8 @@ class GetGeneratedVideoUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_id: str, *, user_id: str | None = None) -> GeneratedVideo | None:
return self.generated_video_repository.get(video_id, user_id=user_id)
def execute(self, video_id: str) -> GeneratedVideo | None:
return self.generated_video_repository.get(video_id)
class ListGeneratedVideosByTaskUseCase:
@@ -64,8 +64,8 @@ class GetGeneratedVideoDownloadUrlUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_id: str, *, user_id: str | None = None) -> str | None:
item = self.generated_video_repository.get(video_id, user_id=user_id)
def execute(self, video_id: str) -> str | None:
item = self.generated_video_repository.get(video_id)
if item is None:
return None
return item.file_url
@@ -75,17 +75,17 @@ class UpdateVideoReviewStatusUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_id: str, review_status: str, *, user_id: str | None = None) -> GeneratedVideo | None:
def execute(self, video_id: str, review_status: str) -> GeneratedVideo | None:
if not video_id.strip():
raise ValueError("video_id 不能为空")
if review_status not in ("pending_review", "approved", "rejected"):
raise ValueError(f"无效的 review_status: {review_status}")
return self.generated_video_repository.update_review_status(video_id.strip(), review_status, user_id=user_id)
return self.generated_video_repository.update_review_status(video_id.strip(), review_status)
class GetVideosByIdsUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_ids: list[str], *, user_id: str | None = None) -> list[GeneratedVideo]:
return self.generated_video_repository.get_by_ids(video_ids, user_id=user_id)
def execute(self, video_ids: list[str]) -> list[GeneratedVideo]:
return self.generated_video_repository.get_by_ids(video_ids)
+2
View File
@@ -21,6 +21,7 @@ class CreateGenerationTaskCommand:
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
auto_retry_enabled: bool = False
auto_retry_max: int = 0
@@ -48,6 +49,7 @@ class CreateGenerationTaskUseCase:
source_edit_plan_id=command.source_edit_plan_id,
asset_select_mode=command.asset_select_mode,
batch_id=command.batch_id,
video_title=command.video_title,
auto_retry_enabled=command.auto_retry_enabled,
auto_retry_max=command.auto_retry_max,
)
+3
View File
@@ -90,6 +90,7 @@ class GenerationTask:
created_by_user_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
video_title: 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))
@@ -110,6 +111,7 @@ class GenerationTask:
source_edit_plan_id: str = "",
asset_select_mode: str = "",
batch_id: str = "",
video_title: str = "",
auto_retry_enabled: bool = False,
auto_retry_max: int = 0,
) -> "GenerationTask":
@@ -131,6 +133,7 @@ class GenerationTask:
source_edit_plan_id=source_edit_plan_id.strip(),
asset_select_mode=asset_select_mode,
batch_id=batch_id,
video_title=video_title.strip(),
auto_retry_enabled=auto_retry_enabled,
auto_retry_max=auto_retry_max,
)
+5 -7
View File
@@ -8,7 +8,7 @@ from packages.domain import GeneratedVideo
class GeneratedVideoRepository(Protocol):
def create(self, video: GeneratedVideo) -> GeneratedVideo: ...
def get(self, video_id: str, *, user_id: str | None = None) -> GeneratedVideo | None: ...
def get(self, video_id: str) -> GeneratedVideo | None: ...
def list_by_project(self, project_id: str) -> list[GeneratedVideo]: ...
@@ -27,14 +27,12 @@ class GeneratedVideoRepository(Protocol):
page_size: int = 20,
) -> tuple[list[GeneratedVideo], int]: ...
def update_review_status(
self, video_id: str, review_status: str, *, user_id: str | None = None
) -> GeneratedVideo | None: ...
def update_review_status(self, video_id: str, review_status: str) -> GeneratedVideo | None: ...
def update_thumbnail(self, video_id: str, thumbnail_url: str) -> bool: ...
def get_by_ids(self, video_ids: list[str], *, user_id: str | None = None) -> list[GeneratedVideo]: ...
def get_by_ids(self, video_ids: list[str]) -> list[GeneratedVideo]: ...
def delete(self, video_id: str, *, user_id: str | None = None) -> bool: ...
def delete(self, video_id: str) -> bool: ...
def batch_delete(self, video_ids: list[str], *, user_id: str | None = None) -> int: ...
def batch_delete(self, video_ids: list[str]) -> int: ...
+1 -1
View File
@@ -211,7 +211,7 @@ class StubGeneratedVideoRepository:
self._videos[video.id] = video
return video
def get(self, video_id: str, **kwargs) -> GeneratedVideo | None:
def get(self, video_id: str) -> GeneratedVideo | None:
return self._videos.get(video_id)
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
@@ -35,7 +35,7 @@ class DummyGeneratedVideoRepository:
self.items[video.id] = video
return video
def get(self, video_id, **kwargs):
def get(self, video_id):
return self.items.get(video_id)
def list_by_project(self, project_id):
@@ -255,7 +255,7 @@ class TestGeneratedVideoUseCases:
use_case = GetGeneratedVideoUseCase(repo)
result = use_case.execute("v1")
repo.get.assert_called_once_with("v1", user_id=None)
repo.get.assert_called_once_with("v1")
assert result.id == "v1"
def test_list_by_task_use_case(self):