b5bc285aff
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (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 / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m35s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m4s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m35s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 23m11s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m20s
- 面板1:出镜视频改为从素材库弹窗选择(ModalAssetPicker),竖屏9:16预览 - 面板2:配音库改为系统预设/我的音色双tab切换,复用 /api/v1/voices - 面板3:对口型三态(生成/进度/完成+重新生成),竖屏9:16预览 - 面板4:标题配置直接 import TitleStylePanel + TITLE_PRESETS,与智能剪辑完全一致 - 面板5:封面+分辨率+配置汇总+渐变生成按钮,删除多余画面插入模式 - B-roll弹窗:已选素材标灰pointer-events:none防重选,画中画四角位置 - 路由 /app/ai-avatar + 侧边栏AI数字人导航项
223 lines
7.2 KiB
TypeScript
223 lines
7.2 KiB
TypeScript
/**
|
|
* AI数字人 — 文案 & 对口型面板(面板4)
|
|
* 上半区:文案(文案库选择 / 手动输入);下半区:对口型视频预览(9:16)+ B-roll 画面
|
|
*/
|
|
import { useState } from "react"
|
|
import type { LipsyncJob, BRollSegment } from "../types"
|
|
|
|
interface PanelScriptAndLipsyncProps {
|
|
scriptText: string
|
|
onScriptTextChange: (text: string) => void
|
|
onOpenScriptModal: () => void
|
|
lipsyncJob: LipsyncJob | null
|
|
onGenerateLipsync: () => void
|
|
bRollSegments: BRollSegment[]
|
|
onOpenBRollModal: () => void
|
|
onRemoveBRoll: (id: string) => void
|
|
}
|
|
|
|
type ScriptTab = "library" | "manual"
|
|
|
|
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
|
fullscreen: "全屏",
|
|
pip: "画中画",
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.round(seconds % 60)
|
|
return `${m}:${s.toString().padStart(2, "0")}`
|
|
}
|
|
|
|
export function PanelScriptAndLipsync({
|
|
scriptText,
|
|
onScriptTextChange,
|
|
onOpenScriptModal,
|
|
lipsyncJob,
|
|
onGenerateLipsync,
|
|
bRollSegments,
|
|
onOpenBRollModal,
|
|
onRemoveBRoll,
|
|
}: PanelScriptAndLipsyncProps) {
|
|
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
|
|
|
|
/* 对口型状态判断 */
|
|
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
|
|
const isDone = lipsyncJob?.status === "completed"
|
|
const isFailed = lipsyncJob?.status === "failed"
|
|
|
|
const statusText =
|
|
lipsyncJob?.status === "processing"
|
|
? "对口型生成中…"
|
|
: lipsyncJob?.status === "pending"
|
|
? "排队中…"
|
|
: "对口型生成中…"
|
|
|
|
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>
|
|
|
|
{/* ── 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>
|
|
)}
|
|
</div>
|
|
<div className="aa-lipsync-actions">
|
|
<button
|
|
type="button"
|
|
className="aa-btn aa-btn--primary aa-btn--full"
|
|
onClick={onOpenBRollModal}
|
|
>
|
|
🎬 插入画面
|
|
</button>
|
|
</div>
|
|
|
|
{bRollSegments.length > 0 && (
|
|
<div className="aa-broll-list">
|
|
{bRollSegments.map((seg) => (
|
|
<div key={seg.id} className="aa-broll-item">
|
|
{seg.asset.thumbnail_url || seg.asset.file_url ? (
|
|
<img
|
|
className="aa-broll-item__thumb"
|
|
src={seg.asset.thumbnail_url || seg.asset.file_url}
|
|
alt={seg.asset.name}
|
|
/>
|
|
) : (
|
|
<span className="aa-broll-item__thumb" style={{ padding: "6px 4px" }}>
|
|
🎬
|
|
</span>
|
|
)}
|
|
<div className="aa-broll-item__info">
|
|
<div
|
|
style={{
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{seg.asset.name}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: "#8c8ca1", marginTop: 2 }}>
|
|
{BROLL_MODE_LABEL[seg.mode]} · {formatTime(seg.start_time)}-
|
|
{formatTime(seg.end_time)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="aa-broll-item__remove"
|
|
title="删除"
|
|
onClick={() => onRemoveBRoll(seg.id)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 下半区:对口型预览(竖屏 9:16) ── */}
|
|
<div className="aa-lipsync-section">
|
|
<div className="aa-lipsync-section__title">对口型预览</div>
|
|
|
|
<div className="aa-lipsync-preview">
|
|
{isDone && lipsyncJob?.output_video_url ? (
|
|
<video src={lipsyncJob.output_video_url} controls />
|
|
) : isGenerating ? (
|
|
<div style={{ width: "80%", textAlign: "center", color: "#fff" }}>
|
|
<div style={{ fontSize: 13, marginBottom: 8 }}>
|
|
{statusText} {Math.round(lipsyncJob?.progress ?? 0)}%
|
|
</div>
|
|
<div className="aa-progress">
|
|
<div
|
|
className="aa-progress__bar"
|
|
style={{ width: `${lipsyncJob?.progress ?? 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="aa-video-preview__placeholder">
|
|
{isFailed ? (
|
|
<>
|
|
<div style={{ fontSize: 28, marginBottom: 8 }}>❌</div>
|
|
<div>对口型生成失败</div>
|
|
{lipsyncJob?.error_message && (
|
|
<div style={{ fontSize: 11, marginTop: 4, color: "#fca5a5" }}>
|
|
{lipsyncJob.error_message}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
"生成对口型视频后在此预览"
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="aa-lipsync-actions">
|
|
{isDone ? (
|
|
<button type="button" className="aa-btn aa-btn--full" onClick={onGenerateLipsync}>
|
|
🔄 重新生成对口型
|
|
</button>
|
|
) : isGenerating ? (
|
|
<button type="button" className="aa-btn aa-btn--full" disabled>
|
|
对口型生成中…
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="aa-btn aa-btn--primary aa-btn--full"
|
|
onClick={onGenerateLipsync}
|
|
>
|
|
🎬 生成对口型视频
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PanelScriptAndLipsync
|