diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 7e8c2a886..4c178d700 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -15,8 +15,6 @@ import { LoadingOutlined, PlayCircleOutlined, PauseCircleOutlined, - DownloadOutlined, - ShareAltOutlined, SaveOutlined, PlusOutlined, MinusOutlined, @@ -44,65 +42,27 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" import { getTags, createTag } from "@/api/tags" import { useCloneProgress } from "@/hooks/useCloneProgress" import { useSearchParams, useNavigate } from "react-router-dom" +import GenerateHeader from "./components/GenerateHeader" +import GenerateStepsBar from "./components/GenerateStepsBar" +import GenerateResultPanel from "./components/GenerateResultPanel" +import { + CLONE_STATUS_CONFIG, + MODE_GRADIENTS, + VOICE_GENDER_ICON, + POSITION_OPTIONS, + FONT_OPTIONS, + TITLE_PRESETS, + COVER_MODE_LABELS, + COVER_MODE_ICONS, + DEFAULT_COVER_SETTINGS, + SMART_MATCH_REASONS, + AI_TITLE_TEMPLATES, +} from "./constants" +import type { TitleSettings } from "./types" import "./generate.css" const { Text } = Typography -/* ── 克隆声音状态配置 ── */ -const CLONE_STATUS_CONFIG: Record = { - ready: { label: "就绪", color: "var(--secondary-color, #10b981)" }, - processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" }, - failed: { label: "失败", color: "var(--error-color, #ef4444)" }, -} - -/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */ -const MODE_GRADIENTS: Record = { - pip: "linear-gradient(135deg, #fbbf24, #f59e0b)", - one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)", - voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)", - voice_pip: "linear-gradient(135deg, #10b981, #059669)", -} -/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */ -const VOICE_GENDER_ICON: Record = { - female: "🎀", - male: "🎙️", - child: "🧒", - neutral: "✨", -} - -/* ── 步骤定义 ── */ -const STEPS = [ - { key: 1, label: "选择模板" }, - { key: 2, label: "选择素材" }, - { key: 3, label: "生成预览" }, - { key: 4, label: "选择标题" }, - { key: 5, label: "选择配音" }, - { key: 6, label: "选择封面" }, - { key: 7, label: "确认生成" }, -] - -/* ── 标题设置常量 ── */ -const POSITION_OPTIONS = [ - { value: "top", label: "顶部" }, - { value: "center", label: "居中" }, - { value: "bottom", label: "底部" }, -] - -const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"] - -interface TitleSettings { - aiAutoSelect: boolean - title: string - position: string - font: string - size: number - bold: boolean - italic: boolean - stroke: boolean - shadow: boolean - color: string -} - const DEFAULT_TITLE_SETTINGS: TitleSettings = { aiAutoSelect: false, title: "", @@ -116,110 +76,6 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = { color: "#ffffff", } -const TITLE_PRESETS = [ - { - key: "classic_white", - label: "经典白字", - style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false }, - previewStyle: { - fontWeight: 700, - color: "#ffffff", - WebkitTextStroke: "1px #000000", - fontSize: "20px", - }, - }, - { - key: "black_gold", - label: "黑金质感", - style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true }, - previewStyle: { - fontWeight: 700, - color: "#d4a843", - textShadow: "1px 1px 3px rgba(0,0,0,0.8)", - fontSize: "20px", - }, - }, - { - key: "fresh_minimal", - label: "清新简约", - style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false }, - previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" }, - }, - { - key: "variety_show", - label: "综艺花字", - style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true }, - previewStyle: { - fontWeight: 900, - color: "#ff4081", - WebkitTextStroke: "1.5px #ffffff", - textShadow: "2px 2px 4px rgba(0,0,0,0.5)", - fontSize: "22px", - }, - }, - { - key: "business", - label: "商务极简", - style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false }, - previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" }, - }, - { - key: "retro_film", - label: "复古胶片", - style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true }, - previewStyle: { - fontWeight: 400, - color: "#e8d5b7", - textShadow: "2px 2px 6px rgba(0,0,0,0.7)", - fontSize: "18px", - }, - }, - { - key: "neon_glow", - label: "霓虹发光", - style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true }, - previewStyle: { - fontWeight: 700, - color: "#00e5ff", - textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)", - fontSize: "20px", - }, - }, - { - key: "handwriting", - label: "手写字", - style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true }, - previewStyle: { - fontWeight: 400, - color: "#333333", - textShadow: "1px 1px 2px rgba(0,0,0,0.3)", - fontSize: "20px", - }, - }, -] - -/* ── 封面设置常量 ── */ -const COVER_MODE_LABELS: Record = { - auto: "智能封面", - frame: "抽帧选封面", - upload: "上传封面", -} - -const COVER_MODE_ICONS: Record = { - auto: "🤖", - frame: "🎞️", - upload: "📤", -} - -const DEFAULT_COVER_SETTINGS: CoverConfig = { - enabled: true, - mode: "auto", - frame_time: 0, - upload_url: "", - ai_suggested_time: null, - thumbnail_url: "", -} - function getActivePreset(settings: TitleSettings): string | null { for (const p of TITLE_PRESETS) { if ( @@ -236,45 +92,6 @@ function getActivePreset(settings: TitleSettings): string | null { return null } -/* ================================================================ - 常量 - ================================================================ */ - -const SMART_MATCH_REASONS = [ - "画面清晰度高,构图专业", - "与描述场景高度契合", - "时长适中,适合剪辑节奏", - "色彩风格统一", - "包含关键动作镜头", - "镜头运动流畅自然", - "光影效果出色", - "人物表情生动", -] - -const AI_TITLE_TEMPLATES: Record = { - catchy: [ - "震惊!{topic}居然还能这样操作", - "99%的人都不知道的{topic}秘诀", - "{topic}的终极指南,看完直接封神", - "别再走弯路了!{topic}看这一篇就够", - "一个视频讲透{topic},建议收藏", - ], - emotional: [ - "致每一个在{topic}路上坚持的人", - "关于{topic},我想说句真心话", - "{topic}背后的故事,看完沉默了", - "为什么我劝你一定要了解{topic}", - "这才是{topic}最动人的样子", - ], - informative: [ - "{topic}完整科普:从入门到精通", - "深度解析{topic}的核心原理", - "{topic}行业趋势报告|2026最新版", - "三分钟带你全面了解{topic}", - "{topic}常见问题与解决方案汇总", - ], -} - /* ================================================================ 组件 ================================================================ */ @@ -2771,57 +2588,10 @@ const GeneratePage: React.FC = () => { return (
{/* ── 页头 ── */} -
-
-

- - 智能剪辑 -

-

快速生成短视频,支持多种风格和素材组合

-
- {editPlanId && ( - - 🎬 来自模板草稿 - - )} -
+ {/* ── 步骤条 ── */} -
- {STEPS.map((step, idx) => { - const isActive = currentStep === step.key - const isDone = currentStep > step.key - const cls = ["xx-step-item", isActive ? "active" : "", isDone ? "done" : ""] - .filter(Boolean) - .join(" ") - return ( - - {idx > 0 && →} -
{ - // 允许点击已完成的步骤回退 - if (isDone) setCurrentStep(step.key) - }} - role="button" - tabIndex={0} - > -
{isDone ? "✓" : step.key}
- {step.label} -
-
- ) - })} -
+ {/* ── 主布局 ── */}
@@ -2859,146 +2629,20 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:生成结果 ════ */} -
-
-

生成结果

- {generated && generatedVideos.length > 0 && ( - {generatedVideos.length} 个视频 - )} -
- - {/* 生成中进度 */} - {generating && ( -
-
- - - - - {Math.round(progress)}% -
-
- - 正在生成视频 - - - AI 正在处理素材,请稍候… - -
-
- )} - - {/* 生成失败 */} - {generateError && !generating && ( -
- - - 生成失败 - - - {typeof generateError === "string" ? generateError : "请重试"} - -
- )} - - {/* 空状态 */} - {!generated && !generating && !generateError && ( -
- - - 完成配置后点击「确认生成」 - - - 生成的视频将在这里展示 - -
- )} - - {/* 生成结果卡片列表 */} - {generated && generatedVideos.length > 0 && ( -
- {generatedVideos.map((video, idx) => ( -
{ - setPreviewVideo(video) - setPreviewModalOpen(true) - }} - > -
- {video.thumbnail_url ? ( - - ) : ( -
- -
- )} -
- -
- {video.duration && ( - {formatDuration(video.duration)} - )} -
-
-
视频 {idx + 1}
-
- - -
-
-
- ))} -
- )} - - {generated && ( -
- -
- )} -
+ { + setPreviewVideo(video) + setPreviewModalOpen(true) + }} + onDownload={handleDownload} + onShare={handleShare} + onGoToLibrary={() => navigate("/app/products")} + />
{/* ── 视频预览弹窗 ── */} diff --git a/apps/web/src/pages/generate/components/GenerateHeader.tsx b/apps/web/src/pages/generate/components/GenerateHeader.tsx new file mode 100644 index 000000000..669e50b01 --- /dev/null +++ b/apps/web/src/pages/generate/components/GenerateHeader.tsx @@ -0,0 +1,40 @@ +/** + * 智能剪辑页头组件 + */ +import React from "react" +import { ThunderboltOutlined } from "@ant-design/icons" + +interface GenerateHeaderProps { + /** 是否来自模板草稿(URL 带 edit_plan_id) */ + fromEditPlan?: boolean +} + +const GenerateHeader: React.FC = ({ fromEditPlan }) => { + return ( +
+
+

+ + 智能剪辑 +

+

快速生成短视频,支持多种风格和素材组合

+
+ {fromEditPlan && ( + + 🎬 来自模板草稿 + + )} +
+ ) +} + +export default GenerateHeader diff --git a/apps/web/src/pages/generate/components/GenerateResultPanel.tsx b/apps/web/src/pages/generate/components/GenerateResultPanel.tsx new file mode 100644 index 000000000..bae3110d8 --- /dev/null +++ b/apps/web/src/pages/generate/components/GenerateResultPanel.tsx @@ -0,0 +1,187 @@ +/** + * 智能剪辑右侧生成结果面板 + */ +import React from "react" +import { Typography } from "antd" +import { + PlayCircleOutlined, + CloseCircleOutlined, + DownloadOutlined, + ShareAltOutlined, +} from "@ant-design/icons" +import type { GeneratedVideo } from "@/api/template-editor" +import { formatDuration } from "@/api/voice-clone" + +const { Text } = Typography + +interface GenerateResultPanelProps { + /** 是否已生成完成 */ + generated: boolean + /** 是否正在生成中 */ + generating: boolean + /** 生成进度(0-100) */ + progress: number + /** 生成错误信息 */ + generateError: string | null + /** 生成的视频列表 */ + generatedVideos: GeneratedVideo[] + /** 点击视频卡片预览回调 */ + onVideoPreview: (video: GeneratedVideo) => void + /** 下载回调 */ + onDownload: () => void + /** 分享回调 */ + onShare: () => void + /** 前往成片库回调 */ + onGoToLibrary: () => void +} + +const GenerateResultPanel: React.FC = ({ + generated, + generating, + progress, + generateError, + generatedVideos, + onVideoPreview, + onDownload, + onShare, + onGoToLibrary, +}) => { + return ( +
+
+

生成结果

+ {generated && generatedVideos.length > 0 && ( + {generatedVideos.length} 个视频 + )} +
+ + {/* 生成中进度 */} + {generating && ( +
+
+ + + + + {Math.round(progress)}% +
+
+ + 正在生成视频 + + + AI 正在处理素材,请稍候… + +
+
+ )} + + {/* 生成失败 */} + {generateError && !generating && ( +
+ + + 生成失败 + + + {typeof generateError === "string" ? generateError : "请重试"} + +
+ )} + + {/* 空状态 */} + {!generated && !generating && !generateError && ( +
+ + + 完成配置后点击「确认生成」 + + + 生成的视频将在这里展示 + +
+ )} + + {/* 生成结果卡片列表 */} + {generated && generatedVideos.length > 0 && ( +
+ {generatedVideos.map((video, idx) => ( +
onVideoPreview(video)} + > +
+ {video.thumbnail_url ? ( + + ) : ( +
+ +
+ )} +
+ +
+ {video.duration && ( + {formatDuration(video.duration)} + )} +
+
+
视频 {idx + 1}
+
+ + +
+
+
+ ))} +
+ )} + + {generated && ( +
+ +
+ )} +
+ ) +} + +export default GenerateResultPanel diff --git a/apps/web/src/pages/generate/components/GenerateStepsBar.tsx b/apps/web/src/pages/generate/components/GenerateStepsBar.tsx new file mode 100644 index 000000000..7f4945f1c --- /dev/null +++ b/apps/web/src/pages/generate/components/GenerateStepsBar.tsx @@ -0,0 +1,47 @@ +/** + * 智能剪辑步骤条组件 + */ +import React from "react" +import { STEPS } from "../constants" + +interface GenerateStepsBarProps { + /** 当前步骤(1-based) */ + currentStep: number + /** 点击已完成步骤的回调(用于回退) */ + onStepClick?: (step: number) => void +} + +const GenerateStepsBar: React.FC = ({ currentStep, onStepClick }) => { + return ( +
+ {STEPS.map((step, idx) => { + const isActive = currentStep === step.key + const isDone = currentStep > step.key + const cls = ["xx-step-item", isActive ? "active" : "", isDone ? "done" : ""] + .filter(Boolean) + .join(" ") + return ( + + {idx > 0 && →} +
{ + // 允许点击已完成的步骤回退 + if (isDone && onStepClick) { + onStepClick(step.key) + } + }} + role="button" + tabIndex={0} + > +
{isDone ? "✓" : step.key}
+ {step.label} +
+
+ ) + })} +
+ ) +} + +export default GenerateStepsBar diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts new file mode 100644 index 000000000..cd27a0bf3 --- /dev/null +++ b/apps/web/src/pages/generate/constants.ts @@ -0,0 +1,200 @@ +/** + * 智能剪辑页面 — 常量定义 + */ + +import type { CoverConfig } from "../editing-planner/types" + +/* ── 克隆声音状态配置 ── */ +export const CLONE_STATUS_CONFIG: Record = { + ready: { label: "就绪", color: "var(--secondary-color, #10b981)" }, + processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" }, + failed: { label: "失败", color: "var(--error-color, #ef4444)" }, +} + +/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */ +export const MODE_GRADIENTS: Record = { + pip: "linear-gradient(135deg, #fbbf24, #f59e0b)", + one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)", + voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)", + voice_pip: "linear-gradient(135deg, #10b981, #059669)", +} + +/* ── 配音性别图标 ── */ +export const VOICE_GENDER_ICON: Record = { + female: "🎀", + male: "🎙️", + child: "🧒", + neutral: "✨", +} + +/* ── 步骤定义 ── */ +export const STEPS = [ + { key: 1, label: "选择模板" }, + { key: 2, label: "选择素材" }, + { key: 3, label: "生成预览" }, + { key: 4, label: "选择标题" }, + { key: 5, label: "选择配音" }, + { key: 6, label: "选择封面" }, + { key: 7, label: "确认生成" }, +] + +/* ── 标题位置选项 ── */ +export const POSITION_OPTIONS = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, +] + +/* ── 标题字体选项 ── */ +export const FONT_OPTIONS = [ + "思源黑体", + "思源宋体", + "苹方", + "PingFang", + "微软雅黑", + "楷体", + "华康俪金黑", +] + +/* ── 标题样式预设 ── */ +export const TITLE_PRESETS = [ + { + key: "classic_white", + label: "经典白字", + style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false }, + previewStyle: { + fontWeight: 700, + color: "#ffffff", + WebkitTextStroke: "1px #000000", + fontSize: "20px", + }, + }, + { + key: "black_gold", + label: "黑金质感", + style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 700, + color: "#d4a843", + textShadow: "1px 1px 3px rgba(0,0,0,0.8)", + fontSize: "20px", + }, + }, + { + key: "fresh_minimal", + label: "清新简约", + style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false }, + previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" }, + }, + { + key: "variety_show", + label: "综艺花字", + style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true }, + previewStyle: { + fontWeight: 900, + color: "#ff4081", + WebkitTextStroke: "1.5px #ffffff", + textShadow: "2px 2px 4px rgba(0,0,0,0.5)", + fontSize: "22px", + }, + }, + { + key: "business", + label: "商务极简", + style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false }, + previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" }, + }, + { + key: "retro_film", + label: "复古胶片", + style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 400, + color: "#e8d5b7", + textShadow: "2px 2px 6px rgba(0,0,0,0.7)", + fontSize: "18px", + }, + }, + { + key: "neon_glow", + label: "霓虹发光", + style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 700, + color: "#00e5ff", + textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)", + fontSize: "20px", + }, + }, + { + key: "handwriting", + label: "手写字", + style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true }, + previewStyle: { + fontWeight: 400, + color: "#333333", + textShadow: "1px 1px 2px rgba(0,0,0,0.3)", + fontSize: "20px", + }, + }, +] + +/* ── 封面模式 ── */ +export const COVER_MODE_LABELS: Record = { + auto: "智能封面", + frame: "抽帧选封面", + upload: "上传封面", +} + +export const COVER_MODE_ICONS: Record = { + auto: "🤖", + frame: "🎞️", + upload: "📤", +} + +/* ── 智能匹配推荐理由 ── */ +export const SMART_MATCH_REASONS = [ + "画面清晰度高,构图专业", + "与描述场景高度契合", + "时长适中,适合剪辑节奏", + "色彩风格统一", + "包含关键动作镜头", + "镜头运动流畅自然", + "光影效果出色", + "人物表情生动", +] + +/* ── AI 标题模板 ── */ +export const AI_TITLE_TEMPLATES: Record = { + catchy: [ + "震惊!{topic}居然还能这样操作", + "99%的人都不知道的{topic}秘诀", + "{topic}的终极指南,看完直接封神", + "别再走弯路了!{topic}看这一篇就够", + "一个视频讲透{topic},建议收藏", + ], + emotional: [ + "致每一个在{topic}路上坚持的人", + "关于{topic},我想说句真心话", + "{topic}背后的故事,看完沉默了", + "为什么我劝你一定要了解{topic}", + "这才是{topic}最动人的样子", + ], + informative: [ + "{topic}完整科普:从入门到精通", + "深度解析{topic}的核心原理", + "{topic}行业趋势报告|2026最新版", + "三分钟带你全面了解{topic}", + "{topic}常见问题与解决方案汇总", + ], +} + +/* ── 默认封面设置 ── */ +export const DEFAULT_COVER_SETTINGS: CoverConfig = { + enabled: true, + mode: "auto", + frame_time: 0, + upload_url: "", + ai_suggested_time: null, + thumbnail_url: "", +} diff --git a/apps/web/src/pages/generate/types.ts b/apps/web/src/pages/generate/types.ts new file mode 100644 index 000000000..88be13f77 --- /dev/null +++ b/apps/web/src/pages/generate/types.ts @@ -0,0 +1,73 @@ +/** + * 智能剪辑页面 — 类型定义 + */ + +import type { AssetItem } from "@/api/assets" + +/* ── 标题设置 ── */ +export interface TitleSettings { + aiAutoSelect: boolean + title: string + position: string + font: string + size: number + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean + color: string +} + +/* ── 智能匹配结果 ── */ +export interface SmartMatchResult { + asset: AssetItem + matchScore: number + reasons: string[] +} + +/* ── AI 标题结果 ── */ +export interface AiTitleResult { + title: string + style: string + styleLabel: string + highlights: string[] +} + +/* ── 配音推荐结果 ── */ +export interface VoiceRecommendation { + voiceId: string + voiceName: string + reason: string +} + +/* ── 步骤定义 ── */ +export interface StepDef { + key: number + label: string +} + +/* ── 标题预设样式 ── */ +export interface TitlePresetStyle { + size: number + color: string + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean +} + +export interface TitlePreset { + key: string + label: string + style: TitlePresetStyle + previewStyle: Record +} + +/* ── 生成结果视频 ── */ +export interface GeneratedVideoResult { + id: string + url: string + thumbnail: string + duration: number + title: string +} diff --git a/apps/web/src/test/components/GenerateHeader.test.tsx b/apps/web/src/test/components/GenerateHeader.test.tsx new file mode 100644 index 000000000..2bc6be03a --- /dev/null +++ b/apps/web/src/test/components/GenerateHeader.test.tsx @@ -0,0 +1,27 @@ +/** + * GenerateHeader 组件单元测试 + */ +import { render, screen } from "@testing-library/react" +import { describe, it, expect } from "vitest" +import GenerateHeader from "@/pages/generate/components/GenerateHeader" + +describe("GenerateHeader", () => { + it("should render title and description", () => { + render() + + expect(screen.getByText("智能剪辑")).toBeInTheDocument() + expect(screen.getByText("快速生成短视频,支持多种风格和素材组合")).toBeInTheDocument() + }) + + it("should not show edit plan badge by default", () => { + render() + + expect(screen.queryByText("来自模板草稿")).not.toBeInTheDocument() + }) + + it("should show edit plan badge when fromEditPlan is true", () => { + render() + + expect(screen.getByText("🎬 来自模板草稿")).toBeInTheDocument() + }) +})