feat(results): add generated video management

This commit is contained in:
Xiaoxia AI
2026-06-24 06:28:06 +08:00
parent 719dc12d3d
commit 8acbd6c8a7
11 changed files with 230 additions and 29 deletions
@@ -0,0 +1,34 @@
"""Add generated video management fields.
Revision ID: 005
Revises: 004
Create Date: 2026-06-24
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("status", sa.String(length=20), nullable=False, server_default="completed"))
op.add_column("generated_videos", sa.Column("review_status", sa.String(length=20), nullable=False, server_default="pending_review"))
op.add_column("generated_videos", sa.Column("generation_params", sa.Text(), nullable=False, server_default="{}"))
op.add_column("generated_videos", sa.Column("updated_at", sa.DateTime(), nullable=True))
op.create_index(op.f("ix_generated_videos_status"), "generated_videos", ["status"], unique=False)
op.create_index(op.f("ix_generated_videos_review_status"), "generated_videos", ["review_status"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_generated_videos_review_status"), table_name="generated_videos")
op.drop_index(op.f("ix_generated_videos_status"), table_name="generated_videos")
op.drop_column("generated_videos", "updated_at")
op.drop_column("generated_videos", "generation_params")
op.drop_column("generated_videos", "review_status")
op.drop_column("generated_videos", "status")
@@ -8,6 +8,7 @@ from app.schemas.generated_video import (
GeneratedVideoDownloadUrlResponse,
GeneratedVideoResponse,
ListGeneratedVideosResponse,
UpdateGeneratedVideoReviewRequest,
)
from fastapi import APIRouter, Depends, HTTPException
@@ -35,6 +36,9 @@ def _to_generated_video_response(item) -> GeneratedVideoResponse:
width=item.width,
height=item.height,
fps=item.fps,
status=item.status,
review_status=item.review_status,
generation_params=item.generation_params,
)
@@ -70,6 +74,23 @@ def get_generated_video(
return _to_generated_video_response(item)
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
def update_generated_video_review_status(
video_id: str,
request: UpdateGeneratedVideoReviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> GeneratedVideoResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
video.review_status = request.review_status
updated = generated_video_repository.update(video)
return _to_generated_video_response(updated)
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
def get_generated_video_download_url(
video_id: str,
+12 -1
View File
@@ -1,4 +1,12 @@
from pydantic import BaseModel
from typing import Literal
from pydantic import BaseModel, Field
GeneratedVideoReviewStatus = Literal["pending_review", "approved", "rejected"]
class UpdateGeneratedVideoReviewRequest(BaseModel):
review_status: GeneratedVideoReviewStatus
class GeneratedVideoResponse(BaseModel):
@@ -14,6 +22,9 @@ class GeneratedVideoResponse(BaseModel):
width: int
height: int
fps: float
status: str = "completed"
review_status: str = "pending_review"
generation_params: dict = Field(default_factory=dict)
class GeneratedVideoDownloadUrlResponse(BaseModel):
+9
View File
@@ -154,6 +154,15 @@ test.describe('Core generation and download flow', () => {
await page.goto(`/projects/${projectData.id}/results`);
await expect(page.getByRole('heading').getByText(generatedVideo.name, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('待复核')).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { name: '标记可发布' }).click();
await expect(page.getByText('成片复核状态已更新')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('可发布')).toBeVisible({ timeout: 20_000 });
const reviewedVideo = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}`, { headers });
expect(reviewedVideo.status(), await reviewedVideo.text()).toBe(200);
const reviewedVideoData = (await reviewedVideo.json()) as { review_status: string; generation_params: Record<string, unknown> };
expect(reviewedVideoData.review_status).toBe('approved');
expect(reviewedVideoData.generation_params.title_id).toBe(titleData.id);
const downloadUrlResponse = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}/download-url`, { headers });
expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(200);
const downloadData = (await downloadUrlResponse.json()) as { download_url: string };
+8
View File
@@ -26,6 +26,9 @@ export interface GeneratedVideoItem {
width: number;
height: number;
fps: number;
status?: string;
review_status?: 'pending_review' | 'approved' | 'rejected';
generation_params?: Record<string, unknown>;
}
export const createGenerationTask = async (data: {
@@ -55,6 +58,11 @@ export const getGeneratedVideos = async (projectId: string): Promise<GeneratedVi
return response.data.items;
};
export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: 'pending_review' | 'approved' | 'rejected'): Promise<GeneratedVideoItem> => {
const response = await apiClient.patch(`/generated-videos/${videoId}/review`, { review_status: reviewStatus });
return response.data;
};
export const getGeneratedVideoDownloadUrl = async (videoId: string): Promise<string> => {
const response = await apiClient.get(`/generated-videos/${videoId}/download-url`);
return response.data.download_url;
+61 -28
View File
@@ -1,14 +1,21 @@
import React, { useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Alert, Button, Card, Empty, List, Space, Spin, Tag, Typography, message } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { getGeneratedVideoDownloadUrl, getGeneratedVideos } from '@/api/generation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getGeneratedVideoDownloadUrl, getGeneratedVideos, updateGeneratedVideoReviewStatus } from '@/api/generation';
const reviewLabels: Record<string, { label: string; color: string }> = {
pending_review: { label: '待复核', color: 'gold' },
approved: { label: '可发布', color: 'green' },
rejected: { label: '需返工', color: 'red' },
};
const ProjectResults: React.FC = () => {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const projectId = id || '';
const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || '';
const queryClient = useQueryClient();
useEffect(() => {
if (projectId && routedWorkspaceId) {
@@ -23,6 +30,16 @@ const ProjectResults: React.FC = () => {
refetchInterval: 5000,
});
const reviewMutation = useMutation({
mutationFn: ({ videoId, reviewStatus }: { videoId: string; reviewStatus: 'approved' | 'rejected' }) =>
updateGeneratedVideoReviewStatus(videoId, reviewStatus),
onSuccess: () => {
message.success('成片复核状态已更新');
queryClient.invalidateQueries({ queryKey: ['generated-videos', projectId] });
},
onError: () => message.error('成片复核状态更新失败'),
});
const handleDownload = async (videoId: string) => {
try {
const url = await getGeneratedVideoDownloadUrl(videoId);
@@ -34,7 +51,7 @@ const ProjectResults: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Card title="项目成片结果">
<Card title="项目成片中心">
{videosQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="生成结果加载失败" />
)}
@@ -44,31 +61,47 @@ const ProjectResults: React.FC = () => {
) : videosQuery.data?.length ? (
<List
dataSource={videosQuery.data}
renderItem={(item) => (
<List.Item
actions={[
<Button key="download" type="link" onClick={() => handleDownload(item.id)}>
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap>
<Typography.Text>{item.name}</Typography.Text>
<Tag>{item.width}x{item.height}</Tag>
<Tag>{item.duration}s</Tag>
<Tag>{Math.round(item.file_size / 1024)} KB</Tag>
</Space>
}
description={
<Typography.Text copyable ellipsis style={{ maxWidth: 720 }}>
{item.name}
</Typography.Text>
}
/>
</List.Item>
)}
renderItem={(item) => {
const review = reviewLabels[item.review_status || 'pending_review'] || reviewLabels.pending_review;
return (
<List.Item
actions={[
<Button key="download" type="link" onClick={() => handleDownload(item.id)}>
</Button>,
<Button key="approve" type="link" onClick={() => reviewMutation.mutate({ videoId: item.id, reviewStatus: 'approved' })}>
</Button>,
<Button key="reject" danger type="link" onClick={() => reviewMutation.mutate({ videoId: item.id, reviewStatus: 'rejected' })}>
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap>
<Typography.Text>{item.name}</Typography.Text>
<Tag color="blue">{item.status || 'completed'}</Tag>
<Tag color={review.color}>{review.label}</Tag>
<Tag>{item.width}x{item.height}</Tag>
<Tag>{item.duration}s</Tag>
<Tag>{Math.round(item.file_size / 1024)} KB</Tag>
</Space>
}
description={
<Space direction="vertical" size={4}>
<Typography.Text copyable ellipsis style={{ maxWidth: 720 }}>
{item.name}
</Typography.Text>
<Typography.Text type="secondary">
{String(item.generation_params?.asset_library_id || '未记录')} {String(item.generation_params?.title_id || '自动/未选择')}
</Typography.Text>
</Space>
}
/>
</List.Item>
);
}}
/>
) : (
<Empty description="暂无生成结果,先去发起生成任务" />
@@ -241,6 +241,15 @@ def generate_video(task_id: str) -> dict:
height=OUTPUT_HEIGHT,
fps=OUTPUT_FPS,
thumbnail_url=None,
generation_params={
"asset_library_id": task.asset_library_id,
"voice_library_id": task.voice_library_id,
"title_id": task.strategy_id,
"output_width": OUTPUT_WIDTH,
"output_height": OUTPUT_HEIGHT,
"output_fps": OUTPUT_FPS,
"output_duration_seconds": OUTPUT_DURATION_SECONDS,
},
)
video_repo.create(video)
@@ -1,3 +1,5 @@
import json
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
@@ -22,6 +24,9 @@ class SQLAlchemyGeneratedVideoRepository:
width=video.width,
height=video.height,
fps=video.fps,
status=video.status,
review_status=video.review_status,
generation_params=json.dumps(video.generation_params, ensure_ascii=False),
generated_at=video.generated_at,
created_at=video.created_at,
)
@@ -46,10 +51,24 @@ class SQLAlchemyGeneratedVideoRepository:
width=int(model.width or 0),
height=int(model.height or 0),
fps=model.fps,
status=getattr(model, "status", "completed"),
review_status=getattr(model, "review_status", "pending_review"),
generation_params=json.loads(getattr(model, "generation_params", "{}") or "{}"),
generated_at=model.generated_at,
created_at=model.created_at,
)
def update(self, video: GeneratedVideo) -> GeneratedVideo:
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video.id).first()
if model is None:
return video
model.status = video.status
model.review_status = video.review_status
model.generation_params = json.dumps(video.generation_params, ensure_ascii=False)
self.session.add(model)
self.session.commit()
return video
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
return [self.get(model.id) for model in models if self.get(model.id) is not None]
@@ -195,8 +195,12 @@ class GeneratedVideoModel(Base):
width = Column(Float, nullable=False)
height = Column(Float, nullable=False)
fps = Column(Float, nullable=False)
status = Column(String(20), nullable=False, default="completed", index=True)
review_status = Column(String(20), nullable=False, default="pending_review", index=True)
generation_params = Column(Text, nullable=False, default="{}")
generated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=True)
class TaskModel(Base):
+6
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
@@ -19,6 +20,9 @@ class GeneratedVideo:
height: int
fps: float
thumbnail_url: str | None = None
status: str = "completed"
review_status: str = "pending_review"
generation_params: dict[str, Any] = field(default_factory=dict)
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -37,6 +41,7 @@ class GeneratedVideo:
height: int,
fps: float,
thumbnail_url: str | None = None,
generation_params: dict[str, Any] | None = None,
) -> "GeneratedVideo":
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
@@ -61,4 +66,5 @@ class GeneratedVideo:
height=height,
fps=fps,
thumbnail_url=thumbnail_url,
generation_params=generation_params or {},
)
@@ -0,0 +1,47 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.generated_video_repository import SQLAlchemyGeneratedVideoRepository
from packages.adapters.sqlalchemy_impl.models import Base
from packages.domain import GeneratedVideo
def _repository():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return SQLAlchemyGeneratedVideoRepository(session)
def test_generated_video_repository_preserves_management_fields():
repository = _repository()
video = GeneratedVideo.create(
workspace_id="workspace-1",
project_id="project-1",
generation_task_id="task-1",
name="output.mp4",
file_url="generated/output.mp4",
file_size=2048,
duration=5.0,
width=1280,
height=720,
fps=25.0,
generation_params={"asset_library_id": "library-1", "title_id": "title-1"},
)
repository.create(video)
saved = repository.get(video.id)
assert saved.status == "completed"
assert saved.review_status == "pending_review"
assert saved.generation_params["title_id"] == "title-1"
saved.review_status = "approved"
repository.update(saved)
assert repository.get(video.id).review_status == "approved"