From cfc65f09e3a73613067505cc242970637a434820 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 23 Aug 2026 22:50:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E5=89=AA?= =?UTF-8?q?=E8=BE=91=E6=A8=A1=E6=9D=BF=E7=BC=96=E8=BE=91=E5=99=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=A7=86=E9=A2=91=E6=AD=BB=E4=BB=A3=E7=A0=81=20+=20?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E5=89=AA=E8=BE=91=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E4=B8=BA=E5=89=AA=E8=BE=91=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 GenerationProgressModal/GenerationHistoryModal 组件及 generation-progress-modal/ 目录 - 删除 editPlans.ts 中无人调用的 generateEditPlan/getGenerationStatus/getEditPlanGenerations - 删除 types.ts 中对应的 GenerateResponse/EditPlanGeneration/GenerationStatusResponse 类型 - 清理相关测试文件和 mock 引用 - 用户可见文案 '剪辑编辑器' 统一替换为 '剪辑模板' - 不改变量名、组件名、路由路径和 API 路径 --- apps/web/src/api/template-editor/editPlans.ts | 29 +---- apps/web/src/api/template-editor/index.ts | 6 - apps/web/src/api/template-editor/types.ts | 36 ------ apps/web/src/config/navigation.ts | 4 +- .../components/GenerationHistoryModal.tsx | 116 ------------------ .../components/GenerationProgressModal.tsx | 6 - .../ProgressPhase.tsx | 65 ---------- .../generation-progress-modal/ResultPhase.tsx | 82 ------------- .../generation-progress-modal/SetupPhase.tsx | 53 -------- .../generation-progress-modal/constants.ts | 31 ----- .../generation-progress-modal/index.tsx | 70 ----------- .../generation-progress-modal/types.ts | 23 ---- .../templates/hooks/useTemplateLibrary.ts | 2 +- apps/web/src/test/api/edit-plans.test.ts | 51 -------- .../src/test/pages/EditingPlanner.test.tsx | 9 -- apps/web/src/test/pages/GeneratePage.test.tsx | 9 -- .../GenerationHistoryModal.test.tsx | 39 ------ .../GenerationProgressModal.test.tsx | 102 --------------- .../test/pages/editing-planner/smoke.test.tsx | 2 - 19 files changed, 5 insertions(+), 730 deletions(-) delete mode 100644 apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx delete mode 100644 apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx delete mode 100644 apps/web/src/pages/editing-planner/components/generation-progress-modal/ProgressPhase.tsx delete mode 100644 apps/web/src/pages/editing-planner/components/generation-progress-modal/ResultPhase.tsx delete mode 100644 apps/web/src/pages/editing-planner/components/generation-progress-modal/SetupPhase.tsx delete mode 100644 apps/web/src/pages/editing-planner/components/generation-progress-modal/constants.ts delete mode 100644 apps/web/src/pages/editing-planner/components/generation-progress-modal/index.tsx delete mode 100755 apps/web/src/pages/editing-planner/components/generation-progress-modal/types.ts delete mode 100644 apps/web/src/test/pages/editing-planner/components/GenerationHistoryModal.test.tsx delete mode 100644 apps/web/src/test/pages/editing-planner/components/GenerationProgressModal.test.tsx diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts index 19b7c9fab..2ec4acdea 100644 --- a/apps/web/src/api/template-editor/editPlans.ts +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -1,15 +1,8 @@ /** - * 模板草稿 CRUD + 生成相关 API + * 模板草稿 CRUD API */ import apiClient from "../client" -import type { - EditPlan, - UpdateEditPlanRequest, - GenerateResponse, - GenerationStatusResponse, - EditPlanGeneration, - GeneratedVideo, -} from "./types" +import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types" /** 获取单个模板草稿 */ export async function getEditPlan(templateId: string): Promise { @@ -27,24 +20,6 @@ export async function updateEditPlan( return response.data } -/** 触发生成 */ -export async function generateEditPlan(templateId: string): Promise { - const response = await apiClient.post(`/templates/${templateId}/editor/generate`) - return response.data -} - -/** 获取生成状态(轮询用) */ -export async function getGenerationStatus(templateId: string): Promise { - const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`) - return response.data -} - -/** 获取模板草稿关联的生成记录 */ -export async function getEditPlanGenerations(templateId: string): Promise { - const response = await apiClient.get(`/templates/${templateId}/editor/generations`) - return response.data.items || [] -} - /** 获取生成任务的视频结果列表 */ export async function getGenerationTaskResults(taskId: string): Promise { const response = await apiClient.get(`/generation/tasks/${taskId}/results`) diff --git a/apps/web/src/api/template-editor/index.ts b/apps/web/src/api/template-editor/index.ts index 094b41107..c39a3c426 100644 --- a/apps/web/src/api/template-editor/index.ts +++ b/apps/web/src/api/template-editor/index.ts @@ -16,10 +16,7 @@ export type { EditPlanConfig, EditPlan, UpdateEditPlanRequest, - GenerateResponse, - EditPlanGeneration, ClipStatusItem, - GenerationStatusResponse, GeneratedVideo, AIRecommendRequest, AIRecommendClipItem, @@ -52,9 +49,6 @@ export { getEditPlan, updateEditPlan, updateEditPlanClips, - generateEditPlan, - getGenerationStatus, - getEditPlanGenerations, getGenerationTaskResults, } from "./editPlans" export type { EditPlanClipInput } from "./editPlans" diff --git a/apps/web/src/api/template-editor/types.ts b/apps/web/src/api/template-editor/types.ts index 46c367624..734340628 100644 --- a/apps/web/src/api/template-editor/types.ts +++ b/apps/web/src/api/template-editor/types.ts @@ -187,31 +187,6 @@ export interface EditPlanListResponse { /* ── 生成相关 ── */ -/** 生成响应 */ -export interface GenerateResponse { - plan_id: string - plan_status: EditPlanStatus - generation_task_id: string - clip_count: number -} - -/** 模板草稿关联的生成记录 */ -export interface EditPlanGeneration { - id: string - source_edit_plan_id: string - template_id: string - asset_ids: string[] - status: EditPlanStatus - progress: number - result_count: number - error_message: string - error_info: Record - logs: Array> - retry_count: number - created_at?: string - updated_at?: string -} - /** 片段生成状态 */ export interface ClipStatusItem { clip_id: string @@ -224,17 +199,6 @@ export interface ClipStatusItem { error_message?: string } -/** 生成状态轮询响应 */ -export interface GenerationStatusResponse { - plan_id: string - plan_status: EditPlanStatus - generation_task_id?: string - error_message?: string - clips: ClipStatusItem[] - error?: string - message?: string -} - /** 生成视频详情 */ export interface GeneratedVideo { id: string diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index c47599806..dcbf208b2 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -71,7 +71,7 @@ export const NAV_ITEMS: NavItem[] = [ }, { key: "editing-planner", - label: "剪辑编辑器", + label: "剪辑模板", path: "/app/editing-planner", icon: React.createElement(EditOutlined), }, @@ -133,7 +133,7 @@ export const NAV_GROUPS: NavGroup[] = [ }, { key: "editing-planner", - label: "剪辑编辑器", + label: "剪辑模板", path: "/app/editing-planner", icon: React.createElement(EditOutlined), }, diff --git a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx b/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx deleted file mode 100644 index c4dbd4db3..000000000 --- a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * 生成历史弹窗 — 展示当前模板草稿的生成任务记录 - * 从 EditingPlanner 拆分,避免主文件过大 - */ -import React from "react" -import { CloseOutlined, InboxOutlined } from "@ant-design/icons" -import type { EditPlanGeneration } from "@/api/template-editor" -import { PLAN_STATUS_LABELS } from "@/api/template-editor" - -interface GenerationHistoryModalProps { - open: boolean - loading: boolean - history: EditPlanGeneration[] - onClose: () => void - onCancel?: (taskId: string) => void - cancelLoading?: boolean -} - -const GenerationHistoryModal: React.FC = ({ - open, - loading, - history, - onClose, - onCancel, - cancelLoading, -}) => { - if (!open) return null - - return ( -
-
e.stopPropagation()}> -
-

生成历史

- -
-
- {loading ? ( -
-
-
-
-
-
-
-
- ) : history.length === 0 ? ( -
- - 暂无生成记录 -
- ) : ( - - - - - - - - {onCancel && } - - - - {history.map((gen) => { - const statusClass = `ep-gh-status-tag--${gen.status}` - const canCancel = gen.status === "rendering" || gen.status === "editing" - return ( - - - - - - {onCancel && ( - - )} - - ) - })} - -
任务ID状态创建时间更新时间操作
- {gen.id ? `${gen.id.slice(0, 8)}...` : "—"} - - - {PLAN_STATUS_LABELS[gen.status] || gen.status} - - - {gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"} - - {gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"} - - {canCancel ? ( - - ) : ( - - )} -
- )} -
-
- -
-
-
- ) -} - -export default GenerationHistoryModal diff --git a/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx b/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx deleted file mode 100644 index 2f66ce31e..000000000 --- a/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/** - * 生成进度弹窗 — 入口文件(向后兼容) - * 实际实现已移至 ./generation-progress-modal/ 目录 - */ -export { default } from "./generation-progress-modal" -export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal" diff --git a/apps/web/src/pages/editing-planner/components/generation-progress-modal/ProgressPhase.tsx b/apps/web/src/pages/editing-planner/components/generation-progress-modal/ProgressPhase.tsx deleted file mode 100644 index 0e2597af5..000000000 --- a/apps/web/src/pages/editing-planner/components/generation-progress-modal/ProgressPhase.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react" -import { Modal } from "@/components/ui" -import type { TaskItem } from "@/api/tasks" -import { getStepLabel, getStatusColor } from "./constants" - -interface ProgressPhaseProps { - open: boolean - task: TaskItem | null - onCancel: () => void -} - -/** progress(进度轮询)阶段弹窗 */ -export const ProgressPhase: React.FC = ({ open, task, onCancel }) => { - const progress = task?.progress ?? 0 - const status = task?.status ?? "" - const currentStep = task?.current_step ?? "" - const userMessage = task?.user_message ?? "" - const stepColor = getStatusColor(status, currentStep) - - return ( - -
- {/* 进度环 */} -
- - - - - - {progress}% - -
- - {/* 当前步骤 */} -
- {userMessage || getStepLabel(currentStep) || "处理中…"} -
- - {/* 进度条 */} -
-
-
- - {/* 任务 ID */} - {task?.id &&
任务 ID: {task.id}
} -
- - ) -} diff --git a/apps/web/src/pages/editing-planner/components/generation-progress-modal/ResultPhase.tsx b/apps/web/src/pages/editing-planner/components/generation-progress-modal/ResultPhase.tsx deleted file mode 100644 index 14af7f83f..000000000 --- a/apps/web/src/pages/editing-planner/components/generation-progress-modal/ResultPhase.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react" -import { Modal, Button } from "@/components/ui" -import type { TaskItem } from "@/api/tasks" - -interface ResultPhaseProps { - open: boolean - phase: "completed" | "failed" - task: TaskItem | null - onCancel: () => void - onRetry?: () => void - onClose?: () => void -} - -/** completed / failed(结果)阶段弹窗 */ -export const ResultPhase: React.FC = ({ - open, - phase, - task, - onCancel, - onRetry, - onClose, -}) => { - const userMessage = task?.user_message ?? "" - const errorMessage = task?.error_message ?? "" - const retryable = task?.retryable ?? false - const handleClose = onClose || onCancel - - if (phase === "completed") { - return ( - -
-
🎉
-
视频生成完成!
- {userMessage &&
{userMessage}
} -
- -
-
-
- ) - } - - return ( - -
-
😥
-
视频生成失败
- {(errorMessage || userMessage) && ( -
- {errorMessage || userMessage} -
- )} -
- {retryable && onRetry && ( - - )} - -
-
-
- ) -} diff --git a/apps/web/src/pages/editing-planner/components/generation-progress-modal/SetupPhase.tsx b/apps/web/src/pages/editing-planner/components/generation-progress-modal/SetupPhase.tsx deleted file mode 100644 index 0c7e3f799..000000000 --- a/apps/web/src/pages/editing-planner/components/generation-progress-modal/SetupPhase.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react" -import { Modal } from "@/components/ui" - -interface SetupPhaseProps { - open: boolean - voiceoverDuration: number | null - estimatedDuration: number - submitting: boolean - onDurationChange: (v: number | null) => void - onGenerate: () => void - onCancel: () => void -} - -/** setup(配置)阶段弹窗 */ -export const SetupPhase: React.FC = ({ - open, - voiceoverDuration, - estimatedDuration, - submitting, - onDurationChange, - onGenerate, - onCancel, -}) => ( - -
- - { - const v = e.target.value ? Number(e.target.value) : null - onDurationChange(v) - }} - min={1} - max={600} - /> -
- 预估总时长:{estimatedDuration}s -
-
-
-) diff --git a/apps/web/src/pages/editing-planner/components/generation-progress-modal/constants.ts b/apps/web/src/pages/editing-planner/components/generation-progress-modal/constants.ts deleted file mode 100644 index eeecef559..000000000 --- a/apps/web/src/pages/editing-planner/components/generation-progress-modal/constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* ──────────── 步骤文案映射 ──────────── */ - -export const STEP_LABELS: Record = { - queued: "排队中…", - preparing: "准备素材…", - generating_video: "渲染视频中…", - adding_effects: "添加特效…", - composing: "合成中…", - encoding: "编码输出中…", - completed: "生成完成!", - failed: "生成失败", -} - -export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ") - -/* ──────────── 状态徽标颜色 ──────────── */ - -export const STATUS_COLOR: Record = { - queued: "#6b7280", - pending: "#6b7280", - preparing: "#f59e0b", - generating_video: "#4f46e5", - adding_effects: "#7c3aed", - composing: "#2563eb", - encoding: "#0891b2", - completed: "#10b981", - failed: "#ef4444", -} - -export const getStatusColor = (status: string, currentStep: string) => - STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5" diff --git a/apps/web/src/pages/editing-planner/components/generation-progress-modal/index.tsx b/apps/web/src/pages/editing-planner/components/generation-progress-modal/index.tsx deleted file mode 100644 index 14b4f94b5..000000000 --- a/apps/web/src/pages/editing-planner/components/generation-progress-modal/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * 生成进度弹窗 — 任务 2.17 - * 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果) - * V21 设计系统,CSS 类名前缀 ep-gen- - */ -import React, { useEffect, useRef } from "react" -import type { GenPhase, GenerationProgressModalProps } from "./types" -import { SetupPhase } from "./SetupPhase" -import { ProgressPhase } from "./ProgressPhase" -import { ResultPhase } from "./ResultPhase" - -/* 重新导出类型,保持向后兼容 */ -export type { GenPhase, GenerationProgressModalProps } - -const GenerationProgressModal: React.FC = ({ - open, - phase, - voiceoverDuration, - estimatedDuration, - onDurationChange, - onGenerate, - task, - submitting, - onCancel, - onRetry, - onClose, -}) => { - /* 关闭弹窗时重置(避免下次打开残留旧状态) */ - const prevOpen = useRef(false) - useEffect(() => { - if (prevOpen.current && !open) { - /* modal just closed — parent handles reset */ - } - prevOpen.current = open - }, [open]) - - /* setup 阶段 */ - if (phase === "setup") { - return ( - - ) - } - - /* progress 阶段 */ - if (phase === "progress") { - return - } - - /* completed / failed 阶段 */ - return ( - - ) -} - -export default GenerationProgressModal diff --git a/apps/web/src/pages/editing-planner/components/generation-progress-modal/types.ts b/apps/web/src/pages/editing-planner/components/generation-progress-modal/types.ts deleted file mode 100755 index 5e0b3990d..000000000 --- a/apps/web/src/pages/editing-planner/components/generation-progress-modal/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { TaskItem } from "@/api/tasks" - -export type GenPhase = "setup" | "progress" | "completed" | "failed" - -export interface GenerationProgressModalProps { - open: boolean - phase: GenPhase - - /* setup 阶段 */ - voiceoverDuration: number | null - estimatedDuration: number - onDurationChange: (v: number | null) => void - onGenerate: () => void - - /* progress / 结果阶段 */ - task: TaskItem | null - - /* 通用 */ - submitting: boolean - onCancel: () => void - onRetry?: () => void - onClose?: () => void -} diff --git a/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts index 826ad2357..028f8d1b8 100644 --- a/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts +++ b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts @@ -87,7 +87,7 @@ export const useTemplateLibrary = () => { [copyMutation], ) - /* 操作:使用模板 → 跳转剪辑编辑器 */ + /* 操作:使用模板 → 跳转剪辑模板 */ const handleUse = useCallback( (template: TemplateItem) => { navigate(`/app/editing-planner?templateId=${template.id}`) diff --git a/apps/web/src/test/api/edit-plans.test.ts b/apps/web/src/test/api/edit-plans.test.ts index 2186497dc..4a28c504b 100644 --- a/apps/web/src/test/api/edit-plans.test.ts +++ b/apps/web/src/test/api/edit-plans.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest" import { getEditPlan, updateEditPlan, - generateEditPlan, - getGenerationStatus, aiRecommendClips, - getEditPlanGenerations, getGenerationTaskResults, getEditPlanClips, getEditPlanClip, @@ -80,38 +77,6 @@ describe("editPlans API", () => { }) }) - describe("generateEditPlan", () => { - it("should resolve successfully", async () => { - await expect(generateEditPlan("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(generateEditPlan("test-planId")).rejects.toThrow() - }) - }) - - describe("getGenerationStatus", () => { - it("should resolve successfully", async () => { - await expect(getGenerationStatus("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(getGenerationStatus("test-planId")).rejects.toThrow() - }) - }) - describe("aiRecommendClips", () => { it("should resolve successfully", async () => { await expect(aiRecommendClips("test-planId")).resolves.not.toThrow() @@ -128,22 +93,6 @@ describe("editPlans API", () => { }) }) - describe("getEditPlanGenerations", () => { - it("should resolve successfully", async () => { - await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(getEditPlanGenerations("test-planId")).rejects.toThrow() - }) - }) - describe("getGenerationTaskResults", () => { it("should resolve successfully", async () => { await expect(getGenerationTaskResults("test-taskId")).resolves.not.toThrow() diff --git a/apps/web/src/test/pages/EditingPlanner.test.tsx b/apps/web/src/test/pages/EditingPlanner.test.tsx index 5a14b4acc..0225eb76f 100644 --- a/apps/web/src/test/pages/EditingPlanner.test.tsx +++ b/apps/web/src/test/pages/EditingPlanner.test.tsx @@ -148,11 +148,8 @@ vi.mock("@/api/editing-planner", () => ({ vi.mock("@/api/template-editor", () => ({ getMediaAssets: vi.fn().mockResolvedValue({ items: [] }), - getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }), getEditPlan: vi.fn().mockResolvedValue({}), updateEditPlan: vi.fn().mockResolvedValue({}), - generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }), - getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }), getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }), getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }), createEditPlanClip: vi.fn().mockResolvedValue({}), @@ -223,12 +220,6 @@ vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({ vi.mock("@/pages/editing-planner/components/SaveModal", () => ({ default: () => React.createElement("div", { "data-testid": "SaveModal" }), })) -vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({ - default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }), -})) -vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({ - default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }), -})) // === useUndoRedo hook mock === vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({ diff --git a/apps/web/src/test/pages/GeneratePage.test.tsx b/apps/web/src/test/pages/GeneratePage.test.tsx index 48c8e315b..d4ad0648a 100644 --- a/apps/web/src/test/pages/GeneratePage.test.tsx +++ b/apps/web/src/test/pages/GeneratePage.test.tsx @@ -214,12 +214,6 @@ vi.mock("@/api/titles", () => ({ })) vi.mock("@/api/template-editor", () => ({ - generateEditPlan: vi.fn().mockResolvedValue({ - plan_id: "test-plan", - generation_task_id: "test-task", - plan_status: "processing", - clip_count: 5, - }), updateEditPlan: vi.fn().mockResolvedValue({ plan_id: "test-plan", template_id: "test-template" }), getEditPlan: vi.fn().mockResolvedValue({ plan_id: "test-plan", @@ -228,9 +222,6 @@ vi.mock("@/api/template-editor", () => ({ config: {}, status: "draft", }), - getGenerationStatus: vi - .fn() - .mockResolvedValue({ plan_status: "completed", generation_task_id: "test-task", clips: [] }), getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }), })) diff --git a/apps/web/src/test/pages/editing-planner/components/GenerationHistoryModal.test.tsx b/apps/web/src/test/pages/editing-planner/components/GenerationHistoryModal.test.tsx deleted file mode 100644 index 36e931951..000000000 --- a/apps/web/src/test/pages/editing-planner/components/GenerationHistoryModal.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import { render } from "@testing-library/react" -import GenerationHistoryModal from "@/pages/editing-planner/components/GenerationHistoryModal" - -const defaultProps = { - open: true, - loading: false, - history: [], - onClose: vi.fn(), - onCancel: vi.fn(), - cancelLoading: false, -} - -describe("GenerationHistoryModal", () => { - it("should render without crashing", () => { - const { container } = render() - expect(container).toBeTruthy() - }) - - it("should not render when open is false", () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it("should render with history items", () => { - const history = [ - { - id: "1", - status: "completed", - created_at: "2024-01-01T00:00:00Z", - duration: 60, - }, - ] - const { container } = render( - , - ) - expect(container).toBeTruthy() - }) -}) diff --git a/apps/web/src/test/pages/editing-planner/components/GenerationProgressModal.test.tsx b/apps/web/src/test/pages/editing-planner/components/GenerationProgressModal.test.tsx deleted file mode 100644 index 65b088729..000000000 --- a/apps/web/src/test/pages/editing-planner/components/GenerationProgressModal.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import { render } from "@testing-library/react" -import GenerationProgressModal from "@/pages/editing-planner/components/GenerationProgressModal" - -const baseProps = { - open: true, - voiceoverDuration: null, - estimatedDuration: 60, - onDurationChange: vi.fn(), - onGenerate: vi.fn(), - task: null, - submitting: false, - onCancel: vi.fn(), - onRetry: vi.fn(), - onClose: vi.fn(), -} - -describe("GenerationProgressModal", () => { - it("should render setup phase", () => { - const { container } = render() - expect(container).toBeTruthy() - }) - - it("should render progress phase without task", () => { - const { container } = render() - expect(container).toBeTruthy() - }) - - it("should render progress phase with task data", () => { - const { container } = render( - , - ) - expect(container).toBeTruthy() - }) - - it("should render completed phase", () => { - const { container } = render( - , - ) - expect(container).toBeTruthy() - }) - - it("should render failed phase with retry", () => { - const { container } = render( - , - ) - expect(container).toBeTruthy() - }) - - it("should render failed phase without retry", () => { - const { container } = render( - , - ) - expect(container).toBeTruthy() - }) - - it("should not render when closed", () => { - const { container } = render( - , - ) - expect(container).toBeTruthy() - }) -}) diff --git a/apps/web/src/test/pages/editing-planner/smoke.test.tsx b/apps/web/src/test/pages/editing-planner/smoke.test.tsx index 995d96b59..35a1dc967 100755 --- a/apps/web/src/test/pages/editing-planner/smoke.test.tsx +++ b/apps/web/src/test/pages/editing-planner/smoke.test.tsx @@ -31,8 +31,6 @@ import "@/pages/editing-planner/components/ClipPropertiesPanel" import "@/pages/editing-planner/components/EditorClipList" import "@/pages/editing-planner/components/EditingDrawers" import "@/pages/editing-planner/components/FilterPanel" -import "@/pages/editing-planner/components/GenerationHistoryModal" -import "@/pages/editing-planner/components/GenerationProgressModal" import "@/pages/editing-planner/components/GreenScreenPanel" import "@/pages/editing-planner/components/IntroOutroPanel" import "@/pages/editing-planner/components/MediaPanel" -- 2.54.0 From 02796937f3d5a75833f3632bb3b89602135da1a0 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 23 Aug 2026 22:52:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(api):=20=E6=B8=85=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=BC=96=E8=BE=91=E5=99=A8=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除模板编辑器(剪辑模板)的生成视频接口和连带死代码,生成功能统一由 智能剪辑 /generation/* 路由承载。 删除文件: - apps/api/app/api/routes/templates_editor/generation.py (POST /generate, GET /generation-status, GET /generations) - apps/api/app/api/routes/templates_editor/_fallback.py (4个自动兜底函数,仅被 generation.py 调用) - tests/unit/test_generate_title_and_cover.py - tests/unit/test_unified_render_reuse.py - tests/unit/test_fallback_user_assets.py 修改文件: - __init__.py: 移除 generation_router 导入和注册 - dependencies.py: 移除 _check_queue_limits 及 task_enqueue 独占导入 - schemas.py: 移除 ClipStatusItem/EditPlanGenerate*/EditPlanGenerationsResponse 及 GenerationTaskResponse 独占导入 不动的文件: - generation_tasks.py / generation_preview.py / generation_cover.py (智能剪辑 /generation/* 路由,正在使用) --- .../api/routes/templates_editor/__init__.py | 6 +- .../api/routes/templates_editor/_fallback.py | 227 ----------- .../routes/templates_editor/dependencies.py | 28 -- .../api/routes/templates_editor/generation.py | 385 ------------------ .../api/routes/templates_editor/schemas.py | 56 +-- tests/unit/test_fallback_user_assets.py | 331 --------------- tests/unit/test_generate_title_and_cover.py | 369 ----------------- tests/unit/test_unified_render_reuse.py | 287 ------------- 8 files changed, 2 insertions(+), 1687 deletions(-) delete mode 100755 apps/api/app/api/routes/templates_editor/_fallback.py delete mode 100755 apps/api/app/api/routes/templates_editor/generation.py delete mode 100644 tests/unit/test_fallback_user_assets.py delete mode 100644 tests/unit/test_generate_title_and_cover.py delete mode 100644 tests/unit/test_unified_render_reuse.py diff --git a/apps/api/app/api/routes/templates_editor/__init__.py b/apps/api/app/api/routes/templates_editor/__init__.py index 0ad746410..c8f1c2944 100755 --- a/apps/api/app/api/routes/templates_editor/__init__.py +++ b/apps/api/app/api/routes/templates_editor/__init__.py @@ -1,10 +1,9 @@ """模板编辑器 API 路由包. -将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块: +模块拆分: - schemas.py: 所有 Pydantic model - dependencies.py: 依赖注入 - _utils.py: 工具函数 -- _fallback.py: 自动兜底逻辑 - draft.py: 草稿管理(详情/更新/发布/版本/回滚) - clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建) - adjustments.py: 片段调整(速度/音量/裁剪/批量调速) @@ -13,7 +12,6 @@ - export.py: 导出配置 - subtitles.py: 字幕管理 - ai_features.py: AI 推荐 -- generation.py: 生成(触发/进度/记录) - timeline.py: 时间线 挂载路径: /api/v1/templates/{template_id}/editor/ @@ -34,7 +32,6 @@ from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401 from .draft import router as draft_router from .effects import router as effects_router from .export import router as export_router -from .generation import router as generation_router from .subtitles import router as subtitles_router from .timeline import router as timeline_router @@ -51,7 +48,6 @@ _sub_routers = [ export_router, subtitles_router, ai_features_router, - generation_router, timeline_router, ] diff --git a/apps/api/app/api/routes/templates_editor/_fallback.py b/apps/api/app/api/routes/templates_editor/_fallback.py deleted file mode 100755 index 031cf7d12..000000000 --- a/apps/api/app/api/routes/templates_editor/_fallback.py +++ /dev/null @@ -1,227 +0,0 @@ -"""模板编辑器自动兜底逻辑. - -generate_editor_draft 触发生成前的自动修复流程: -1. draft → editing 状态迁移 -2. 无片段时从模板复制片段配置 -3. 为无素材片段分配指定素材 -4. 项目有素材库时自动选素材 -""" - -from __future__ import annotations - -import logging -import random -from typing import Any - -from app.services.edit_plan_service import EditPlanService -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.template_clip_config_repository import ( - SQLAlchemyTemplateClipConfigRepository, -) -from packages.adapters.sqlalchemy_impl.template_repository import ( - SQLAlchemyTemplateRepository, -) -from packages.domain.edit_plan import EditPlanStatus - -logger = logging.getLogger(__name__) - - -def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None: - """自动兜底 1: draft → editing""" - if plan_check.status == EditPlanStatus.DRAFT: - logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id) - svc.transition_status(plan_id, EditPlanStatus.EDITING) - - -def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None: - """自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置""" - existing_clips = svc.count_clips(plan_id) - if existing_clips == 0 and plan_check.template_id: - logger.info( - "模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置", - plan_id, - plan_check.template_id, - ) - clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) - configs = clip_config_repo.list_by_template(plan_check.template_id) - if configs: - for cfg in configs: - svc.create_clip( - plan_id=plan_id, - clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type, - order=cfg.order, - template_clip_config_id=cfg.id, - duration=cfg.default_duration, - transition_effect=( - cfg.transition_effect.value - if hasattr(cfg.transition_effect, "value") - else cfg.transition_effect - ), - ) - logger.info( - "模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段", - plan_id, - len(configs), - ) - else: - tpl_repo = SQLAlchemyTemplateRepository(db) - segments = tpl_repo.list_segments(plan_check.template_id) - for seg in segments: - avg_duration = (seg.duration_min + seg.duration_max) / 2 - svc.create_clip( - plan_id=plan_id, - clip_type="main", - order=seg.segment_order, - duration=avg_duration, - config={ - "material_type": seg.material_type or "", - "template_segment_id": seg.id, - }, - ) - logger.info( - "模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段", - plan_id, - len(segments), - ) - - -def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list: - """自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。""" - all_clips = svc.list_clips(plan_id) - clips_without_asset = [c for c in all_clips if not c.asset_id] - config_asset_ids = (plan_check.config or {}).get("asset_ids", []) - - logger.info( - "模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r", - plan_id, - len(all_clips), - len(clips_without_asset), - config_asset_ids[:5] if config_asset_ids else [], - ) - - if clips_without_asset and config_asset_ids: - logger.info( - "模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材", - plan_id, - len(clips_without_asset), - len(config_asset_ids), - ) - assigned = 0 - for i, clip in enumerate(clips_without_asset): - asset_idx = i % len(config_asset_ids) - try: - svc.assign_asset(clip.id, config_asset_ids[asset_idx]) - assigned += 1 - except Exception as exc: - logger.error( - "模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s", - plan_id, - clip.id, - config_asset_ids[asset_idx], - exc, - ) - logger.info( - "模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d", - plan_id, - assigned, - len(clips_without_asset), - ) - # 重新检查剩余无素材片段 - all_clips_after = svc.list_clips(plan_id) - clips_without_asset = [c for c in all_clips_after if not c.asset_id] - if clips_without_asset: - logger.warning( - "模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材", - plan_id, - len(clips_without_asset), - ) - elif not clips_without_asset: - logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id) - elif not config_asset_ids: - logger.info( - "模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配", - plan_id, - ) - - return clips_without_asset - - -def _auto_fallback_auto_material_mode( - svc: EditPlanService, - plan_id: str, - plan_check, - clips_without_asset: list, - asset_library_repo: Any, - asset_repo: Any, - user_id: str = "", -) -> None: - """自动兜底 4: 自动选素材分配给无素材片段 - - 查找策略(按优先级): - 1. plan 有 project_id → 从项目素材库查找 - 2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找 - """ - if not clips_without_asset: - return - - ready_videos: list = [] - source_desc = "" - - # 策略 1: 通过 project_id 查找项目素材库 - if plan_check.project_id: - libs = asset_library_repo.find_by_project(plan_check.project_id) - video_lib = None - for lib in libs: - lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind - if lib_kind == "video": - video_lib = lib - break - if video_lib: - assets = asset_repo.find_by_library(video_lib.id) - ready_videos = [ - a - for a in assets - if (a.status.value if hasattr(a.status, "value") else a.status) == "ready" - and a.mime_type - and a.mime_type.startswith("video") - ] - source_desc = f"素材库 {video_lib.name}" - - # 策略 2: 通过 user_id 查找用户上传的素材 - if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"): - logger.info( - "模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材", - plan_id, - user_id, - ) - ready_videos = asset_repo.find_ready_videos_by_user(user_id) - source_desc = f"用户上传 (user_id={user_id[:8]}...)" - - if not ready_videos: - logger.warning( - "模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)", - plan_id, - plan_check.project_id or "(empty)", - user_id[:8] + "..." if user_id else "(empty)", - ) - return - - logger.info( - "模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)", - plan_id, - len(clips_without_asset), - source_desc, - len(ready_videos), - ) - random.shuffle(ready_videos) - for i, clip in enumerate(clips_without_asset): - asset = ready_videos[i % len(ready_videos)] - svc.assign_asset(clip.id, asset.id) - logger.info( - "模板编辑器自动兜底4: plan=%s 从 %s 分配了 %d 个素材给 %d 个片段", - plan_id, - source_desc, - len(ready_videos), - len(clips_without_asset), - ) diff --git a/apps/api/app/api/routes/templates_editor/dependencies.py b/apps/api/app/api/routes/templates_editor/dependencies.py index 1aa5946bc..c4388f12e 100755 --- a/apps/api/app/api/routes/templates_editor/dependencies.py +++ b/apps/api/app/api/routes/templates_editor/dependencies.py @@ -3,7 +3,6 @@ 核心依赖: - get_editor_services: 获取模板+计划服务 - get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id -- _check_queue_limits: 生成队列限流检查 """ from __future__ import annotations @@ -11,7 +10,6 @@ from __future__ import annotations import logging from app.auth import AuthenticatedUser, get_current_user -from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT from app.dependencies import get_db_session from app.services.edit_plan_service import EditPlanService from app.services.edit_template_service import EditTemplateService @@ -113,29 +111,3 @@ def get_draft_plan_id( user_id, ) return plan.id - - -def _check_queue_limits(gen_task_repo, user_id: str) -> None: - """队列限流预检查""" - try: - has_count = ( - hasattr(gen_task_repo, "count_pending_by_user") - and hasattr(gen_task_repo, "count_pending_total") - ) - if has_count: - user_pending = gen_task_repo.count_pending_by_user(user_id) - global_pending = gen_task_repo.count_pending_total() - if user_pending >= USER_PENDING_LIMIT: - raise HTTPException( - status_code=429, - detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交", - ) - if global_pending >= GLOBAL_PENDING_LIMIT: - raise HTTPException( - status_code=503, - detail="系统繁忙,请稍后再试", - ) - except HTTPException: - raise - except Exception as e: - logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e) diff --git a/apps/api/app/api/routes/templates_editor/generation.py b/apps/api/app/api/routes/templates_editor/generation.py deleted file mode 100755 index aa9912f8a..000000000 --- a/apps/api/app/api/routes/templates_editor/generation.py +++ /dev/null @@ -1,385 +0,0 @@ -"""草稿生成路由. - -端点: -- POST /generate 触发生成 -- GET /generation-status 生成进度 -- GET /generations 生成记录列表 -""" - -from __future__ import annotations - -import json -import logging -from typing import Any, Optional - -from app.auth import AuthenticatedUser, get_current_user -from app.core.celery_app import celery_app -from app.core.storage import OSSStorageService, get_storage_service -from app.dependencies import ( - get_asset_library_repository, - get_asset_repository, - get_db_session, - get_generated_video_repository, -) -from app.schemas.generation_task import GenerationTaskResponse -from app.services.edit_plan_service import EditPlanService -from app.services.edit_template_service import EditTemplateService -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.generation_task_repository import ( - SQLAlchemyGenerationTaskRepository, -) -from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase -from packages.application.generation_tasks import ( - CreateGenerationTaskCommand, - CreateGenerationTaskUseCase, -) -from packages.domain.edit_plan import EditPlanStatus - -from ._fallback import ( - _auto_fallback_assign_assets, - _auto_fallback_auto_material_mode, - _auto_fallback_copy_template_clips, - _auto_fallback_draft_to_editing, -) -from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services -from .schemas import ( - ClipStatusItem, - EditPlanGenerateRequest, - EditPlanGenerateResponse, - EditPlanGenerationsResponse, - EditPlanGenerationStatusResponse, -) - -logger = logging.getLogger(__name__) -router = APIRouter(tags=["Template Editor"]) - - -# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线 -@router.post("/generate", response_model=EditPlanGenerateResponse) -def generate_editor_draft( - template_id: str, - request: Optional[EditPlanGenerateRequest] = None, - plan_id: str = Depends(get_draft_plan_id), - services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - asset_library_repo: Any = Depends(get_asset_library_repository), - asset_repo: Any = Depends(get_asset_repository), -) -> EditPlanGenerateResponse: - """触发模板草稿渲染生成""" - req = request or EditPlanGenerateRequest() - _, plan_svc = services - plan_check = plan_svc.get_plan_or_raise(plan_id) - - # 自动兜底流程 - _auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check) - _auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db) - clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check) - _auto_fallback_auto_material_mode( - plan_svc, - plan_id, - plan_check, - clips_without_asset, - asset_library_repo, - asset_repo, - user_id=str(current_user.user.id), - ) - - # 检查是否可复用已完成的预览产物(预览品质已与正式一致) - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check) - if reusable_task: - # 复用预览产物:标记为正式产出,跳过渲染 - # 如果前端传了 title_config,需要创建新任务(因为预览任务的 custom_title 可能不同) - title_config_reuse = req.title_config or {} - title_text_reuse = (title_config_reuse.get("text") or "").strip() - existing_custom_title = getattr(reusable_task, "custom_title", "") or "" - if title_text_reuse and existing_custom_title: - # 如果新标题和已有标题不同,不能复用,走新建任务流程 - new_title_json = json.dumps(title_config_reuse, ensure_ascii=False) - if new_title_json != existing_custom_title: - logger.info( - "[模板生成] 标题已变更,跳过复用: task_id=%s", - reusable_task.id, - ) - reusable_task = None - elif title_text_reuse and not existing_custom_title: - # 原来没标题,现在有标题,不能复用 - logger.info( - "[模板生成] 新增标题,跳过复用: task_id=%s", - reusable_task.id, - ) - reusable_task = None - elif not title_text_reuse and existing_custom_title: - # 原来有标题,现在移除了,不能复用 - logger.info( - "[模板生成] 移除标题,跳过复用: task_id=%s", - reusable_task.id, - ) - reusable_task = None - - if reusable_task: - # 复用预览产物:标记为正式产出,跳过渲染 - reusable_task.mark_confirmed() - gen_task_repo.update(reusable_task) - - # 将产物 URL 写入 plan config - rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db) - plan_svc.update_plan_config( - plan_id, - { - "generation_task_id": reusable_task.id, - "rendered_storage_key": rendered_url, # 统一用 rendered_storage_key - }, - ) - plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED) - - updated_plan = plan_svc.get_plan_or_raise(plan_id) - logger.info( - "模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s", - template_id, - plan_id, - reusable_task.id, - current_user.user.id, - ) - return EditPlanGenerateResponse( - plan_id=plan_id, - plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status, - generation_task_id=reusable_task.id, - clip_count=len((plan_check.config or {}).get("clips", [])), - ) - - # 检查是否可生成(含最后防线自动修复 + 诊断日志) - try: - can_gen, reason = plan_svc.can_generate(plan_id) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - if not can_gen: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason) - - try: - clip_count = plan_svc.mark_clips_ready(plan_id) - - user_id = current_user.user.id - _check_queue_limits(gen_task_repo, user_id) - - gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo) - plan = plan_svc.get_plan_or_raise(plan_id) - config_asset_ids = (plan.config or {}).get("asset_ids", []) - # 从 plan config 读取封面 URL(由 generate-cover 保存) - cover_url_from_config = (plan.config or {}).get("cover", {}).get("image_url", "") - - # 处理标题配置:序列化 title_config 为 JSON 存入 custom_title - title_config = req.title_config or {} - title_text = (title_config.get("text") or "").strip() - custom_title_value = "" - if title_text: - custom_title_value = json.dumps(title_config, ensure_ascii=False) - logger.info( - "[模板生成] 标题配置: text=%s, config_keys=%s", - title_text[:30], - list(title_config.keys()), - ) - - gen_task = gen_task_use_case.execute( - CreateGenerationTaskCommand( - project_id=plan.project_id or "", - template_id=plan.template_id, - created_by_user_id=current_user.user.id, - source_edit_plan_id=plan_id, - asset_ids=list(config_asset_ids) if config_asset_ids else [], - cover_url=cover_url_from_config, - custom_title=custom_title_value, - ), - ) - - plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id}) - plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING) - celery_app.send_task("worker.generate_video", args=[gen_task.id]) - - updated_plan = plan_svc.get_plan_or_raise(plan_id) - - logger.info( - "模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s", - template_id, - plan_id, - gen_task.id, - clip_count, - current_user.user.id, - ) - - return EditPlanGenerateResponse( - plan_id=plan_id, - plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status, - generation_task_id=gen_task.id, - clip_count=clip_count, - ) - except HTTPException: - raise - except Exception as _e: - logger.exception( - "模板编辑器触发生成失败: template_id=%s plan_id=%s", - template_id, - plan_id, - ) - try: - plan_svc.transition_status(plan_id, EditPlanStatus.FAILED) - except Exception: - pass - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="生成失败,请稍后重试", - ) from _e - - -def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None": - """查找该 plan 关联的已完成预览任务,判断是否可复用。 - - 复用条件: - 1. 存在 source_edit_plan_id == plan_id 的已完成预览任务 - 2. plan 在预览完成后未被修改(updated_at <= 预览完成时间) - - Returns: - 可复用的 GenerationTask,或 None - """ - try: - tasks = gen_task_repo.list_by_source_edit_plan(plan_id) - except Exception: - return None - - for task in tasks: - if not getattr(task, "is_preview", False): - continue - if not task.is_completed: - continue - # 检查 plan 是否在预览完成后被修改 - completed_at = getattr(task, "completed_at", None) - if completed_at and hasattr(plan, "updated_at"): - plan_updated = plan.updated_at - # 如果 plan.updated_at 为空,无法判断是否修改过,跳过 - if plan_updated is None: - continue - # 如果 plan 在预览完成后又被修改了,不能复用 - if plan_updated > completed_at: - continue - return task - return None - - -def _get_task_output_url(task, gen_task_repo, db) -> str: - """获取任务的输出视频 URL。""" - try: - video_repo = get_generated_video_repository(db) - use_case = ListGeneratedVideosByTaskUseCase(video_repo) - videos = use_case.execute(task.id) - if videos: - url = getattr(videos[0], "file_url", "") or "" - # 规范化:合并路径中的双斜杠(保留协议头 ://) - if url: - import re as _re - url = _re.sub(r"(? EditPlanGenerationStatusResponse: - """查询草稿生成进度""" - _, plan_svc = services - try: - gen_status = plan_svc.get_generation_status(plan_id) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - plan = gen_status["plan"] - clips = gen_status["clips"] - - clip_items = [ - ClipStatusItem( - clip_id=c.id, - clip_type=c.clip_type, - order=c.order, - status=c.status.value if hasattr(c.status, "value") else c.status, - asset_id=c.asset_id or "", - text_content=c.text_content or "", - duration=c.duration, - ) - for c in clips - ] - - raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "") - video_url = "" - if raw_video_url: - if raw_video_url.startswith("http"): - video_url = raw_video_url # 已经是完整 URL - else: - try: - video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL - except Exception as e: - logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e) - video_url = raw_video_url - - progress = gen_status.get("progress", 0.0) - error_message = gen_status.get("error_message", "") - gen_task_status = gen_status.get("generation_task_status") - plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status - if plan_status_val == "completed" and progress < 100: - progress = 100.0 - - return EditPlanGenerationStatusResponse( - plan_id=plan_id, - plan_status=plan_status_val, - generation_task_id=gen_status["generation_task_id"], - generation_task_status=gen_task_status, - progress=progress, - video_url=video_url, - error_message=error_message, - clips=clip_items, - ) - - -@router.get("/generations", response_model=EditPlanGenerationsResponse) -def list_editor_generations( - template_id: str, - plan_id: str = Depends(get_draft_plan_id), - services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), - db: Session = Depends(get_db_session), - _: AuthenticatedUser = Depends(get_current_user), -) -> EditPlanGenerationsResponse: - """查询草稿关联的生成记录列表""" - _, plan_svc = services - plan_svc.get_plan_or_raise(plan_id) - - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - tasks = gen_task_repo.list_by_source_edit_plan(plan_id) - items = [ - GenerationTaskResponse( - id=t.id, - project_id=t.project_id, - asset_library_id=t.asset_library_id, - strategy_id=t.strategy_id, - voice_library_id=t.voice_library_id, - template_id=t.template_id, - asset_ids=t.asset_ids, - title_ids=t.title_ids, - voice_ids=t.voice_ids, - source_edit_plan_id=t.source_edit_plan_id or "", - status=t.status.value if hasattr(t.status, "value") else t.status, - progress=t.progress, - result_count=t.result_count, - error_message=t.error_message, - ) - for t in tasks - ] - return EditPlanGenerationsResponse(items=items, total=len(items)) diff --git a/apps/api/app/api/routes/templates_editor/schemas.py b/apps/api/app/api/routes/templates_editor/schemas.py index d282a42ab..8135c9a5e 100755 --- a/apps/api/app/api/routes/templates_editor/schemas.py +++ b/apps/api/app/api/routes/templates_editor/schemas.py @@ -6,9 +6,8 @@ from __future__ import annotations import re as _re -from typing import Any, Dict, List, Optional +from typing import Any, List, Optional -from app.schemas.generation_task import GenerationTaskResponse from pydantic import BaseModel, Field, validator _EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$") @@ -16,59 +15,6 @@ _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best _EXPORT_VALID_FORMATS = {"mp4", "mov"} -# ── 生成状态相关 ──────────────────────────────────────────────────────────── - - -class ClipStatusItem(BaseModel): - """片段生成状态""" - - clip_id: str - clip_type: str - order: int - status: str - asset_id: str - text_content: str - duration: float - - -class EditPlanGenerationStatusResponse(BaseModel): - """剪辑计划生成进度响应体""" - - plan_id: str - plan_status: str - generation_task_id: Optional[str] = None - generation_task_status: Optional[str] = None - progress: float = 0.0 - video_url: str = "" - error_message: str = "" - clips: List[ClipStatusItem] - - -class EditPlanGenerateRequest(BaseModel): - """模板编辑器触发生成请求体""" - - title_config: Optional[Dict[str, Any]] = Field( - default_factory=dict, - description="标题配置(可选),渲染时烧录到视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow", - ) - - -class EditPlanGenerateResponse(BaseModel): - """剪辑计划触发生成响应体""" - - plan_id: str - plan_status: str - generation_task_id: str - clip_count: int - - -class EditPlanGenerationsResponse(BaseModel): - """剪辑计划关联的生成记录列表响应体""" - - items: List[GenerationTaskResponse] - total: int - - # ── AI 推荐 ──────────────────────────────────────────────────────────────── diff --git a/tests/unit/test_fallback_user_assets.py b/tests/unit/test_fallback_user_assets.py deleted file mode 100644 index 6624e07ac..000000000 --- a/tests/unit/test_fallback_user_assets.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Unit tests for PR #1338: 确认生成兜底增强 — user_id 查找素材. - -覆盖: -- SQLAlchemyAssetRepository.find_ready_videos_by_user -- _auto_fallback_auto_material_mode 策略2 (user_id 兜底) -""" - -from __future__ import annotations - -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages")) - -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository -from packages.adapters.sqlalchemy_impl.models import Base -from packages.domain import Asset, AssetStatus - - -def _make_repo(): - engine = create_engine("sqlite:///:memory:") - Base.metadata.create_all(engine) - session = sessionmaker(bind=engine)() - return SQLAlchemyAssetRepository(session) - - -class TestFindReadyVideosByUser: - """SQLAlchemyAssetRepository.find_ready_videos_by_user 测试.""" - - def test_returns_ready_videos_for_user(self): - repo = _make_repo() - user_id = "user-abc-123" - v1 = Asset.create( - project_id="", - library_id="lib-1", - name="video1.mp4", - storage_key="v/v1.mp4", - mime_type="video/mp4", - status=AssetStatus.READY, - uploaded_by_user_id=user_id, - ) - v2 = Asset.create( - project_id="", - library_id="lib-1", - name="video2.mp4", - storage_key="v/v2.mp4", - mime_type="video/mp4", - status=AssetStatus.READY, - uploaded_by_user_id=user_id, - ) - repo.create(v1) - repo.create(v2) - result = repo.find_ready_videos_by_user(user_id) - assert len(result) == 2 - assert {a.id for a in result} == {v1.id, v2.id} - - def test_excludes_non_video_assets(self): - repo = _make_repo() - user_id = "user-abc-123" - video = Asset.create( - project_id="", - library_id="lib-1", - name="clip.mp4", - storage_key="v/clip.mp4", - mime_type="video/mp4", - status=AssetStatus.READY, - uploaded_by_user_id=user_id, - ) - image = Asset.create( - project_id="", - library_id="lib-1", - name="photo.jpg", - storage_key="v/photo.jpg", - mime_type="image/jpeg", - status=AssetStatus.READY, - uploaded_by_user_id=user_id, - ) - repo.create(video) - repo.create(image) - result = repo.find_ready_videos_by_user(user_id) - assert len(result) == 1 - assert result[0].id == video.id - - def test_excludes_non_ready_assets(self): - repo = _make_repo() - user_id = "user-abc-123" - ready = Asset.create( - project_id="", - library_id="lib-1", - name="ready.mp4", - storage_key="v/ready.mp4", - mime_type="video/mp4", - status=AssetStatus.READY, - uploaded_by_user_id=user_id, - ) - uploading = Asset.create( - project_id="", - library_id="lib-1", - name="uploading.mp4", - storage_key="v/uploading.mp4", - mime_type="video/mp4", - status=AssetStatus.UPLOADING, - uploaded_by_user_id=user_id, - ) - repo.create(ready) - repo.create(uploading) - result = repo.find_ready_videos_by_user(user_id) - assert len(result) == 1 - assert result[0].id == ready.id - - def test_excludes_other_users_assets(self): - repo = _make_repo() - my_video = Asset.create( - project_id="", - library_id="lib-1", - name="mine.mp4", - storage_key="v/mine.mp4", - mime_type="video/mp4", - status=AssetStatus.READY, - uploaded_by_user_id="user-A", - ) - other_video = Asset.create( - project_id="", - library_id="lib-1", - name="other.mp4", - storage_key="v/other.mp4", - mime_type="video/mp4", - status=AssetStatus.READY, - uploaded_by_user_id="user-B", - ) - repo.create(my_video) - repo.create(other_video) - result = repo.find_ready_videos_by_user("user-A") - assert len(result) == 1 - assert result[0].id == my_video.id - - def test_empty_result_for_unknown_user(self): - repo = _make_repo() - result = repo.find_ready_videos_by_user("nonexistent-user") - assert result == [] - - def test_respects_limit(self): - repo = _make_repo() - user_id = "user-abc-123" - for i in range(10): - asset = Asset.create( - project_id="", - library_id="lib-1", - name=f"video_{i}.mp4", - storage_key=f"v/v{i}.mp4", - mime_type="video/mp4", - status=AssetStatus.READY, - uploaded_by_user_id=user_id, - ) - repo.create(asset) - result = repo.find_ready_videos_by_user(user_id, limit=3) - assert len(result) == 3 - - -class TestAutoFallbackAutoMaterialModeUserId: - """_auto_fallback_auto_material_mode user_id 兜底策略测试.""" - - def _make_plan_check(self, project_id="", template_id="tmpl-1"): - plan = MagicMock() - plan.project_id = project_id - plan.template_id = template_id - plan.config = {} - return plan - - def _make_clip(self, clip_id="clip-1"): - clip = MagicMock() - clip.id = clip_id - clip.asset_id = "" - return clip - - def test_skips_when_no_clips_without_asset(self): - from app.api.routes.templates_editor._fallback import ( - _auto_fallback_auto_material_mode, - ) - - svc = MagicMock() - plan_check = self._make_plan_check() - _auto_fallback_auto_material_mode( - svc, - "plan-1", - plan_check, - [], - MagicMock(), - MagicMock(), - user_id="user-1", - ) - svc.assign_asset.assert_not_called() - - def test_strategy2_user_id_fallback(self): - from app.api.routes.templates_editor._fallback import ( - _auto_fallback_auto_material_mode, - ) - - svc = MagicMock() - plan_check = self._make_plan_check(project_id="") - clip = self._make_clip("clip-1") - mock_asset = MagicMock() - mock_asset.id = "asset-from-user" - mock_asset.status = AssetStatus.READY - mock_asset.mime_type = "video/mp4" - asset_repo = MagicMock() - asset_repo.find_ready_videos_by_user.return_value = [mock_asset] - asset_library_repo = MagicMock() - _auto_fallback_auto_material_mode( - svc, - "plan-1", - plan_check, - [clip], - asset_library_repo, - asset_repo, - user_id="user-123", - ) - asset_repo.find_ready_videos_by_user.assert_called_once_with("user-123") - svc.assign_asset.assert_called_once_with("clip-1", "asset-from-user") - - def test_strategy1_takes_priority_over_strategy2(self): - from app.api.routes.templates_editor._fallback import ( - _auto_fallback_auto_material_mode, - ) - - svc = MagicMock() - plan_check = self._make_plan_check(project_id="proj-1") - clip = self._make_clip("clip-1") - mock_lib = MagicMock() - mock_lib.id = "lib-video" - mock_lib.kind = MagicMock() - mock_lib.kind.value = "video" - mock_asset = MagicMock() - mock_asset.id = "asset-from-project" - mock_asset.status = "ready" - mock_asset.mime_type = "video/mp4" - asset_library_repo = MagicMock() - asset_library_repo.find_by_project.return_value = [mock_lib] - asset_repo = MagicMock() - asset_repo.find_by_library.return_value = [mock_asset] - _auto_fallback_auto_material_mode( - svc, - "plan-1", - plan_check, - [clip], - asset_library_repo, - asset_repo, - user_id="user-123", - ) - asset_library_repo.find_by_project.assert_called_once_with("proj-1") - asset_repo.find_ready_videos_by_user.assert_not_called() - svc.assign_asset.assert_called_once_with("clip-1", "asset-from-project") - - def test_falls_back_when_project_has_no_videos(self): - from app.api.routes.templates_editor._fallback import ( - _auto_fallback_auto_material_mode, - ) - - svc = MagicMock() - plan_check = self._make_plan_check(project_id="proj-1") - clip = self._make_clip("clip-1") - asset_library_repo = MagicMock() - asset_library_repo.find_by_project.return_value = [] - mock_asset = MagicMock() - mock_asset.id = "asset-from-user" - mock_asset.status = AssetStatus.READY - mock_asset.mime_type = "video/mp4" - asset_repo = MagicMock() - asset_repo.find_ready_videos_by_user.return_value = [mock_asset] - _auto_fallback_auto_material_mode( - svc, - "plan-1", - plan_check, - [clip], - asset_library_repo, - asset_repo, - user_id="user-123", - ) - asset_repo.find_ready_videos_by_user.assert_called_once_with("user-123") - svc.assign_asset.assert_called_once_with("clip-1", "asset-from-user") - - def test_no_assets_found_does_nothing(self): - from app.api.routes.templates_editor._fallback import ( - _auto_fallback_auto_material_mode, - ) - - svc = MagicMock() - plan_check = self._make_plan_check(project_id="") - clip = self._make_clip("clip-1") - asset_repo = MagicMock() - asset_repo.find_ready_videos_by_user.return_value = [] - asset_library_repo = MagicMock() - _auto_fallback_auto_material_mode( - svc, - "plan-1", - plan_check, - [clip], - asset_library_repo, - asset_repo, - user_id="user-123", - ) - svc.assign_asset.assert_not_called() - - def test_no_user_id_skips_strategy2(self): - from app.api.routes.templates_editor._fallback import ( - _auto_fallback_auto_material_mode, - ) - - svc = MagicMock() - plan_check = self._make_plan_check(project_id="") - clip = self._make_clip("clip-1") - asset_repo = MagicMock() - asset_library_repo = MagicMock() - _auto_fallback_auto_material_mode( - svc, - "plan-1", - plan_check, - [clip], - asset_library_repo, - asset_repo, - user_id="", - ) - asset_repo.find_ready_videos_by_user.assert_not_called() - svc.assign_asset.assert_not_called() diff --git a/tests/unit/test_generate_title_and_cover.py b/tests/unit/test_generate_title_and_cover.py deleted file mode 100644 index eea8a1644..000000000 --- a/tests/unit/test_generate_title_and_cover.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Tests for /generate endpoint — custom_title and cover_url passing.""" - -from __future__ import annotations - -import json -from unittest.mock import MagicMock, patch - -import pytest - - -class TestGenerateEndpointTitleAndCover: - """测试 /generate 端点传递 custom_title 和 cover_url。""" - - def test_generate_passes_cover_url_from_plan_config(self): - """从 plan.config.cover.image_url 读取封面 URL 传递给生成任务。""" - from app.api.routes.templates_editor.generation import generate_editor_draft - from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest - - mock_plan = MagicMock() - mock_plan.id = "plan-123" - mock_plan.project_id = "project-1" - mock_plan.template_id = "template-1" - mock_plan.status = MagicMock(value="editing") - mock_plan.config = { - "clips": [{"id": "c1"}], - "asset_ids": ["a1"], - "cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"}, - } - mock_plan.updated_at = None - - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - mock_plan_svc.can_generate.return_value = (True, "") - mock_plan_svc.mark_clips_ready.return_value = 1 - mock_template_svc = MagicMock() - - mock_gen_task = MagicMock() - mock_gen_task.id = "task-new" - mock_gen_task.project_id = "project-1" - - mock_current_user = MagicMock() - mock_current_user.user.id = "user-1" - - body = EditPlanGenerateRequest() # No title_config - - with ( - patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, - patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls, - patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None), - patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"), - patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"), - patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]), - patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"), - patch("app.api.routes.templates_editor.generation._check_queue_limits"), - patch("app.api.routes.templates_editor.generation.celery_app"), - patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-123"), - ): - mock_repo = MagicMock() - mock_repo_cls.return_value = mock_repo - - mock_usecase = MagicMock() - mock_usecase.execute.return_value = mock_gen_task - mock_usecase_cls.return_value = mock_usecase - - mock_plan_svc.transition_status = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - - result = generate_editor_draft( - template_id="template-1", - request=body, - plan_id="plan-123", - services=(mock_template_svc, mock_plan_svc), - db=MagicMock(), - current_user=mock_current_user, - asset_library_repo=MagicMock(), - asset_repo=MagicMock(), - ) - - # Verify cover_url was passed to CreateGenerationTaskCommand - call_args = mock_usecase.execute.call_args - command = call_args[0][0] - assert command.cover_url == "https://oss.example.com/uploaded/cover.jpg" - assert command.custom_title == "" - - def test_generate_passes_custom_title_from_title_config(self): - """前端传 title_config 时,序列化为 JSON 存入 custom_title。""" - from app.api.routes.templates_editor.generation import generate_editor_draft - from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest - - mock_plan = MagicMock() - mock_plan.id = "plan-456" - mock_plan.project_id = "project-1" - mock_plan.template_id = "template-1" - mock_plan.status = MagicMock(value="editing") - mock_plan.config = { - "clips": [{"id": "c1"}], - "asset_ids": ["a1"], - "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/cover.jpg"}, - } - mock_plan.updated_at = None - - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - mock_plan_svc.can_generate.return_value = (True, "") - mock_plan_svc.mark_clips_ready.return_value = 1 - mock_template_svc = MagicMock() - - mock_gen_task = MagicMock() - mock_gen_task.id = "task-title" - - mock_current_user = MagicMock() - mock_current_user.user.id = "user-1" - - title_config = { - "text": "测试标题", - "font_size": 36, - "font_color": "#ffffff", - "position": "center", - } - body = EditPlanGenerateRequest(title_config=title_config) - - with ( - patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, - patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls, - patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None), - patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"), - patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"), - patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]), - patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"), - patch("app.api.routes.templates_editor.generation._check_queue_limits"), - patch("app.api.routes.templates_editor.generation.celery_app"), - ): - mock_repo = MagicMock() - mock_repo_cls.return_value = mock_repo - - mock_usecase = MagicMock() - mock_usecase.execute.return_value = mock_gen_task - mock_usecase_cls.return_value = mock_usecase - - mock_plan_svc.transition_status = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - - result = generate_editor_draft( - template_id="template-1", - request=body, - plan_id="plan-456", - services=(mock_template_svc, mock_plan_svc), - db=MagicMock(), - current_user=mock_current_user, - asset_library_repo=MagicMock(), - asset_repo=MagicMock(), - ) - - # Verify custom_title was serialized to JSON - call_args = mock_usecase.execute.call_args - command = call_args[0][0] - parsed_title = json.loads(command.custom_title) - assert parsed_title["text"] == "测试标题" - assert parsed_title["font_size"] == 36 - assert command.cover_url == "https://oss.example.com/cover.jpg" - - def test_generate_empty_title_config_passes_empty_custom_title(self): - """title_config 为空时 custom_title 为空字符串。""" - from app.api.routes.templates_editor.generation import generate_editor_draft - from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest - - mock_plan = MagicMock() - mock_plan.id = "plan-789" - mock_plan.project_id = "project-1" - mock_plan.template_id = "template-1" - mock_plan.status = MagicMock(value="editing") - mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]} - mock_plan.updated_at = None - - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - mock_plan_svc.can_generate.return_value = (True, "") - mock_plan_svc.mark_clips_ready.return_value = 1 - mock_template_svc = MagicMock() - - mock_gen_task = MagicMock() - mock_gen_task.id = "task-no-title" - - body = EditPlanGenerateRequest() # No title_config - - with ( - patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, - patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls, - patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None), - patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"), - patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"), - patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]), - patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"), - patch("app.api.routes.templates_editor.generation._check_queue_limits"), - patch("app.api.routes.templates_editor.generation.celery_app"), - ): - mock_repo = MagicMock() - mock_repo_cls.return_value = mock_repo - - mock_usecase = MagicMock() - mock_usecase.execute.return_value = mock_gen_task - mock_usecase_cls.return_value = mock_usecase - - mock_plan_svc.transition_status = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - - result = generate_editor_draft( - template_id="template-1", - request=body, - plan_id="plan-789", - services=(mock_template_svc, mock_plan_svc), - db=MagicMock(), - current_user=MagicMock(), - asset_library_repo=MagicMock(), - asset_repo=MagicMock(), - ) - - call_args = mock_usecase.execute.call_args - command = call_args[0][0] - assert command.custom_title == "" - - -class TestGenerateEndpointRequestSchema: - """测试 EditPlanGenerateRequest schema。""" - - def test_schema_default_empty_title_config(self): - """默认 title_config 为空 dict。""" - from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest - - req = EditPlanGenerateRequest() - assert req.title_config == {} - - def test_schema_accepts_title_config(self): - """可以传入标题配置。""" - from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest - - req = EditPlanGenerateRequest(title_config={"text": "我的标题", "font_size": 48}) - assert req.title_config["text"] == "我的标题" - assert req.title_config["font_size"] == 48 - - -class TestGenerateTitleChangeSkipsReuse: - """测试标题变更时跳过预览产物复用。""" - - def _make_mocks(self, custom_title=""): - mock_plan = MagicMock() - mock_plan.id = "plan-reuse" - mock_plan.project_id = "project-1" - mock_plan.template_id = "template-1" - mock_plan.status = MagicMock(value="editing") - mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]} - mock_plan.updated_at = None - - mock_plan_svc = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - mock_plan_svc.can_generate.return_value = (True, "") - mock_plan_svc.mark_clips_ready.return_value = 1 - mock_template_svc = MagicMock() - - reusable_task = MagicMock() - reusable_task.id = "task-reusable" - reusable_task.is_completed = True - reusable_task.is_preview = True - reusable_task.custom_title = custom_title - reusable_task.project_id = "project-1" - reusable_task.source_edit_plan_id = "plan-reuse" - - mock_new_task = MagicMock() - mock_new_task.id = "task-new" - - return mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task - - def test_title_removed_skips_reuse(self): - """原来有标题,现在移除了 → 跳过复用,创建新任务。""" - from app.api.routes.templates_editor.generation import generate_editor_draft - from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest - - mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks( - custom_title='{"text": "旧标题"}' - ) - - body = EditPlanGenerateRequest() # No title_config → title removed - - with ( - patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, - patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls, - patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task), - patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"), - patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"), - patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]), - patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"), - patch("app.api.routes.templates_editor.generation._check_queue_limits"), - patch("app.api.routes.templates_editor.generation.celery_app"), - patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"), - ): - mock_repo = MagicMock() - mock_repo_cls.return_value = mock_repo - - mock_usecase = MagicMock() - mock_usecase.execute.return_value = mock_new_task - mock_usecase_cls.return_value = mock_usecase - - mock_plan_svc.transition_status = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - - result = generate_editor_draft( - template_id="template-1", - request=body, - plan_id="plan-reuse", - services=(mock_template_svc, mock_plan_svc), - db=MagicMock(), - current_user=MagicMock(), - asset_library_repo=MagicMock(), - asset_repo=MagicMock(), - ) - - # 应该创建新任务而不是复用 - mock_usecase.execute.assert_called_once() - # 不应该 mark_confirmed 在 reusable_task 上 - reusable_task.mark_confirmed.assert_not_called() - - def test_title_changed_skips_reuse(self): - """标题变更 → 跳过复用。""" - import json - - from app.api.routes.templates_editor.generation import generate_editor_draft - from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest - - mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks( - custom_title=json.dumps({"text": "旧标题", "font_size": 36}, ensure_ascii=False) - ) - - body = EditPlanGenerateRequest(title_config={"text": "新标题", "font_size": 48}) - - with ( - patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, - patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls, - patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task), - patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"), - patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"), - patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]), - patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"), - patch("app.api.routes.templates_editor.generation._check_queue_limits"), - patch("app.api.routes.templates_editor.generation.celery_app"), - patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"), - ): - mock_repo = MagicMock() - mock_repo_cls.return_value = mock_repo - - mock_usecase = MagicMock() - mock_usecase.execute.return_value = mock_new_task - mock_usecase_cls.return_value = mock_usecase - - mock_plan_svc.transition_status = MagicMock() - mock_plan_svc.get_plan_or_raise.return_value = mock_plan - - result = generate_editor_draft( - template_id="template-1", - request=body, - plan_id="plan-reuse", - services=(mock_template_svc, mock_plan_svc), - db=MagicMock(), - current_user=MagicMock(), - asset_library_repo=MagicMock(), - asset_repo=MagicMock(), - ) - - mock_usecase.execute.assert_called_once() - reusable_task.mark_confirmed.assert_not_called() diff --git a/tests/unit/test_unified_render_reuse.py b/tests/unit/test_unified_render_reuse.py deleted file mode 100644 index 6af944b84..000000000 --- a/tests/unit/test_unified_render_reuse.py +++ /dev/null @@ -1,287 +0,0 @@ -"""统一渲染路径 — 编辑器预览产物复用逻辑单元测试。 - -覆盖: -- _find_reusable_preview_task: 查找可复用的预览任务 -- _get_task_output_url: 获取任务输出 URL -- 编辑器 generate 接口复用预览产物路径 -""" - -from __future__ import annotations - -import os -import sys -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -from typing import Any, Optional -from unittest.mock import MagicMock, patch - -os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") -os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") - -import pytest - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) - -from packages.domain.generation_task import GenerationTask, GenerationTaskStatus - -# ── Stub Repository ────────────────────────────────────────────────────────── - - -class StubGenTaskRepo: - def __init__(self): - self._store = {} - - def create(self, task): - self._store[task.id] = task - return task - - def get(self, task_id): - return self._store.get(task_id) - - def update(self, task): - self._store[task.id] = task - return task - - def list_by_source_edit_plan(self, plan_id): - return [t for t in self._store.values() if (t.source_edit_plan_id or "") == plan_id] - - -def _make_task(**kwargs): - defaults = dict( - id="task-001", - project_id="proj-1", - asset_library_id="lib-1", - strategy_id="one_take", - voice_library_id="", - template_id="tmpl-1", - asset_ids=["a1"], - title_ids=[], - voice_ids=[], - status=GenerationTaskStatus.COMPLETED, - progress=100.0, - result_count=1, - error_message="", - created_by_user_id="user-1", - source_edit_plan_id="plan-1", - asset_select_mode="all", - is_preview=True, - source_task_id="", - output_width=1920, - output_height=1080, - cover_url="", - custom_title="", - video_title="", - resolution="", - bgm_config={}, - completed_at=datetime.now(timezone.utc), - ) - defaults.update(kwargs) - return GenerationTask(**defaults) - - -def _make_plan(updated_at=None): - plan = MagicMock() - plan.id = "plan-1" - plan.updated_at = updated_at or datetime.now(timezone.utc) - plan.status = MagicMock() - plan.status.value = "editing" - plan.config = {"clips": [{"id": "c1"}, {"id": "c2"}]} - return plan - - -# ── _find_reusable_preview_task ────────────────────────────────────────────── - - -class TestFindReusablePreviewTask: - def test_returns_completed_preview_task(self): - from app.api.routes.templates_editor.generation import _find_reusable_preview_task - - repo = StubGenTaskRepo() - now = datetime.now(timezone.utc) - task = _make_task( - source_edit_plan_id="plan-1", - is_preview=True, - status=GenerationTaskStatus.COMPLETED, - completed_at=now - timedelta(minutes=5), - ) - repo.create(task) - - plan = _make_plan(updated_at=now - timedelta(minutes=10)) - result = _find_reusable_preview_task(repo, "plan-1", plan) - - assert result is not None - assert result.id == "task-001" - - def test_returns_none_when_no_tasks(self): - from app.api.routes.templates_editor.generation import _find_reusable_preview_task - - repo = StubGenTaskRepo() - plan = _make_plan() - result = _find_reusable_preview_task(repo, "plan-1", plan) - assert result is None - - def test_returns_none_when_preview_not_completed(self): - from app.api.routes.templates_editor.generation import _find_reusable_preview_task - - repo = StubGenTaskRepo() - task = _make_task( - source_edit_plan_id="plan-1", - is_preview=True, - status=GenerationTaskStatus.RUNNING, - completed_at=None, - ) - repo.create(task) - - plan = _make_plan() - result = _find_reusable_preview_task(repo, "plan-1", plan) - assert result is None - - def test_returns_none_when_plan_modified_after_preview(self): - from app.api.routes.templates_editor.generation import _find_reusable_preview_task - - repo = StubGenTaskRepo() - now = datetime.now(timezone.utc) - task = _make_task( - source_edit_plan_id="plan-1", - is_preview=True, - status=GenerationTaskStatus.COMPLETED, - completed_at=now - timedelta(minutes=10), - ) - repo.create(task) - - # Plan was updated AFTER preview completed - plan = _make_plan(updated_at=now) - result = _find_reusable_preview_task(repo, "plan-1", plan) - assert result is None - - def test_skips_non_preview_tasks(self): - from app.api.routes.templates_editor.generation import _find_reusable_preview_task - - repo = StubGenTaskRepo() - task = _make_task( - source_edit_plan_id="plan-1", - is_preview=False, # not a preview task - status=GenerationTaskStatus.COMPLETED, - ) - repo.create(task) - - plan = _make_plan() - result = _find_reusable_preview_task(repo, "plan-1", plan) - assert result is None - - def test_handles_repo_exception(self): - from app.api.routes.templates_editor.generation import _find_reusable_preview_task - - repo = MagicMock() - repo.list_by_source_edit_plan.side_effect = Exception("db error") - plan = _make_plan() - result = _find_reusable_preview_task(repo, "plan-1", plan) - assert result is None - - -# ── _get_task_output_url ───────────────────────────────────────────────────── - - -class TestGetTaskOutputUrl: - def test_returns_video_url(self): - from app.api.routes.templates_editor.generation import _get_task_output_url - - task = _make_task() - repo = MagicMock() - db = MagicMock() - - mock_video = MagicMock() - mock_video.file_url = "https://oss.example.com/video.mp4" - - mock_use_case = MagicMock() - mock_use_case.execute.return_value = [mock_video] - - with patch( - "app.api.routes.templates_editor.generation.ListGeneratedVideosByTaskUseCase", - return_value=mock_use_case, - ): - result = _get_task_output_url(task, repo, db) - - assert result == "https://oss.example.com/video.mp4" - - def test_returns_empty_when_no_videos(self): - from app.api.routes.templates_editor.generation import _get_task_output_url - - task = _make_task() - repo = MagicMock() - db = MagicMock() - - mock_use_case = MagicMock() - mock_use_case.execute.return_value = [] - - with patch( - "app.api.routes.templates_editor.generation.ListGeneratedVideosByTaskUseCase", - return_value=mock_use_case, - ): - result = _get_task_output_url(task, repo, db) - - assert result == "" - - def test_returns_empty_on_exception(self): - from app.api.routes.templates_editor.generation import _get_task_output_url - - task = _make_task() - repo = MagicMock() - db = MagicMock() - - with patch( - "app.api.routes.templates_editor.generation.ListGeneratedVideosByTaskUseCase", - side_effect=Exception("db error"), - ): - result = _get_task_output_url(task, repo, db) - - assert result == "" - - -# ── mark_confirmed ─────────────────────────────────────────────────────────── - - -class TestMarkConfirmed: - def test_sets_is_preview_false(self): - task = _make_task(is_preview=True) - task.mark_confirmed() - assert task.is_preview is False - - def test_sets_cover_url(self): - task = _make_task() - task.mark_confirmed(cover_url="https://example.com/cover.jpg") - assert task.cover_url == "https://example.com/cover.jpg" - - def test_sets_custom_title(self): - task = _make_task() - task.mark_confirmed(custom_title="My Video") - assert task.custom_title == "My Video" - - def test_sets_output_dimensions(self): - task = _make_task() - task.mark_confirmed(output_width=1080, output_height=1920) - assert task.output_width == 1080 - assert task.output_height == 1920 - - def test_zero_dimensions_not_applied(self): - task = _make_task(output_width=1920, output_height=1080) - task.mark_confirmed(output_width=0, output_height=0) - assert task.output_width == 1920 - assert task.output_height == 1080 - - def test_skips_when_plan_updated_at_is_none(self): - from app.api.routes.templates_editor.generation import _find_reusable_preview_task - - repo = StubGenTaskRepo() - now = datetime.now(timezone.utc) - task = _make_task( - source_edit_plan_id="plan-1", - is_preview=True, - status=GenerationTaskStatus.COMPLETED, - completed_at=now - timedelta(minutes=5), - ) - repo.create(task) - - plan = _make_plan(updated_at=None) - result = _find_reusable_preview_task(repo, "plan-1", plan) - assert result is None -- 2.54.0 From 1035c349d7e8e0047e6c3d4b337f508300bb267a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 23 Aug 2026 22:54:38 +0800 Subject: [PATCH 3/3] chore: retrigger CI -- 2.54.0