feat(ai-avatar): AI数字人页面拆分为两步
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 32s
CI/CD Pipeline / Validate - Style (push) Successful in 1m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m10s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Security (push) Successful in 4m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m13s
CI/CD Pipeline / Unit Tests (push) Successful in 7m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled

- 步骤1:出镜视频 / 配音库 / 文案
- 步骤2:对口型预览(含插入画面) / 标题配置 / 封面&生成
- 新增 PanelScript.tsx(文案面板)
- 新增 PanelLipsyncPreview.tsx(对口型预览面板)
- 删除 PanelScriptAndLipsync.tsx(已拆分)
- 补充对应 CSS 样式
This commit is contained in:
xiaoxia
2026-09-10 12:28:40 +08:00
parent e679b54ebc
commit 9fff6ff8e2
4 changed files with 779 additions and 1250 deletions
File diff suppressed because it is too large Load Diff
+300 -128
View File
@@ -1,6 +1,7 @@
/**
* AI数字人 — 主页面(v3
* 5列水平面板布局
* AI数字人 — 主页面(两步骤版
* 步骤1:出镜视频 / 配音库 / 文案
* 步骤2:插入画面 / 对口型预览 / 标题配置 / 封面&生成
*/
import React, { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd"
@@ -8,7 +9,8 @@ import "./AiAvatar.css"
import { useAiAvatar } from "./hooks/useAiAvatar"
import { PanelVideoSelector } from "./components/PanelVideoSelector"
import PanelVoiceSelector from "./components/PanelVoiceSelector"
import PanelScriptAndLipsync from "./components/PanelScriptAndLipsync"
import PanelScript from "./components/PanelScript"
import PanelLipsyncPreview from "./components/PanelLipsyncPreview"
import PanelTitleConfig from "./components/PanelTitleConfig"
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
import { ModalAssetPicker } from "./components/ModalAssetPicker"
@@ -28,25 +30,33 @@ import {
} from "./utils/contract"
/** 面板折叠状态 */
type PanelKey = "video" | "voice" | "script" | "title" | "cover"
type PanelKey =
| "video"
| "voice"
| "script"
| "lipsync"
| "title"
| "cover"
const AiAvatarPage: React.FC = () => {
const state = useAiAvatar()
const [currentStep, setCurrentStep] = useState(1)
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
video: false,
voice: false,
script: false,
lipsync: false,
title: false,
cover: false,
})
/* ── 对口型生成弹窗 ── */
const [showLipsyncModal, setShowLipsyncModal] = useState(false)
const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">(
"generating",
)
const [lipsyncStatus, setLipsyncStatus] = useState<
"generating" | "completed" | "failed"
>("generating")
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ─ 智能封面加载态 ── */
/* ─ 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 对口型轮询 ── */
@@ -56,9 +66,25 @@ const AiAvatarPage: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
/* ── 对口型 ── */
/* ── 步骤切换 ── */
const canGoToStep2 = useCallback(() => {
return state.selectedVideo && state.selectedVoice && state.scriptText.trim()
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
const handleNextStep = useCallback(() => {
if (!canGoToStep2()) {
message.warning("请先完成出镜视频、配音和文案的选择")
return
}
setCurrentStep(2)
}, [canGoToStep2])
const handlePrevStep = useCallback(() => {
setCurrentStep(1)
}, [])
/* ── 对口型 ─ */
const handleGenerateLipsync = useCallback(async () => {
// ② 缺项明确提示(#1809):不再静默 return
const video = state.selectedVideo
const voice = state.selectedVoice
const text = state.scriptText.trim()
@@ -71,12 +97,10 @@ const AiAvatarPage: React.FC = () => {
return
}
try {
// 显示生成弹窗
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
// ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url)
console.log("[对口型] 开始生成:", {
videoId: video.id,
voiceId: voice.voice_id,
@@ -95,19 +119,20 @@ const AiAvatarPage: React.FC = () => {
message.error("获取出镜视频播放地址失败,请重新选择素材")
return
}
// ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822)
const payload = {
voice_id: voice.voice_id,
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed, // 语速 0.5~2.0
emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
}
console.log("[对口型] createLipsyncJob 请求:", payload)
const job = await createLipsyncJob(payload)
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
console.log("[对口型] createLipsyncJob 响应:", {
id: job.id,
status: job.status,
})
state.setLipsyncJob(job)
// 开始轮询
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = setInterval(async () => {
try {
@@ -128,7 +153,9 @@ const AiAvatarPage: React.FC = () => {
} else if (updated.status === "failed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
setLipsyncStatus("failed")
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
setLipsyncErrorMessage(
updated.error_message || "对口型生成失败",
)
}
} catch (err) {
console.error("[对口型] 轮询错误:", err)
@@ -141,12 +168,19 @@ const AiAvatarPage: React.FC = () => {
message: err instanceof Error ? err.message : String(err),
})
setShowLipsyncModal(false)
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
message.error(
err instanceof Error ? err.message : "对口型任务提交失败,请重试",
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
}, [
state.selectedVideo,
state.selectedVoice,
state.scriptText,
state.speed,
state.emotion,
])
// 取消对口型生成
const handleCancelLipsync = useCallback(() => {
if (lipsyncTimerRef.current) {
clearInterval(lipsyncTimerRef.current)
@@ -157,7 +191,6 @@ const AiAvatarPage: React.FC = () => {
setLipsyncErrorMessage("")
}, [])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
@@ -166,7 +199,6 @@ const AiAvatarPage: React.FC = () => {
/* ── 生成视频 ── */
const handleGenerate = useCallback(async () => {
// ② 前置条件提示(#1809
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
return
@@ -177,16 +209,19 @@ const AiAvatarPage: React.FC = () => {
lipsync_job_id: state.lipsyncJob.id,
script_id: state.script?.id,
b_roll_segments: state.bRollSegments as never,
// 字段映射:build_title_drawtext_filter 真实口径 text/font_size/font_color/position/...
title_config: buildTitleConfigPayload(state.titleConfig),
// cover_config:智能封面 cover_url + 截帧 timestamp
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
cover_config: buildCoverConfigPayload(
state.coverConfig,
state.coverConfig.smart_cover_url,
),
resolution: state.resolution,
})
message.success("渲染任务已提交,可在视频管理中查看进度")
} catch (err) {
console.error("渲染任务提交失败:", err)
message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试")
message.error(
err instanceof Error ? err.message : "渲染任务提交失败,请重试",
)
} finally {
state.setIsGenerating(false)
}
@@ -200,9 +235,8 @@ const AiAvatarPage: React.FC = () => {
state.resolution,
])
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822 ── */
/* ── 智能封面 ── */
const handleSmartCover = useCallback(async () => {
// 基于对口型成片抽帧,必须先完成对口型
const videoUrl = state.lipsyncJob?.output_video_url
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
message.warning("请先生成对口型视频,完成后再智能获取封面")
@@ -224,7 +258,9 @@ const AiAvatarPage: React.FC = () => {
}
} catch (err) {
console.error("智能封面生成失败:", err)
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
message.error(
err instanceof Error ? err.message : "智能封面生成失败,请重试",
)
} finally {
setSmartCoverLoading(false)
}
@@ -245,101 +281,196 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI数字人</h1>
<h1>AI </h1>
</div>
{/* 步骤指示器 */}
<div className="aa-steps">
<div className={`aa-step ${currentStep >= 1 ? "active" : ""}`}>
<span className="aa-step__number">1</span>
<span className="aa-step__label"> / / </span>
</div>
<div className="aa-step__connector" />
<div className={`aa-step ${currentStep >= 2 ? "active" : ""}`}>
<span className="aa-step__number">2</span>
<span className="aa-step__label">
/ / /
</span>
</div>
</div>
<div className="aa-page-body">
{/* 面板1:出镜视频 */}
<div className={`aa-panel aa-panel--p1${collapsed.video ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVideoSelector
selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo}
titleConfig={state.titleConfig}
/>
</div>
</div>
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ═══ */}
{currentStep === 1 && (
<>
{/* 面板 1:出镜视频 */}
<div
className={`aa-panel${collapsed.video ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("video")}
>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVideoSelector
selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo}
titleConfig={state.titleConfig}
/>
</div>
</div>
{/* 面板2:配音库 */}
<div className={`aa-panel aa-panel--p2${collapsed.voice ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVoiceSelector
voiceSource={state.voiceSource}
onVoiceSourceChange={state.setVoiceSource}
selectedVoice={state.selectedVoice}
onSelectVoice={state.setSelectedVoice}
emotion={state.emotion}
onEmotionChange={state.setEmotion}
speed={state.speed}
onSpeedChange={state.setSpeed}
language={state.language}
onLanguageChange={state.setLanguage}
/>
</div>
</div>
{/* 面板 2:配音库 */}
<div
className={`aa-panel${collapsed.voice ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("voice")}
>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelVoiceSelector
voiceSource={state.voiceSource}
onVoiceSourceChange={state.setVoiceSource}
selectedVoice={state.selectedVoice}
onSelectVoice={state.setSelectedVoice}
emotion={state.emotion}
onEmotionChange={state.setEmotion}
speed={state.speed}
onSpeedChange={state.setSpeed}
language={state.language}
onLanguageChange={state.setLanguage}
/>
</div>
</div>
{/* 面板3:文案 & 对口型 */}
<div className={`aa-panel aa-panel--p3${collapsed.script ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelScriptAndLipsync
scriptText={state.scriptText}
onScriptTextChange={state.setScriptText}
onOpenScriptModal={() => state.setShowScriptModal(true)}
lipsyncJob={state.lipsyncJob}
onGenerateLipsync={handleGenerateLipsync}
bRollSegments={state.bRollSegments}
onOpenBRollModal={() => state.setShowBRollModal(true)}
onRemoveBRoll={state.removeBRollSegment}
/>
</div>
</div>
{/* 面板 3:文案 */}
<div
className={`aa-panel${collapsed.script ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("script")}
>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelScript
scriptText={state.scriptText}
onScriptTextChange={state.setScriptText}
onOpenScriptModal={() => state.setShowScriptModal(true)}
/>
</div>
</div>
{/* 面板4:标题配置 */}
<div className={`aa-panel aa-panel--p4${collapsed.title ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelTitleConfig titleConfig={state.titleConfig} onUpdate={state.updateTitleConfig} />
</div>
</div>
{/* 步骤 1 底部按钮 */}
<div className="aa-step-actions">
<button
className="aa-btn aa-btn--primary"
onClick={handleNextStep}
>
</button>
</div>
</>
)}
{/* 面板5:封面 & 生成 */}
<div className={`aa-panel aa-panel--p5${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelCoverAndGenerate
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={state.lipsyncJob?.status === "completed"}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
summary={summary}
/>
</div>
</div>
{/* ════ 步骤 2:插入画面 / 对口型预览 / 标题配置 / 封面&生成 ═══ */}
{currentStep === 2 && (
<>
{/* 面板 4:插入画面 & 对口型预览 */}
<div
className={`aa-panel${collapsed.lipsync ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("lipsync")}
>
<span className="aa-panel__title">
</span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelLipsyncPreview
lipsyncJob={state.lipsyncJob}
onGenerateLipsync={handleGenerateLipsync}
bRollSegments={state.bRollSegments}
onOpenBRollModal={() => state.setShowBRollModal(true)}
onRemoveBRoll={state.removeBRollSegment}
/>
</div>
</div>
{/* 面板 5:标题配置 */}
<div
className={`aa-panel${collapsed.title ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("title")}
>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelTitleConfig
titleConfig={state.titleConfig}
onUpdate={state.updateTitleConfig}
/>
</div>
</div>
{/* 面板 6:封面 & 生成 */}
<div
className={`aa-panel${collapsed.cover ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("cover")}
>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelCoverAndGenerate
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({
...prev,
...partial,
}))
}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={
state.lipsyncJob?.status === "completed"
}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
summary={summary}
/>
</div>
</div>
{/* 步骤 2 底部按钮 */}
<div className="aa-step-actions">
<button className="aa-btn" onClick={handlePrevStep}>
</button>
</div>
</>
)}
</div>
{/* 素材库弹窗 */}
@@ -380,7 +511,10 @@ const AiAvatarPage: React.FC = () => {
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<button className="aa-modal__close" onClick={handleCancelLipsync}>
<button
className="aa-modal__close"
onClick={handleCancelLipsync}
>
</button>
</div>
@@ -396,10 +530,22 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "generating" && (
<>
<div className="aa-lipsync-spinner" />
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
<div
style={{
marginTop: 20,
fontSize: 15,
color: "#1a1a2e",
}}
>
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#8c8ca1",
}}
>
</div>
</>
@@ -407,7 +553,13 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "completed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
</div>
</>
@@ -415,11 +567,23 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "failed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
</div>
{lipsyncErrorMessage && (
<div style={{ marginTop: 8, fontSize: 13, color: "#ff4d4f" }}>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#ff4d4f",
}}
>
{lipsyncErrorMessage}
</div>
)}
@@ -428,7 +592,10 @@ const AiAvatarPage: React.FC = () => {
</div>
<div className="aa-modal__footer">
{lipsyncStatus === "generating" && (
<button className="aa-btn aa-btn--danger" onClick={handleCancelLipsync}>
<button
className="aa-btn aa-btn--danger"
onClick={handleCancelLipsync}
>
</button>
)}
@@ -496,12 +663,17 @@ const ScriptSelectModalLazy: React.FC<{
) : (
<div className="aa-script-list">
{filtered.map((s) => (
<div key={s.id} className="aa-script-item" onClick={() => onSelect(s)}>
<div
key={s.id}
className="aa-script-item"
onClick={() => onSelect(s)}
>
<span className="aa-script-item__icon">📄</span>
<div className="aa-script-item__info">
<div className="aa-script-item__title">{s.title}</div>
<div className="aa-script-item__meta">
{s.char_count} · {new Date(s.created_at).toLocaleDateString()}
{s.char_count} ·{" "}
{new Date(s.created_at).toLocaleDateString()}
</div>
</div>
</div>
@@ -1,14 +1,10 @@
/**
* AI数字人 & 4
* / 9:16+ B-roll
* AI数字人 2
* B-roll + + /
*/
import { useState } from "react"
import type { LipsyncJob, BRollSegment } from "../types"
interface PanelScriptAndLipsyncProps {
scriptText: string
onScriptTextChange: (text: string) => void
onOpenScriptModal: () => void
interface PanelLipsyncPreviewProps {
lipsyncJob: LipsyncJob | null
onGenerateLipsync: () => void
bRollSegments: BRollSegment[]
@@ -16,8 +12,6 @@ interface PanelScriptAndLipsyncProps {
onRemoveBRoll: (id: string) => void
}
type ScriptTab = "library" | "manual"
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
fullscreen: "全屏",
pip: "画中画",
@@ -29,20 +23,15 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`
}
export function PanelScriptAndLipsync({
scriptText,
onScriptTextChange,
onOpenScriptModal,
export function PanelLipsyncPreview({
lipsyncJob,
onGenerateLipsync,
bRollSegments,
onOpenBRollModal,
onRemoveBRoll,
}: PanelScriptAndLipsyncProps) {
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
/* 对口型状态判断 */
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
}: PanelLipsyncPreviewProps) {
const isGenerating =
lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
const isDone = lipsyncJob?.status === "completed"
const isFailed = lipsyncJob?.status === "failed"
@@ -54,53 +43,15 @@ export function PanelScriptAndLipsync({
: "对口型生成中…"
return (
<div className="aa-script-lipsync">
{/* ── 上半区:文案 ── */}
<div className="aa-script-tabs">
<button
type="button"
className={`aa-script-tab${scriptTab === "library" ? " active" : ""}`}
onClick={() => setScriptTab("library")}
>
</button>
<button
type="button"
className={`aa-script-tab${scriptTab === "manual" ? " active" : ""}`}
onClick={() => setScriptTab("manual")}
>
</button>
</div>
{scriptTab === "library" && (
<button
type="button"
className="aa-btn aa-btn--ghost aa-btn--full"
style={{ marginBottom: 8 }}
onClick={onOpenScriptModal}
>
📚
</button>
)}
<textarea
className="aa-textarea"
value={scriptText}
readOnly={scriptTab === "library"}
placeholder={
scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…"
}
onChange={(e) => onScriptTextChange(e.target.value)}
/>
<div className="aa-char-count">{scriptText.length} </div>
<div className="aa-lipsync-preview-panel">
{/* ── B-roll 画面 ── */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title">
<span style={{ marginRight: 8 }}>🎞 </span>
{bRollSegments.length > 0 && (
<span className="aa-broll-badge">🎬 {bRollSegments.length} </span>
<span className="aa-broll-badge">
🎬 {bRollSegments.length}
</span>
)}
</div>
<div className="aa-lipsync-actions">
@@ -124,7 +75,10 @@ export function PanelScriptAndLipsync({
alt={seg.asset.name}
/>
) : (
<span className="aa-broll-item__thumb" style={{ padding: "6px 4px" }}>
<span
className="aa-broll-item__thumb"
style={{ padding: "6px 4px" }}
>
🎬
</span>
)}
@@ -157,7 +111,7 @@ export function PanelScriptAndLipsync({
)}
</div>
{/* ── 下半区:对口型预览(竖屏 9:16─ */}
{/* ── 对口型预览 ─ */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title"></div>
@@ -183,7 +137,13 @@ export function PanelScriptAndLipsync({
<div style={{ fontSize: 28, marginBottom: 8 }}></div>
<div></div>
{lipsyncJob?.error_message && (
<div style={{ fontSize: 11, marginTop: 4, color: "#fca5a5" }}>
<div
style={{
fontSize: 11,
marginTop: 4,
color: "#fca5a5",
}}
>
{lipsyncJob.error_message}
</div>
)}
@@ -197,7 +157,11 @@ export function PanelScriptAndLipsync({
<div className="aa-lipsync-actions">
{isDone ? (
<button type="button" className="aa-btn aa-btn--full" onClick={onGenerateLipsync}>
<button
type="button"
className="aa-btn aa-btn--full"
onClick={onGenerateLipsync}
>
🔄
</button>
) : isGenerating ? (
@@ -219,4 +183,4 @@ export function PanelScriptAndLipsync({
)
}
export default PanelScriptAndLipsync
export default PanelLipsyncPreview
+69
View File
@@ -0,0 +1,69 @@
/**
* AI数字人 — 文案面板(步骤1用)
* 文案库选择 / 手动输入 + 字数统计
*/
import { useState } from "react"
interface PanelScriptProps {
scriptText: string
onScriptTextChange: (text: string) => void
onOpenScriptModal: () => void
}
type ScriptTab = "library" | "manual"
export function PanelScript({
scriptText,
onScriptTextChange,
onOpenScriptModal,
}: PanelScriptProps) {
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
return (
<div className="aa-script-panel">
{/* ── Tab 切换 ── */}
<div className="aa-script-tabs">
<button
type="button"
className={`aa-script-tab${scriptTab === "library" ? " active" : ""}`}
onClick={() => setScriptTab("library")}
>
</button>
<button
type="button"
className={`aa-script-tab${scriptTab === "manual" ? " active" : ""}`}
onClick={() => setScriptTab("manual")}
>
</button>
</div>
{scriptTab === "library" && (
<button
type="button"
className="aa-btn aa-btn--ghost aa-btn--full"
style={{ marginBottom: 8 }}
onClick={onOpenScriptModal}
>
📚
</button>
)}
<textarea
className="aa-textarea"
value={scriptText}
readOnly={scriptTab === "library"}
placeholder={
scriptTab === "library"
? "点击上方按钮,从文案库选择文案…"
: "请输入数字人口播文案…"
}
onChange={(e) => onScriptTextChange(e.target.value)}
/>
<div className="aa-char-count">{scriptText.length} </div>
</div>
)
}
export default PanelScript