bf7c8b8a08
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Validate - Style (push) Successful in 1m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m50s
CI/CD Pipeline / Unit Tests (push) Successful in 8m50s
CI/CD Pipeline / Validate - Security (push) Successful in 26m4s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 185h11m19s
CI/CD Pipeline / CI Gate (push) Failing after 185h11m20s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 185h11m20s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 185h34m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 185h34m22s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 185h36m24s
CI/CD Pipeline / Build Production Web Image (push) Failing after 185h11m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 185h36m24s
CI/CD Pipeline / Build Production API Image (push) Failing after 185h11m20s
CI/CD Pipeline / Frontend Lint (push) Failing after 185h37m23s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 185h37m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 185h37m28s
CI/CD Pipeline / PR Build Web Image (push) Failing after 185h37m23s
CI/CD Pipeline / Deploy Production (push) Failing after 185h46m5s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 186h9m8s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 186h11m10s
CI/CD Pipeline / PR Build API Image (push) Failing after 186h12m8s
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
|