feat(results): add generated video management
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user