Merge remote-tracking branch 'origin/develop' into ci/matrix-build-parallelism
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1513h18m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1513h18m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1513h18m45s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1513h18m45s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h19m10s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h19m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h19m10s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1513h19m14s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1513h19m20s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1513h19m14s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1513h19m21s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1513h19m22s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1513h19m22s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h20m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1513h20m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1513h20m3s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h20m3s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1513h50m25s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1513h50m27s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1513h51m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h50m54s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1513h51m45s

This commit is contained in:
XiaoXia Bot
2026-07-15 14:08:16 +08:00
6 changed files with 457 additions and 110 deletions
@@ -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")
+1
View File
@@ -1,5 +1,6 @@
/**
* 成品 / 视频相关 API
* 包含:列表查询、复核状态、批量下载
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
*/
import apiClient from "./client";
+9 -4
View File
@@ -16,17 +16,22 @@ import type { EditPlanConfig } from "./editPlans";
/** 模板条目(后端 TemplateResponse) */
export interface TemplateItem {
id: string;
user_id?: string;
name: string;
description: string;
description?: string;
mode?: string;
category: string;
tags?: string[];
target_duration: number;
clip_count: number;
/** 预估时长(后端字段名 estimated_duration) */
estimated_duration?: number;
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number;
clip_count?: number;
/** 使用次数 */
usage_count?: number;
thumbnail_url?: string;
preview_url?: string;
is_active: boolean;
is_active?: boolean;
is_favorite?: boolean;
/** 素材规则(片段配置) */
segments?: TemplateSegment[];
@@ -3,8 +3,8 @@
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*/
import React, { useState, useCallback, useEffect, useRef } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { message } from "antd";
import { useSearchParams } from "react-router-dom";
import { message, Modal, Progress, Button } from "antd";
import { useQuery } from "@tanstack/react-query";
import type {
EditingTemplate,
@@ -20,11 +20,23 @@ import {
getTemplateCategories,
MODE_LABELS,
} from "@/api/editingPlanner";
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
import type {
EditPlanGeneration,
EditPlanConfig,
GeneratedVideo,
MediaAsset,
TransitionEffect,
} from "@/api/editPlans";
import {
getMediaAssets,
getEditPlanGenerations,
generateCover,
getEditPlan,
createEditPlan,
updateEditPlan,
generateEditPlan,
getGenerationStatus,
getGenerationTaskResults,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type {
@@ -33,6 +45,7 @@ import type {
TransitionConfig,
SpeedConfig,
TtsConfig,
TtsMode,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
@@ -107,8 +120,8 @@ const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"];
const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const urlTemplateId = searchParams.get("templateId") || "";
const urlPlanId = searchParams.get("planId") || "";
/* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([]);
@@ -244,6 +257,21 @@ const EditingPlanner: React.FC = () => {
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
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 [currentTime, setCurrentTime] = useState(0);
@@ -374,6 +402,85 @@ const EditingPlanner: React.FC = () => {
.catch(() => message.error("加载模板详情失败"));
}, [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);
@@ -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 = () => {
setSaveModalOpen(true);
@@ -763,94 +929,139 @@ const EditingPlanner: React.FC = () => {
};
/**
* 跳转到一键生成页面
* 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfig(JSON 序列化)
* 一键生成页面从 params 解析配置,无需重复请求接口
* 剪辑计划生成
* 1. 有 planId → 更新计划配置 + 触发生成
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成
* 3. 触发生成后轮询状态,完成后获取视频结果
*/
const handleGoToGenerate = () => {
const planConfig = {
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,
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
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,
},
mode: currentMode,
total_duration: totalDuration,
segments: clips.map((c, i) => ({
order: i,
material_type: c.type === "voice" ? "voiceover" : "video",
duration: c.duration,
template_segment_id: c.template_segment_id,
script_text: c.script_text,
voice_asset_id: c.voice_asset_id,
voice_file_url: c.voice_file_url,
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 params = new URLSearchParams();
if (loadedTemplateId) {
params.set("edit_plan_id", loadedTemplateId);
const handleGoToGenerate = async () => {
if (!loadedTemplateId) {
message.warning("请先选择一个模板");
return;
}
if (clips.length === 0) {
message.warning("请先添加片段");
return;
}
setGenerating(true);
setGenerated(false);
setGeneratedVideos([]);
setGenError(null);
setGenProgress(0);
try {
const config = buildPlanConfig();
let planId = loadedPlanId;
if (planId) {
// 已有计划 → 更新配置
await updateEditPlan(planId, {
config,
total_duration: totalDuration,
status: "editing",
});
} else {
// 无计划 → 创建新计划
const plan = await createEditPlan({
template_id: loadedTemplateId,
name: draftName || "未命名计划",
config,
total_duration: totalDuration,
});
planId = plan.id;
setLoadedPlanId(planId);
// 更新 URL 参数(不刷新页面)
const params = new URLSearchParams(window.location.search);
params.set("planId", planId);
window.history.replaceState(null, "", `?${params.toString()}`);
}
// 触发生成
const genRes = await generateEditPlan(planId);
setGenTotalClips(genRes.clip_count);
message.info("已提交生成,等待处理...");
// 开始轮询
startPolling(planId);
} catch (err) {
console.error("[生成失败]", err);
setGenError("生成提交失败,请重试");
setGenerating(false);
}
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 () => {
if (!loadedTemplateId) {
message.warning("请先加载一个模板");
const targetId = loadedPlanId || loadedTemplateId;
if (!targetId) {
message.warning("请先加载一个模板或计划");
return;
}
setGenHistoryOpen(true);
setGenHistoryLoading(true);
try {
const items = await getEditPlanGenerations(loadedTemplateId);
const items = await getEditPlanGenerations(targetId);
setGenHistory(items);
} catch {
message.error("加载生成历史失败");
@@ -899,8 +1110,9 @@ const EditingPlanner: React.FC = () => {
<button
className="ep-btn ep-btn-primary"
onClick={handleGoToGenerate}
disabled={generating}
>
🎬 使用此模板生成
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"}
</button>
</div>
</div>
@@ -1065,6 +1277,111 @@ const EditingPlanner: React.FC = () => {
onClose={() => setGenHistoryOpen(false)}
/>
{/* ═══ 生成进度弹窗 ═══ */}
<Modal
title={generated ? "生成完成" : "正在生成视频"}
open={generating || generated}
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 ═══ */}
<BgmSelector
open={bgmDrawerOpen}
@@ -23,7 +23,6 @@ import {
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
type TemplateItem,
type TemplateListParams,
type TemplateSegment,
@@ -91,8 +90,8 @@ const gradientForCategory = (category: string): string => {
};
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "0秒";
const formatDuration = (seconds: number | undefined | null): string => {
if (!seconds || seconds <= 0) return "0秒";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}秒`;
@@ -237,7 +236,9 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
{
key: "duration",
label: "目标时长",
children: formatDuration(template.target_duration),
children: formatDuration(
template.estimated_duration ?? template.target_duration,
),
},
{
key: "clips",
@@ -409,7 +410,9 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(template.target_duration)}
{formatDuration(
template.estimated_duration ?? template.target_duration,
)}
</span>
</div>
<div className="xx-template-preview-hint">点击查看详情</div>
@@ -537,19 +540,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(
(id: string, e?: React.MouseEvent) => {
@@ -582,15 +572,12 @@ const TemplateLibrary: React.FC = () => {
[copyMutation],
);
/** 使用模板 → 生成剪辑计划 */
/** 使用模板 → 进入剪辑编辑器配置 */
const handleUse = useCallback(
(template: TemplateItem) => {
generateMutation.mutate({
templateId: template.id,
name: `基于「${template.name}」的剪辑计划`,
});
navigate(`/app/editing-planner?templateId=${template.id}`);
},
[generateMutation, navigate],
[navigate],
);
/** 搜索防抖处理 */
+8
View File
@@ -990,6 +990,14 @@
"type": "FLOAT",
"unique": false
},
{
"index": false,
"name": "result_count",
"nullable": false,
"primary_key": false,
"type": "INTEGER",
"unique": false
},
{
"index": false,
"name": "config",