Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b0ee73f35 | |||
| 39316b7f22 | |||
| f04038f955 | |||
| 79d6addcef | |||
| 989a8221f2 | |||
| 2526f18890 | |||
| e24636d2dd | |||
| ad37a1420f | |||
| 86663150ae | |||
| ecf457ecdb | |||
| e5a96db948 | |||
| f499f4a0e7 | |||
| 6978ec66ed | |||
| 9c1bcd93d2 | |||
| 7748604e76 | |||
| 5471ed473e | |||
| 488928f6eb | |||
| 81779c4e1f | |||
| 833ea8e9d8 | |||
| 018358cbb1 | |||
| ba288f2e8b | |||
| 86a868acfb | |||
| c18146287e | |||
| e1b6ccaf0a | |||
| ab5d3bd251 | |||
| 13883511f2 | |||
| 8ebe970615 | |||
| cebb33c2e2 | |||
| 8ffc22f348 | |||
| c99f3f75ad | |||
| 0382c4e697 | |||
| 45e7cfe7c9 | |||
| 370c3923ae | |||
| 771c5ba579 | |||
| 2e59d4a275 | |||
| f905c3ef8d | |||
| 46c7c351af | |||
| f411dd0e1f | |||
| 09a4dbe04c | |||
| 533a64e215 | |||
| 90f834e03a | |||
| 33f08b465b | |||
| 388341b522 | |||
| 56c7d9c95a | |||
| 05730e6c56 | |||
| d2ea8ba579 | |||
| 1486c7028a | |||
| 73826d2f81 | |||
| 64833c8225 | |||
| 7ff7ae6c7d | |||
| b04345c7ff | |||
| a330ce50c0 | |||
| 5e2800d360 | |||
| 83a3d072fc | |||
| 7f8193fbc6 | |||
| 8107b2a255 | |||
| 497ea8ca25 | |||
| 3ded5eb962 | |||
| 7c67468115 | |||
| 5f17a00318 | |||
| 2a4aa46881 | |||
| 999ea2856a | |||
| 7da09bfcfd | |||
| 1af8c7ae9e | |||
| 27681c785a | |||
| 639f73b16d | |||
| a7b2cbfc8c |
File diff suppressed because one or more lines are too long
+4
-787
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
"""add result_count to edit_plans
|
||||||
|
|
||||||
|
Revision ID: 041_result_count
|
||||||
|
Revises: 040_playback_speed
|
||||||
|
Create Date: 2026-07-15 14:05:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "041_result_count"
|
||||||
|
down_revision = "040_playback_speed"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"edit_plans",
|
||||||
|
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("edit_plans", "result_count")
|
||||||
@@ -72,6 +72,7 @@ class EditPlanResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
status: str
|
status: str
|
||||||
total_duration: float
|
total_duration: float
|
||||||
|
result_count: int = 0
|
||||||
project_id: str = ""
|
project_id: str = ""
|
||||||
created_by_user_id: str = ""
|
created_by_user_id: str = ""
|
||||||
config: dict[str, Any]
|
config: dict[str, Any]
|
||||||
@@ -241,6 +242,7 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
|
|||||||
name=p.name,
|
name=p.name,
|
||||||
status=p.status.value if hasattr(p.status, "value") else p.status,
|
status=p.status.value if hasattr(p.status, "value") else p.status,
|
||||||
total_duration=p.total_duration,
|
total_duration=p.total_duration,
|
||||||
|
result_count=getattr(p, "result_count", 0),
|
||||||
project_id=p.project_id or "",
|
project_id=p.project_id or "",
|
||||||
created_by_user_id=p.created_by_user_id or "",
|
created_by_user_id=p.created_by_user_id or "",
|
||||||
config=p.config,
|
config=p.config,
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class PlanGeneratorService:
|
|||||||
)
|
)
|
||||||
order += 1
|
order += 1
|
||||||
# 剩余为 overlay
|
# 剩余为 overlay
|
||||||
for i in range(1, n):
|
for _ in range(1, n):
|
||||||
clips.append(
|
clips.append(
|
||||||
EditPlanClip.create(
|
EditPlanClip.create(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
@@ -237,7 +237,7 @@ class PlanGeneratorService:
|
|||||||
|
|
||||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||||
# N 个 main clips(B-roll)
|
# N 个 main clips(B-roll)
|
||||||
for i in range(n):
|
for _ in range(n):
|
||||||
clips.append(
|
clips.append(
|
||||||
EditPlanClip.create(
|
EditPlanClip.create(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
@@ -271,7 +271,7 @@ class PlanGeneratorService:
|
|||||||
)
|
)
|
||||||
order += 1
|
order += 1
|
||||||
# 剩余为 b_roll
|
# 剩余为 b_roll
|
||||||
for i in range(2, n):
|
for _ in range(2, n):
|
||||||
clips.append(
|
clips.append(
|
||||||
EditPlanClip.create(
|
EditPlanClip.create(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
@@ -284,7 +284,7 @@ class PlanGeneratorService:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# ONE_TAKE: N 个 main clips
|
# ONE_TAKE: N 个 main clips
|
||||||
for i in range(n):
|
for _ in range(n):
|
||||||
clips.append(
|
clips.append(
|
||||||
EditPlanClip.create(
|
EditPlanClip.create(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export interface EditPlan {
|
|||||||
name: string;
|
name: string;
|
||||||
status: EditPlanStatus;
|
status: EditPlanStatus;
|
||||||
total_duration: number;
|
total_duration: number;
|
||||||
|
/** 生成视频数量(后端 EditPlanResponse.result_count) */
|
||||||
|
result_count: number;
|
||||||
config: EditPlanConfig;
|
config: EditPlanConfig;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -161,14 +163,21 @@ export interface GenerateResponse {
|
|||||||
clip_count: number;
|
clip_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 剪辑计划关联的生成记录 */
|
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
|
||||||
export interface EditPlanGeneration {
|
export interface EditPlanGeneration {
|
||||||
id: string;
|
id: string; // 即 generation_task_id
|
||||||
edit_plan_id: string;
|
source_edit_plan_id: string;
|
||||||
generation_task_id: string;
|
template_id: string;
|
||||||
|
asset_ids: string[];
|
||||||
status: EditPlanStatus;
|
status: EditPlanStatus;
|
||||||
created_at: string;
|
progress: number;
|
||||||
updated_at: string;
|
result_count: number;
|
||||||
|
error_message: string;
|
||||||
|
error_info: Record<string, unknown>;
|
||||||
|
logs: Array<Record<string, unknown>>;
|
||||||
|
retry_count: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 片段生成状态 */
|
/** 片段生成状态 */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 成品 / 视频相关 API
|
* 成品 / 视频相关 API
|
||||||
|
* 包含:列表查询、复核状态、批量下载
|
||||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
|
|||||||
@@ -16,17 +16,22 @@ import type { EditPlanConfig } from "./editPlans";
|
|||||||
/** 模板条目(后端 TemplateResponse) */
|
/** 模板条目(后端 TemplateResponse) */
|
||||||
export interface TemplateItem {
|
export interface TemplateItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
user_id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
|
mode?: string;
|
||||||
category: string;
|
category: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
target_duration: number;
|
/** 预估时长(后端字段名 estimated_duration) */
|
||||||
clip_count: number;
|
estimated_duration?: number;
|
||||||
|
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
|
||||||
|
target_duration?: number;
|
||||||
|
clip_count?: number;
|
||||||
/** 使用次数 */
|
/** 使用次数 */
|
||||||
usage_count?: number;
|
usage_count?: number;
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
preview_url?: string;
|
preview_url?: string;
|
||||||
is_active: boolean;
|
is_active?: boolean;
|
||||||
is_favorite?: boolean;
|
is_favorite?: boolean;
|
||||||
/** 素材规则(片段配置) */
|
/** 素材规则(片段配置) */
|
||||||
segments?: TemplateSegment[];
|
segments?: TemplateSegment[];
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ const formatSize = (bytes: number) => {
|
|||||||
/** 格式化时长 */
|
/** 格式化时长 */
|
||||||
const formatDuration = (seconds?: number) => {
|
const formatDuration = (seconds?: number) => {
|
||||||
if (!seconds) return "-";
|
if (!seconds) return "-";
|
||||||
const m = Math.floor(seconds / 60);
|
const totalSec = Math.round(seconds);
|
||||||
const s = seconds % 60;
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`;
|
return m > 0 ? `${m}分${s}秒` : `${s}秒`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ const formatSize = (bytes: number) => {
|
|||||||
/** 格式化时长 */
|
/** 格式化时长 */
|
||||||
const formatDuration = (seconds?: number) => {
|
const formatDuration = (seconds?: number) => {
|
||||||
if (!seconds) return "-";
|
if (!seconds) return "-";
|
||||||
const m = Math.floor(seconds / 60);
|
const totalSec = Math.round(seconds);
|
||||||
const s = seconds % 60;
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`;
|
return m > 0 ? `${m}分${s}秒` : `${s}秒`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ const STATUS_CONFIG: Record<
|
|||||||
/** 格式化时长 */
|
/** 格式化时长 */
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
if (seconds <= 0) return "-";
|
if (seconds <= 0) return "-";
|
||||||
const m = Math.floor(seconds / 60);
|
const totalSec = Math.round(seconds);
|
||||||
const s = seconds % 60;
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
if (m === 0) return `${s}秒`;
|
if (m === 0) return `${s}秒`;
|
||||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||||
};
|
};
|
||||||
@@ -253,6 +254,16 @@ export default function EditPlans() {
|
|||||||
<span className="plan-duration">{formatDuration(seconds)}</span>
|
<span className="plan-duration">{formatDuration(seconds)}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "视频数",
|
||||||
|
dataIndex: "result_count",
|
||||||
|
key: "result_count",
|
||||||
|
width: 80,
|
||||||
|
align: "center",
|
||||||
|
render: (count: number) => (
|
||||||
|
<span className="plan-result-count">{count > 0 ? count : "—"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
dataIndex: "created_at",
|
dataIndex: "created_at",
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { message } from "antd";
|
import { message, Modal, Progress, Button } from "antd";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
EditingTemplate,
|
EditingTemplate,
|
||||||
@@ -20,11 +20,23 @@ import {
|
|||||||
getTemplateCategories,
|
getTemplateCategories,
|
||||||
MODE_LABELS,
|
MODE_LABELS,
|
||||||
} from "@/api/editingPlanner";
|
} from "@/api/editingPlanner";
|
||||||
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
import type {
|
||||||
|
EditPlanGeneration,
|
||||||
|
EditPlanConfig,
|
||||||
|
GeneratedVideo,
|
||||||
|
MediaAsset,
|
||||||
|
TransitionEffect,
|
||||||
|
} from "@/api/editPlans";
|
||||||
import {
|
import {
|
||||||
getMediaAssets,
|
getMediaAssets,
|
||||||
getEditPlanGenerations,
|
getEditPlanGenerations,
|
||||||
generateCover,
|
generateCover,
|
||||||
|
getEditPlan,
|
||||||
|
createEditPlan,
|
||||||
|
updateEditPlan,
|
||||||
|
generateEditPlan,
|
||||||
|
getGenerationStatus,
|
||||||
|
getGenerationTaskResults,
|
||||||
} from "@/api/editPlans";
|
} from "@/api/editPlans";
|
||||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||||
import type {
|
import type {
|
||||||
@@ -33,6 +45,7 @@ import type {
|
|||||||
TransitionConfig,
|
TransitionConfig,
|
||||||
SpeedConfig,
|
SpeedConfig,
|
||||||
TtsConfig,
|
TtsConfig,
|
||||||
|
TtsMode,
|
||||||
TrimConfig,
|
TrimConfig,
|
||||||
WatermarkConfig,
|
WatermarkConfig,
|
||||||
IntroOutroConfig,
|
IntroOutroConfig,
|
||||||
@@ -107,8 +120,8 @@ const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"];
|
|||||||
|
|
||||||
const EditingPlanner: React.FC = () => {
|
const EditingPlanner: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const urlTemplateId = searchParams.get("templateId") || "";
|
const urlTemplateId = searchParams.get("templateId") || "";
|
||||||
|
const urlPlanId = searchParams.get("planId") || "";
|
||||||
|
|
||||||
/* ── 模板列表 ── */
|
/* ── 模板列表 ── */
|
||||||
const [templates, setTemplates] = useState<EditingTemplate[]>([]);
|
const [templates, setTemplates] = useState<EditingTemplate[]>([]);
|
||||||
@@ -244,6 +257,21 @@ const EditingPlanner: React.FC = () => {
|
|||||||
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
|
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
|
||||||
const [genHistoryLoading, setGenHistoryLoading] = useState(false);
|
const [genHistoryLoading, setGenHistoryLoading] = useState(false);
|
||||||
|
|
||||||
|
/* ── 剪辑计划(从列表页编辑进入时) ── */
|
||||||
|
const [loadedPlanId, setLoadedPlanId] = useState<string | null>(
|
||||||
|
urlPlanId || null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── 生成进度 ── */
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [genProgress, setGenProgress] = useState(0);
|
||||||
|
const [genTotalClips, setGenTotalClips] = useState(0);
|
||||||
|
const [genDoneClips, setGenDoneClips] = useState(0);
|
||||||
|
const [generated, setGenerated] = useState(false);
|
||||||
|
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||||
|
const [genError, setGenError] = useState<string | null>(null);
|
||||||
|
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
/* ── 播放 ── */
|
/* ── 播放 ── */
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
@@ -374,6 +402,85 @@ const EditingPlanner: React.FC = () => {
|
|||||||
.catch(() => message.error("加载模板详情失败"));
|
.catch(() => message.error("加载模板详情失败"));
|
||||||
}, [loadedTemplateId, resetClips]);
|
}, [loadedTemplateId, resetClips]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载已有剪辑计划数据到编辑器
|
||||||
|
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadedPlanId) return;
|
||||||
|
getEditPlan(loadedPlanId)
|
||||||
|
.then((plan) => {
|
||||||
|
// 设置关联的模板(触发模板加载 effect)
|
||||||
|
setLoadedTemplateId(plan.template_id);
|
||||||
|
|
||||||
|
// 还原基本信息
|
||||||
|
setDraftName(plan.name);
|
||||||
|
|
||||||
|
// 还原 config 中的编辑器状态
|
||||||
|
const cfg = plan.config;
|
||||||
|
if (cfg.title_config) {
|
||||||
|
setTitleSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||||
|
title: cfg.title_config!.content,
|
||||||
|
position: cfg.title_config!.position,
|
||||||
|
font: cfg.title_config!.font_preset,
|
||||||
|
size: cfg.title_config!.font_size,
|
||||||
|
color: cfg.title_config!.font_color || "#ffffff",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (cfg.subtitle_config) {
|
||||||
|
setSubtitleSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: cfg.subtitle_config!.enabled,
|
||||||
|
position: (cfg.subtitle_config!.position ||
|
||||||
|
"bottom") as SubtitleStyleConfig["position"],
|
||||||
|
font: cfg.subtitle_config!.font,
|
||||||
|
fontSize: cfg.subtitle_config!.size,
|
||||||
|
fontColor: cfg.subtitle_config!.color || "#ffffff",
|
||||||
|
animation: cfg.subtitle_config!.animation,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (cfg.bgm_config) {
|
||||||
|
setBgmSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: cfg.bgm_config!.enabled,
|
||||||
|
music_id: cfg.bgm_config!.music_id || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips
|
||||||
|
if (cfg.segments && cfg.segments.length > 0) {
|
||||||
|
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
|
||||||
|
id: `seg-${idx}`,
|
||||||
|
template_segment_id: `seg-${idx}`,
|
||||||
|
type: (seg.material_type === "voiceover"
|
||||||
|
? "voice"
|
||||||
|
: "pip") as ClipType,
|
||||||
|
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||||
|
startOffset: 0,
|
||||||
|
script_text: "",
|
||||||
|
order: seg.segment_order,
|
||||||
|
transition: seg.transition
|
||||||
|
? {
|
||||||
|
type: seg.transition.type as TransitionEffect["type"],
|
||||||
|
duration: seg.transition.duration,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
speed: seg.playback_speed
|
||||||
|
? { rate: seg.playback_speed, pitchCorrection: true }
|
||||||
|
: undefined,
|
||||||
|
tts_config: seg.tts_config
|
||||||
|
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
|
||||||
|
: undefined,
|
||||||
|
trim_config: seg.trim_config || undefined,
|
||||||
|
}));
|
||||||
|
setTimeout(() => resetClips(mapped), 100);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => message.error("加载剪辑计划失败"));
|
||||||
|
}, [loadedPlanId, resetClips]);
|
||||||
|
|
||||||
/* ──────────── 计算 ──────────── */
|
/* ──────────── 计算 ──────────── */
|
||||||
|
|
||||||
const currentTemplate = templates.find((t) => t.id === loadedTemplateId);
|
const currentTemplate = templates.find((t) => t.id === loadedTemplateId);
|
||||||
@@ -671,6 +778,65 @@ const EditingPlanner: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 构建剪辑计划 config(编辑器状态 → API config) */
|
||||||
|
const buildPlanConfig = (): EditPlanConfig => ({
|
||||||
|
title_config: {
|
||||||
|
ai_auto_select: titleSettings.aiAutoSelect,
|
||||||
|
content: titleSettings.title,
|
||||||
|
position: titleSettings.position,
|
||||||
|
font_preset: titleSettings.font,
|
||||||
|
font_color: titleSettings.color,
|
||||||
|
font_size: titleSettings.size,
|
||||||
|
},
|
||||||
|
subtitle_config: {
|
||||||
|
enabled: subtitleSettings.enabled,
|
||||||
|
position: subtitleSettings.position,
|
||||||
|
font: subtitleSettings.font,
|
||||||
|
color: subtitleSettings.fontColor,
|
||||||
|
size: subtitleSettings.fontSize,
|
||||||
|
animation: subtitleSettings.animation,
|
||||||
|
},
|
||||||
|
bgm_config: {
|
||||||
|
enabled: bgmSettings.enabled,
|
||||||
|
music_id: bgmSettings.music_id,
|
||||||
|
},
|
||||||
|
estimated_duration: totalDuration,
|
||||||
|
segments: clips.map((c, i) => ({
|
||||||
|
segment_order: i,
|
||||||
|
duration_min: Math.max(1, c.duration - 2),
|
||||||
|
duration_max: c.duration + 2,
|
||||||
|
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||||
|
transition: c.transition
|
||||||
|
? { type: c.transition.type, duration: c.transition.duration }
|
||||||
|
: undefined,
|
||||||
|
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||||
|
tts_config: c.tts_config
|
||||||
|
? {
|
||||||
|
mode: c.tts_config.mode,
|
||||||
|
text: c.tts_config.text,
|
||||||
|
voice_id: c.tts_config.voice_id,
|
||||||
|
speed: c.tts_config.speed,
|
||||||
|
pitch: c.tts_config.pitch,
|
||||||
|
volume: c.tts_config.volume,
|
||||||
|
subtitle_sync: c.tts_config.subtitle_sync,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
trim_config: c.trim_config
|
||||||
|
? {
|
||||||
|
start_time: c.trim_config.start_time,
|
||||||
|
end_time: c.trim_config.end_time,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
watermark_config: { ...watermarkSettings },
|
||||||
|
intro_outro_config: { ...introOutroSettings },
|
||||||
|
pip_config: { ...pipSettings },
|
||||||
|
filter_config: { ...filterSettings },
|
||||||
|
green_screen_config: { ...chromaKeySettings },
|
||||||
|
sticker_config: { ...stickerSettings },
|
||||||
|
cover_config: { ...coverSettings },
|
||||||
|
});
|
||||||
|
|
||||||
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
||||||
const handleOpenSaveModal = () => {
|
const handleOpenSaveModal = () => {
|
||||||
setSaveModalOpen(true);
|
setSaveModalOpen(true);
|
||||||
@@ -763,94 +929,143 @@ const EditingPlanner: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到一键生成页面
|
* 剪辑计划生成
|
||||||
* 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfig(JSON 序列化)
|
* 1. 有 planId → 更新计划配置 + 触发生成
|
||||||
* 一键生成页面从 params 解析配置,无需重复请求接口
|
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成
|
||||||
|
* 3. 触发生成后轮询状态,完成后获取视频结果
|
||||||
*/
|
*/
|
||||||
const handleGoToGenerate = () => {
|
const handleGoToGenerate = async () => {
|
||||||
const planConfig = {
|
if (!loadedTemplateId) {
|
||||||
title_config: {
|
message.warning("请先选择一个模板");
|
||||||
ai_auto_select: titleSettings.aiAutoSelect,
|
return;
|
||||||
content: titleSettings.title,
|
}
|
||||||
position: titleSettings.position,
|
if (clips.length === 0) {
|
||||||
font_preset: titleSettings.font,
|
message.warning("请先添加片段");
|
||||||
font_color: titleSettings.color,
|
return;
|
||||||
font_size: titleSettings.size,
|
}
|
||||||
bold: titleSettings.bold,
|
|
||||||
italic: titleSettings.italic,
|
setGenerating(true);
|
||||||
stroke: titleSettings.stroke,
|
setGenerated(false);
|
||||||
shadow: titleSettings.shadow,
|
setGeneratedVideos([]);
|
||||||
},
|
setGenError(null);
|
||||||
subtitle_config: {
|
setGenProgress(0);
|
||||||
enabled: subtitleSettings.enabled,
|
|
||||||
position: subtitleSettings.position,
|
try {
|
||||||
font: subtitleSettings.font,
|
const config = buildPlanConfig();
|
||||||
color: subtitleSettings.fontColor,
|
let planId = loadedPlanId;
|
||||||
size: subtitleSettings.fontSize,
|
|
||||||
animation: subtitleSettings.animation,
|
if (planId) {
|
||||||
},
|
// 已有计划 → 更新配置(不传 status,避免非 draft 状态被后端拒绝)
|
||||||
bgm_config: {
|
try {
|
||||||
enabled: bgmSettings.enabled,
|
await updateEditPlan(planId, {
|
||||||
music_id: bgmSettings.music_id,
|
config,
|
||||||
},
|
total_duration: totalDuration,
|
||||||
mode: currentMode,
|
});
|
||||||
total_duration: totalDuration,
|
} catch (updateErr) {
|
||||||
segments: clips.map((c, i) => ({
|
// 非 draft 状态(如 failed/editing)PUT 会被拒绝,忽略继续生成
|
||||||
order: i,
|
console.warn("[计划更新跳过]", updateErr);
|
||||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
}
|
||||||
duration: c.duration,
|
} else {
|
||||||
template_segment_id: c.template_segment_id,
|
// 无计划 → 创建新计划
|
||||||
script_text: c.script_text,
|
const plan = await createEditPlan({
|
||||||
voice_asset_id: c.voice_asset_id,
|
template_id: loadedTemplateId,
|
||||||
voice_file_url: c.voice_file_url,
|
name: draftName || "未命名计划",
|
||||||
transition: c.transition
|
config,
|
||||||
? { type: c.transition.type, duration: c.transition.duration }
|
total_duration: totalDuration,
|
||||||
: undefined,
|
});
|
||||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
planId = plan.id;
|
||||||
tts_config: c.tts_config
|
setLoadedPlanId(planId);
|
||||||
? {
|
// 更新 URL 参数(不刷新页面)
|
||||||
mode: c.tts_config.mode,
|
const params = new URLSearchParams(window.location.search);
|
||||||
text: c.tts_config.text,
|
params.set("planId", planId);
|
||||||
voice_id: c.tts_config.voice_id,
|
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||||
speed: c.tts_config.speed,
|
}
|
||||||
pitch: c.tts_config.pitch,
|
|
||||||
volume: c.tts_config.volume,
|
// 触发生成
|
||||||
subtitle_sync: c.tts_config.subtitle_sync,
|
const genRes = await generateEditPlan(planId);
|
||||||
}
|
setGenTotalClips(genRes.clip_count);
|
||||||
: undefined,
|
message.info("已提交生成,等待处理...");
|
||||||
trim_config: c.trim_config
|
|
||||||
? {
|
// 开始轮询
|
||||||
start_time: c.trim_config.start_time,
|
startPolling(planId);
|
||||||
end_time: c.trim_config.end_time,
|
} catch (err) {
|
||||||
}
|
console.error("[生成失败]", err);
|
||||||
: undefined,
|
setGenError("生成提交失败,请重试");
|
||||||
})),
|
setGenerating(false);
|
||||||
watermark_config: { ...watermarkSettings },
|
|
||||||
intro_outro_config: { ...introOutroSettings },
|
|
||||||
pip_config: { ...pipSettings },
|
|
||||||
filter_config: { ...filterSettings },
|
|
||||||
green_screen_config: { ...chromaKeySettings },
|
|
||||||
sticker_config: { ...stickerSettings },
|
|
||||||
cover_config: { ...coverSettings },
|
|
||||||
};
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (loadedTemplateId) {
|
|
||||||
params.set("edit_plan_id", loadedTemplateId);
|
|
||||||
}
|
}
|
||||||
params.set("plan_config", JSON.stringify(planConfig));
|
|
||||||
navigate(`/app/generate?${params.toString()}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 轮询生成状态,每 2 秒一次 */
|
||||||
|
const startPolling = (planId: string) => {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const status = await getGenerationStatus(planId);
|
||||||
|
|
||||||
|
// 计算进度
|
||||||
|
const total = status.clips.length || genTotalClips;
|
||||||
|
const done = status.clips.filter(
|
||||||
|
(c) => c.status === "completed" || c.status === "failed",
|
||||||
|
).length;
|
||||||
|
setGenDoneClips(done);
|
||||||
|
setGenTotalClips(total);
|
||||||
|
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5);
|
||||||
|
|
||||||
|
if (status.plan_status === "completed") {
|
||||||
|
setGenProgress(100);
|
||||||
|
setGenerating(false);
|
||||||
|
setGenerated(true);
|
||||||
|
|
||||||
|
// 获取视频结果
|
||||||
|
if (status.generation_task_id) {
|
||||||
|
try {
|
||||||
|
const videos = await getGenerationTaskResults(
|
||||||
|
status.generation_task_id,
|
||||||
|
);
|
||||||
|
setGeneratedVideos(videos);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[获取视频结果失败]", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.success("视频生成完成!");
|
||||||
|
return; // 停止轮询
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.plan_status === "failed") {
|
||||||
|
setGenerating(false);
|
||||||
|
setGenError("生成失败,请重试");
|
||||||
|
return; // 停止轮询
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续轮询
|
||||||
|
genTimerRef.current = setTimeout(poll, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[轮询状态失败]", err);
|
||||||
|
genTimerRef.current = setTimeout(poll, 5000); // 出错后 5 秒重试
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 首次延迟 2 秒后开始
|
||||||
|
genTimerRef.current = setTimeout(poll, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 清理轮询定时器 */
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (genTimerRef.current) clearTimeout(genTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* 查看生成历史 */
|
/* 查看生成历史 */
|
||||||
const handleViewGenHistory = async () => {
|
const handleViewGenHistory = async () => {
|
||||||
if (!loadedTemplateId) {
|
const targetId = loadedPlanId || loadedTemplateId;
|
||||||
message.warning("请先加载一个模板");
|
if (!targetId) {
|
||||||
|
message.warning("请先加载一个模板或计划");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGenHistoryOpen(true);
|
setGenHistoryOpen(true);
|
||||||
setGenHistoryLoading(true);
|
setGenHistoryLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await getEditPlanGenerations(loadedTemplateId);
|
const items = await getEditPlanGenerations(targetId);
|
||||||
setGenHistory(items);
|
setGenHistory(items);
|
||||||
} catch {
|
} catch {
|
||||||
message.error("加载生成历史失败");
|
message.error("加载生成历史失败");
|
||||||
@@ -899,8 +1114,9 @@ const EditingPlanner: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className="ep-btn ep-btn-primary"
|
className="ep-btn ep-btn-primary"
|
||||||
onClick={handleGoToGenerate}
|
onClick={handleGoToGenerate}
|
||||||
|
disabled={generating}
|
||||||
>
|
>
|
||||||
🎬 使用此模板生成
|
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1065,6 +1281,111 @@ const EditingPlanner: React.FC = () => {
|
|||||||
onClose={() => setGenHistoryOpen(false)}
|
onClose={() => setGenHistoryOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ═══ 生成进度弹窗 ═══ */}
|
||||||
|
<Modal
|
||||||
|
title={genError ? "生成失败" : generated ? "生成完成" : "正在生成视频"}
|
||||||
|
open={generating || generated || !!genError}
|
||||||
|
footer={
|
||||||
|
generated
|
||||||
|
? [
|
||||||
|
<Button
|
||||||
|
key="close"
|
||||||
|
onClick={() => {
|
||||||
|
setGenerated(false);
|
||||||
|
setGenerating(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>,
|
||||||
|
generatedVideos.length > 0 && (
|
||||||
|
<Button
|
||||||
|
key="download"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
const v = generatedVideos[0];
|
||||||
|
const url = v.download_url || v.file_url;
|
||||||
|
if (url) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = v.name || "video.mp4";
|
||||||
|
a.target = "_blank";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载视频
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
closable={!generating}
|
||||||
|
maskClosable={false}
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
{generating && (
|
||||||
|
<div style={{ padding: "16px 0" }}>
|
||||||
|
<Progress percent={genProgress} status="active" />
|
||||||
|
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
|
||||||
|
已处理 {genDoneClips}/{genTotalClips} 个片段
|
||||||
|
</p>
|
||||||
|
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||||
|
请耐心等待,生成过程中请勿关闭页面
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{generated && generatedVideos.length > 0 && (
|
||||||
|
<div style={{ padding: "8px 0" }}>
|
||||||
|
<video
|
||||||
|
src={
|
||||||
|
generatedVideos[0].file_url || generatedVideos[0].download_url
|
||||||
|
}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generatedVideos[0].name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{generated && !generatedVideos.length && (
|
||||||
|
<div style={{ padding: "24px 0", textAlign: "center" }}>
|
||||||
|
<p>生成完成,但暂未获取到视频结果</p>
|
||||||
|
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||||
|
请稍后在剪辑计划列表中查看
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{genError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#ff4d4f",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>{genError}</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setGenError(null);
|
||||||
|
setGenerating(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||||
<BgmSelector
|
<BgmSelector
|
||||||
open={bgmDrawerOpen}
|
open={bgmDrawerOpen}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<tr key={gen.id} className="ep-gh-table-row">
|
<tr key={gen.id} className="ep-gh-table-row">
|
||||||
<td className="ep-gh-td ep-gh-td-id">
|
<td className="ep-gh-td ep-gh-td-id">
|
||||||
{gen.generation_task_id.slice(0, 8)}...
|
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="ep-gh-td">
|
<td className="ep-gh-td">
|
||||||
<span className={`ep-gh-status-tag ${statusClass}`}>
|
<span className={`ep-gh-status-tag ${statusClass}`}>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
getTemplate,
|
getTemplate,
|
||||||
toggleFavoriteTemplate,
|
toggleFavoriteTemplate,
|
||||||
copyTemplate,
|
copyTemplate,
|
||||||
generateFromTemplate,
|
|
||||||
type TemplateItem,
|
type TemplateItem,
|
||||||
type TemplateListParams,
|
type TemplateListParams,
|
||||||
type TemplateSegment,
|
type TemplateSegment,
|
||||||
@@ -91,10 +90,11 @@ const gradientForCategory = (category: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 格式化时长 */
|
/** 格式化时长 */
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number | undefined | null): string => {
|
||||||
if (seconds <= 0) return "0秒";
|
if (!seconds || seconds <= 0) return "0秒";
|
||||||
const m = Math.floor(seconds / 60);
|
const totalSec = Math.round(seconds);
|
||||||
const s = seconds % 60;
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
if (m === 0) return `${s}秒`;
|
if (m === 0) return `${s}秒`;
|
||||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||||
};
|
};
|
||||||
@@ -237,7 +237,9 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
{
|
{
|
||||||
key: "duration",
|
key: "duration",
|
||||||
label: "目标时长",
|
label: "目标时长",
|
||||||
children: formatDuration(template.target_duration),
|
children: formatDuration(
|
||||||
|
template.estimated_duration ?? template.target_duration,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "clips",
|
key: "clips",
|
||||||
@@ -409,7 +411,9 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
<div className="xx-template-thumb-name">{template.name}</div>
|
<div className="xx-template-thumb-name">{template.name}</div>
|
||||||
<div className="xx-template-thumb-meta">
|
<div className="xx-template-thumb-meta">
|
||||||
<span className="xx-template-thumb-duration">
|
<span className="xx-template-thumb-duration">
|
||||||
{formatDuration(template.target_duration)}
|
{formatDuration(
|
||||||
|
template.estimated_duration ?? template.target_duration,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||||
@@ -537,19 +541,6 @@ const TemplateLibrary: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 从模板生成剪辑计划 mutation ──
|
|
||||||
const generateMutation = useMutation({
|
|
||||||
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
|
|
||||||
generateFromTemplate(templateId, { name }),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
message.success(`剪辑计划「${data.name}」已创建`);
|
|
||||||
navigate("/app/edit-plans");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error("生成剪辑计划失败,请稍后重试");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 切换收藏 */
|
/** 切换收藏 */
|
||||||
const toggleFavorite = useCallback(
|
const toggleFavorite = useCallback(
|
||||||
(id: string, e?: React.MouseEvent) => {
|
(id: string, e?: React.MouseEvent) => {
|
||||||
@@ -582,15 +573,12 @@ const TemplateLibrary: React.FC = () => {
|
|||||||
[copyMutation],
|
[copyMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 使用模板 → 生成剪辑计划 */
|
/** 使用模板 → 进入剪辑编辑器配置 */
|
||||||
const handleUse = useCallback(
|
const handleUse = useCallback(
|
||||||
(template: TemplateItem) => {
|
(template: TemplateItem) => {
|
||||||
generateMutation.mutate({
|
navigate(`/app/editing-planner?templateId=${template.id}`);
|
||||||
templateId: template.id,
|
|
||||||
name: `基于「${template.name}」的剪辑计划`,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[generateMutation, navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 搜索防抖处理 */
|
/** 搜索防抖处理 */
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ class PiPEngine:
|
|||||||
input_args: list[str] = []
|
input_args: list[str] = []
|
||||||
current_label = base_label
|
current_label = base_label
|
||||||
|
|
||||||
for i, (input_label, layer, path) in enumerate(pip_sources):
|
for i, (_input_label, layer, path) in enumerate(pip_sources):
|
||||||
# 添加输入
|
# 添加输入
|
||||||
input_args.extend(["-i", str(path)])
|
input_args.extend(["-i", str(path)])
|
||||||
|
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ class StickerEngine:
|
|||||||
image_stickers: list[ImageStickerConfig] = []
|
image_stickers: list[ImageStickerConfig] = []
|
||||||
image_paths: list[str] = []
|
image_paths: list[str] = []
|
||||||
|
|
||||||
for i, s in enumerate(stickers):
|
for _, s in enumerate(stickers):
|
||||||
try:
|
try:
|
||||||
sticker_type = s.get("type", "image")
|
sticker_type = s.get("type", "image")
|
||||||
z = int(s.get("z_index", 10))
|
z = int(s.get("z_index", 10))
|
||||||
|
|||||||
@@ -168,11 +168,13 @@ def _finalize_render_success(
|
|||||||
clip.mark_rendered()
|
clip.mark_rendered()
|
||||||
clip_repo.update(clip)
|
clip_repo.update(clip)
|
||||||
|
|
||||||
# 更新 EditPlan 状态为 completed + 回写实际渲染时长
|
# 更新 EditPlan 状态为 completed + 回写实际渲染时长 + 结果数
|
||||||
plan.config["rendered_url"] = output_url or ""
|
plan.config["rendered_url"] = output_url or ""
|
||||||
plan.config["rendered_storage_key"] = storage_key
|
plan.config["rendered_storage_key"] = storage_key
|
||||||
if hasattr(plan, "total_duration") and duration > 0:
|
if hasattr(plan, "total_duration") and duration > 0:
|
||||||
plan.total_duration = duration
|
plan.total_duration = duration
|
||||||
|
if hasattr(plan, "result_count"):
|
||||||
|
plan.result_count = 1
|
||||||
plan.mark_completed()
|
plan.mark_completed()
|
||||||
plan_repo.update(plan)
|
plan_repo.update(plan)
|
||||||
|
|
||||||
|
|||||||
@@ -990,6 +990,14 @@
|
|||||||
"type": "FLOAT",
|
"type": "FLOAT",
|
||||||
"unique": false
|
"unique": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"index": false,
|
||||||
|
"name": "result_count",
|
||||||
|
"nullable": false,
|
||||||
|
"primary_key": false,
|
||||||
|
"type": "INTEGER",
|
||||||
|
"unique": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"index": false,
|
"index": false,
|
||||||
"name": "config",
|
"name": "config",
|
||||||
|
|||||||
Regular → Executable
+3
@@ -100,6 +100,7 @@ class SQLAlchemyEditPlanRepository:
|
|||||||
name=plan.name,
|
name=plan.name,
|
||||||
status=plan.status,
|
status=plan.status,
|
||||||
total_duration=plan.total_duration,
|
total_duration=plan.total_duration,
|
||||||
|
result_count=plan.result_count,
|
||||||
source_edit_plan_id=plan.source_edit_plan_id or None,
|
source_edit_plan_id=plan.source_edit_plan_id or None,
|
||||||
project_id=plan.project_id or "",
|
project_id=plan.project_id or "",
|
||||||
created_by_user_id=plan.created_by_user_id or "",
|
created_by_user_id=plan.created_by_user_id or "",
|
||||||
@@ -119,6 +120,7 @@ class SQLAlchemyEditPlanRepository:
|
|||||||
model.name = plan.name
|
model.name = plan.name
|
||||||
model.status = plan.status
|
model.status = plan.status
|
||||||
model.total_duration = plan.total_duration
|
model.total_duration = plan.total_duration
|
||||||
|
model.result_count = plan.result_count
|
||||||
model.source_edit_plan_id = plan.source_edit_plan_id or None
|
model.source_edit_plan_id = plan.source_edit_plan_id or None
|
||||||
model.project_id = plan.project_id or ""
|
model.project_id = plan.project_id or ""
|
||||||
model.created_by_user_id = plan.created_by_user_id or ""
|
model.created_by_user_id = plan.created_by_user_id or ""
|
||||||
@@ -152,6 +154,7 @@ class SQLAlchemyEditPlanRepository:
|
|||||||
name=model.name,
|
name=model.name,
|
||||||
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
|
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
|
||||||
total_duration=model.total_duration or 0.0,
|
total_duration=model.total_duration or 0.0,
|
||||||
|
result_count=int(model.result_count or 0),
|
||||||
source_edit_plan_id=model.source_edit_plan_id or "",
|
source_edit_plan_id=model.source_edit_plan_id or "",
|
||||||
project_id=model.project_id or "",
|
project_id=model.project_id or "",
|
||||||
created_by_user_id=model.created_by_user_id or "",
|
created_by_user_id=model.created_by_user_id or "",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
|
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
Base = declarative_base()
|
Base: Any = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
class UserModel(Base):
|
class UserModel(Base):
|
||||||
@@ -148,6 +149,7 @@ class EditPlanModel(Base):
|
|||||||
name = Column(String(200), nullable=False)
|
name = Column(String(200), nullable=False)
|
||||||
status = Column(String(20), nullable=False, default="draft", index=True)
|
status = Column(String(20), nullable=False, default="draft", index=True)
|
||||||
total_duration = Column(Float, nullable=False, default=0.0)
|
total_duration = Column(Float, nullable=False, default=0.0)
|
||||||
|
result_count = Column(Integer, nullable=False, default=0)
|
||||||
config = Column(JSON, nullable=False, default=dict)
|
config = Column(JSON, nullable=False, default=dict)
|
||||||
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
||||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||||
|
|||||||
Regular → Executable
+3
@@ -42,6 +42,7 @@ class EditPlan:
|
|||||||
name: str
|
name: str
|
||||||
status: EditPlanStatus = EditPlanStatus.DRAFT
|
status: EditPlanStatus = EditPlanStatus.DRAFT
|
||||||
total_duration: float = 0.0
|
total_duration: float = 0.0
|
||||||
|
result_count: int = 0
|
||||||
source_edit_plan_id: str = ""
|
source_edit_plan_id: str = ""
|
||||||
project_id: str = ""
|
project_id: str = ""
|
||||||
created_by_user_id: str = ""
|
created_by_user_id: str = ""
|
||||||
@@ -57,6 +58,7 @@ class EditPlan:
|
|||||||
*,
|
*,
|
||||||
config: dict[str, Any] | None = None,
|
config: dict[str, Any] | None = None,
|
||||||
total_duration: float = 0.0,
|
total_duration: float = 0.0,
|
||||||
|
result_count: int = 0,
|
||||||
source_edit_plan_id: str = "",
|
source_edit_plan_id: str = "",
|
||||||
project_id: str = "",
|
project_id: str = "",
|
||||||
created_by_user_id: str = "",
|
created_by_user_id: str = "",
|
||||||
@@ -73,6 +75,7 @@ class EditPlan:
|
|||||||
name=clean_name,
|
name=clean_name,
|
||||||
status=EditPlanStatus.DRAFT,
|
status=EditPlanStatus.DRAFT,
|
||||||
total_duration=total_duration,
|
total_duration=total_duration,
|
||||||
|
result_count=result_count,
|
||||||
source_edit_plan_id=source_edit_plan_id.strip(),
|
source_edit_plan_id=source_edit_plan_id.strip(),
|
||||||
project_id=project_id.strip(),
|
project_id=project_id.strip(),
|
||||||
created_by_user_id=created_by_user_id.strip(),
|
created_by_user_id=created_by_user_id.strip(),
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ select = [
|
|||||||
"E", # pycodestyle errors(同 flake8)
|
"E", # pycodestyle errors(同 flake8)
|
||||||
"F", # pyflakes(同 flake8)
|
"F", # pyflakes(同 flake8)
|
||||||
"W", # pycodestyle warnings(同 flake8)
|
"W", # pycodestyle warnings(同 flake8)
|
||||||
|
"B", # flake8-bugbear(P0-5 Step 2 已完成修复)
|
||||||
]
|
]
|
||||||
# 与原 setup.cfg + .flake8 的 flake8 配置完全对齐
|
# 与原 setup.cfg + .flake8 的 flake8 配置完全对齐
|
||||||
# 注意:W503 在 ruff≥0.14 中已被移除(行为变默认),故不列入
|
# 注意:W503 在 ruff≥0.14 中已被移除(行为变默认),故不列入
|
||||||
@@ -86,6 +87,7 @@ ignore = [
|
|||||||
"E722", # bare-except
|
"E722", # bare-except
|
||||||
"W291",
|
"W291",
|
||||||
"W293",
|
"W293",
|
||||||
|
"B008", # function-call-in-default-argument(FastAPI 依赖注入模式,大量使用)
|
||||||
"F401", # unused-import
|
"F401", # unused-import
|
||||||
"F403",
|
"F403",
|
||||||
"F405",
|
"F405",
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ fi
|
|||||||
# 默认只读不写,防止 feature 分支污染主缓存
|
# 默认只读不写,防止 feature 分支污染主缓存
|
||||||
# 只有 develop/main 分支才写回缓存
|
# 只有 develop/main 分支才写回缓存
|
||||||
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
|
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
|
||||||
|
# 清理本地旧镜像
|
||||||
|
docker rmi -f "$API_IMAGE" "$API_LATEST" 2>/dev/null || true
|
||||||
|
|
||||||
if [ "$USE_CACHE" -eq 1 ]; then
|
if [ "$USE_CACHE" -eq 1 ]; then
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg APP_VERSION="$VERSION" \
|
--build-arg APP_VERSION="$VERSION" \
|
||||||
@@ -89,6 +92,9 @@ build_with_cache() {
|
|||||||
echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}"
|
echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 清理本地旧镜像,避免 buildx --load 报 already exists 错误
|
||||||
|
docker rmi -f "$IMG_NAME:$VERSION" 2>/dev/null || true
|
||||||
|
|
||||||
if [ "$USE_CACHE" -eq 1 ]; then
|
if [ "$USE_CACHE" -eq 1 ]; then
|
||||||
if [ -n "$CACHE_TO" ]; then
|
if [ -n "$CACHE_TO" ]; then
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
@@ -119,6 +125,9 @@ build_with_cache "api" "infra/docker/api.Dockerfile" \
|
|||||||
docker tag "$API_IMAGE" "$API_LATEST"
|
docker tag "$API_IMAGE" "$API_LATEST"
|
||||||
|
|
||||||
echo "=== Building Worker image ==="
|
echo "=== Building Worker image ==="
|
||||||
|
# 清理本地旧镜像
|
||||||
|
docker rmi -f "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
|
||||||
|
|
||||||
if [ "$USE_CACHE" -eq 1 ]; then
|
if [ "$USE_CACHE" -eq 1 ]; then
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg APP_VERSION="$VERSION" \
|
--build-arg APP_VERSION="$VERSION" \
|
||||||
@@ -148,6 +157,9 @@ docker run --rm \
|
|||||||
|
|
||||||
test -f apps/web/dist/index.html
|
test -f apps/web/dist/index.html
|
||||||
|
|
||||||
|
# 清理本地旧镜像
|
||||||
|
docker rmi -f "$WEB_IMAGE" 2>/dev/null || true
|
||||||
|
|
||||||
if [ "$USE_CACHE" -eq 1 ]; then
|
if [ "$USE_CACHE" -eq 1 ]; then
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
||||||
|
|||||||
@@ -30,10 +30,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
@@ -99,30 +102,37 @@ def extract_upgrade_content(content: str) -> str:
|
|||||||
|
|
||||||
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
|
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
|
||||||
"""
|
"""
|
||||||
通过 git diff 对比目标分支/commit,找出 alembic/versions/ 下新增的迁移文件。
|
通过 Gitea API 对比目标分支,找出 alembic/versions/ 下新增的迁移文件。
|
||||||
只包含新增文件(A状态),不包含修改或删除的文件。
|
不依赖本地 git,避免 CI 环境下 git 操作不稳定的问题。
|
||||||
"""
|
"""
|
||||||
|
api_url = os.environ.get("GITHUB_API_URL", "")
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
token = os.environ.get("GITHUB_TOKEN", "")
|
||||||
|
branch = diff_target.replace("origin/", "")
|
||||||
|
|
||||||
|
if not api_url or not repo or not token:
|
||||||
|
print("⚠️ CI 环境变量不完整,降级为检查所有迁移文件")
|
||||||
|
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
url = f"{api_url}/repos/{repo}/contents/alembic/versions?ref={branch}"
|
||||||
[
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
"git",
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
"diff",
|
data = json.loads(resp.read().decode())
|
||||||
"--name-only",
|
|
||||||
"--diff-filter=A",
|
remote_files = {item["name"] for item in data if item["name"].endswith(".py")}
|
||||||
diff_target,
|
local_files = {f.name for f in ALEMBIC_VERSIONS_DIR.glob("*.py")}
|
||||||
"HEAD",
|
new_file_names = sorted(local_files - remote_files)
|
||||||
"--",
|
|
||||||
"alembic/versions/",
|
if new_file_names:
|
||||||
],
|
result = [ALEMBIC_VERSIONS_DIR / f for f in new_file_names]
|
||||||
cwd=str(REPO_ROOT),
|
print(f" (API 对比 {branch} 分支,发现 {len(result)} 个新增迁移)")
|
||||||
capture_output=True,
|
return result
|
||||||
text=True,
|
else:
|
||||||
check=True,
|
print(f" (API 对比 {branch} 分支,无新增迁移)")
|
||||||
)
|
return []
|
||||||
files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
except Exception as e:
|
||||||
return [REPO_ROOT / f for f in files]
|
print(f"⚠️ API 获取迁移列表失败:{e}")
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
|
|
||||||
print(" 降级为检查所有迁移文件")
|
print(" 降级为检查所有迁移文件")
|
||||||
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
||||||
|
|
||||||
|
|||||||
@@ -54,38 +54,38 @@ echo ""
|
|||||||
echo "Image pushed: ${IMAGE_TAG}"
|
echo "Image pushed: ${IMAGE_TAG}"
|
||||||
echo "Local cache updated"
|
echo "Local cache updated"
|
||||||
|
|
||||||
echo ""
|
# DISABLED: registry cache too slow echo ""
|
||||||
echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
# DISABLED: registry cache too slow echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
||||||
CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
# DISABLED: registry cache too slow CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
||||||
|
# DISABLED: registry cache too slow
|
||||||
MAX_RETRIES=3
|
# DISABLED: registry cache too slow MAX_RETRIES=3
|
||||||
SUCCESS=0
|
# DISABLED: registry cache too slow SUCCESS=0
|
||||||
for attempt in $(seq 1 $MAX_RETRIES); do
|
# DISABLED: registry cache too slow for attempt in $(seq 1 $MAX_RETRIES); do
|
||||||
echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
# DISABLED: registry cache too slow echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
||||||
if docker buildx build \
|
# DISABLED: registry cache too slow if docker buildx build \
|
||||||
$BUILD_ARGS \
|
# DISABLED: registry cache too slow $BUILD_ARGS \
|
||||||
--cache-from "${CACHE_FROM_LOCAL}" \
|
# DISABLED: registry cache too slow --cache-from "${CACHE_FROM_LOCAL}" \
|
||||||
--cache-to "${CACHE_TO_REGISTRY}" \
|
# DISABLED: registry cache too slow --cache-to "${CACHE_TO_REGISTRY}" \
|
||||||
-f "${DOCKERFILE}" \
|
# DISABLED: registry cache too slow -f "${DOCKERFILE}" \
|
||||||
-t "${IMAGE_TAG}" \
|
# DISABLED: registry cache too slow -t "${IMAGE_TAG}" \
|
||||||
--push \
|
# DISABLED: registry cache too slow --push \
|
||||||
.; then
|
# DISABLED: registry cache too slow .; then
|
||||||
echo "Registry cache synced (attempt $attempt)"
|
# DISABLED: registry cache too slow echo "Registry cache synced (attempt $attempt)"
|
||||||
SUCCESS=1
|
# DISABLED: registry cache too slow SUCCESS=1
|
||||||
break
|
# DISABLED: registry cache too slow break
|
||||||
else
|
# DISABLED: registry cache too slow else
|
||||||
echo "Registry cache sync failed (attempt $attempt)"
|
# DISABLED: registry cache too slow echo "Registry cache sync failed (attempt $attempt)"
|
||||||
if [ $attempt -lt $MAX_RETRIES ]; then
|
# DISABLED: registry cache too slow if [ $attempt -lt $MAX_RETRIES ]; then
|
||||||
WAIT=$((attempt * 5))
|
# DISABLED: registry cache too slow WAIT=$((attempt * 5))
|
||||||
echo "Retrying in ${WAIT}s..."
|
# DISABLED: registry cache too slow echo "Retrying in ${WAIT}s..."
|
||||||
sleep $WAIT
|
# DISABLED: registry cache too slow sleep $WAIT
|
||||||
fi
|
# DISABLED: registry cache too slow fi
|
||||||
fi
|
# DISABLED: registry cache too slow fi
|
||||||
done
|
# DISABLED: registry cache too slow done
|
||||||
|
# DISABLED: registry cache too slow
|
||||||
if [ $SUCCESS -eq 0 ]; then
|
# DISABLED: registry cache too slow if [ $SUCCESS -eq 0 ]; then
|
||||||
echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
# DISABLED: registry cache too slow echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
||||||
fi
|
# DISABLED: registry cache too slow fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Build completed: ${IMAGE_TAG}"
|
echo "Build completed: ${IMAGE_TAG}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# 环境变量:
|
# 环境变量:
|
||||||
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
|
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
|
||||||
# REGISTRY_TOKEN - Registry 访问令牌
|
# REGISTRY_TOKEN - Registry 访问令牌
|
||||||
# REGISTRY - Registry 地址(默认 git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas)
|
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji)
|
||||||
# REGISTRY_USER - Registry 用户名(默认 xiaoxia)
|
# REGISTRY_USER - Registry 用户名(默认 xiaoxia)
|
||||||
# ENV_FILE - 环境变量文件路径
|
# ENV_FILE - 环境变量文件路径
|
||||||
# GENERATED_DIR - 生成文件目录
|
# GENERATED_DIR - 生成文件目录
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
|
||||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||||
|
|
||||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class PerfAssert:
|
|||||||
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
||||||
|
|
||||||
last_response = None
|
last_response = None
|
||||||
for i in range(num_samples):
|
for _ in range(num_samples):
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
last_response = func()
|
last_response = func()
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
elapsed = (time.perf_counter() - start) * 1000
|
||||||
@@ -261,7 +261,7 @@ def run_perf_test(
|
|||||||
result = PerfResult(name=name, threshold_ms=threshold_ms)
|
result = PerfResult(name=name, threshold_ms=threshold_ms)
|
||||||
|
|
||||||
last_response = None
|
last_response = None
|
||||||
for i in range(samples):
|
for _ in range(samples):
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
last_response = func()
|
last_response = func()
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
elapsed = (time.perf_counter() - start) * 1000
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ class TestLargeDataRequests:
|
|||||||
def test_rapid_sequential_requests(self, auth_headers):
|
def test_rapid_sequential_requests(self, auth_headers):
|
||||||
"""快速连续请求不应触发限流导致 500。"""
|
"""快速连续请求不应触发限流导致 500。"""
|
||||||
statuses = []
|
statuses = []
|
||||||
for i in range(20):
|
for _ in range(20):
|
||||||
resp = client.get("/api/v1/projects", headers=auth_headers)
|
resp = client.get("/api/v1/projects", headers=auth_headers)
|
||||||
statuses.append(resp.status_code)
|
statuses.append(resp.status_code)
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TestEditTemplate:
|
|||||||
def test_create_empty_name_raises(self):
|
def test_create_empty_name_raises(self):
|
||||||
try:
|
try:
|
||||||
EditTemplate.create(" ")
|
EditTemplate.create(" ")
|
||||||
assert False, "应该抛出 ValueError"
|
raise AssertionError("应该抛出 ValueError")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
assert "模板名称不能为空" in str(e)
|
assert "模板名称不能为空" in str(e)
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ class TestEditPlan:
|
|||||||
def test_create_empty_name_raises(self):
|
def test_create_empty_name_raises(self):
|
||||||
try:
|
try:
|
||||||
EditPlan.create("tpl-1", " ")
|
EditPlan.create("tpl-1", " ")
|
||||||
assert False, "应该抛出 ValueError"
|
raise AssertionError("应该抛出 ValueError")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
assert "计划名称不能为空" in str(e)
|
assert "计划名称不能为空" in str(e)
|
||||||
|
|
||||||
def test_create_empty_template_id_raises(self):
|
def test_create_empty_template_id_raises(self):
|
||||||
try:
|
try:
|
||||||
EditPlan.create(" ", "test")
|
EditPlan.create(" ", "test")
|
||||||
assert False, "应该抛出 ValueError"
|
raise AssertionError("应该抛出 ValueError")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
assert "template_id 不能为空" in str(e)
|
assert "template_id 不能为空" in str(e)
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ class TestEditPlan:
|
|||||||
p = EditPlan.create("tpl-1", "test")
|
p = EditPlan.create("tpl-1", "test")
|
||||||
try:
|
try:
|
||||||
p.start_rendering() # draft → rendering 不合法
|
p.start_rendering() # draft → rendering 不合法
|
||||||
assert False, "应该抛出 ValueError"
|
raise AssertionError("应该抛出 ValueError")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class TestEditPlan:
|
|||||||
p = EditPlan.create("tpl-1", "test")
|
p = EditPlan.create("tpl-1", "test")
|
||||||
try:
|
try:
|
||||||
p.mark_completed() # draft → completed 不合法
|
p.mark_completed() # draft → completed 不合法
|
||||||
assert False, "应该抛出 ValueError"
|
raise AssertionError("应该抛出 ValueError")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ class TestEditPlan:
|
|||||||
p = EditPlan.create("tpl-1", "test")
|
p = EditPlan.create("tpl-1", "test")
|
||||||
try:
|
try:
|
||||||
p.reset_to_draft() # draft → draft 不合法
|
p.reset_to_draft() # draft → draft 不合法
|
||||||
assert False, "应该抛出 ValueError"
|
raise AssertionError("应该抛出 ValueError")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ class TestConcatSecurity:
|
|||||||
|
|
||||||
# 创建超过上限的段数
|
# 创建超过上限的段数
|
||||||
segments = []
|
segments = []
|
||||||
for i in range(MAX_CONCAT_SEGMENTS + 5):
|
for _ in range(MAX_CONCAT_SEGMENTS + 5):
|
||||||
segments.append(ConcatSegment(video_path=str(sample_video)))
|
segments.append(ConcatSegment(video_path=str(sample_video)))
|
||||||
|
|
||||||
config = ConcatConfig(segments=segments)
|
config = ConcatConfig(segments=segments)
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class TestAudioMerger:
|
|||||||
|
|
||||||
# 创建临时文件
|
# 创建临时文件
|
||||||
paths = []
|
paths = []
|
||||||
for i in range(3):
|
for _ in range(3):
|
||||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||||
f.write(b"audio")
|
f.write(b"audio")
|
||||||
paths.append(f.name)
|
paths.append(f.name)
|
||||||
@@ -151,7 +151,7 @@ class TestAudioMerger:
|
|||||||
mock_run_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error details")
|
mock_run_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error details")
|
||||||
|
|
||||||
paths = []
|
paths = []
|
||||||
for i in range(2):
|
for _ in range(2):
|
||||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||||
f.write(b"audio")
|
f.write(b"audio")
|
||||||
paths.append(f.name)
|
paths.append(f.name)
|
||||||
|
|||||||
Reference in New Issue
Block a user