b69595bd28
CI/CD Pipeline / Check push changed paths (push) Successful in 37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m24s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Failing after 12m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m53s
CI/CD Pipeline / Integration Tests (push) Successful in 5m57s
CI/CD Pipeline / Validate - Style (push) Failing after 7m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m1s
CI/CD Pipeline / Unit Tests (push) Failing after 11m3s
CI/CD Pipeline / Validate - Security (push) Successful in 23m12s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 67h10m28s
CI/CD Pipeline / Build Production API Image (push) Failing after 67h10m28s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 67h44m41s
CI/CD Pipeline / PR Build Web Image (push) Failing after 67h45m17s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 67h10m4s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 67h44m53s
CI/CD Pipeline / PR Build API Image (push) Failing after 67h44m53s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 67h44m17s
CI/CD Pipeline / Deploy Production (push) Failing after 67h9m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 67h44m17s
CI/CD Pipeline / Canary Release to Production (push) Failing after 67h9m59s
CI/CD Pipeline / Build Production Web Image (push) Failing after 67h10m4s
CI/CD Pipeline / Frontend Lint (push) Failing after 67h33m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 67h45m28s
339 lines
13 KiB
TypeScript
Executable File
339 lines
13 KiB
TypeScript
Executable File
/**
|
||
* AI数字人 — 对口型预览面板(步骤2用)
|
||
* B-roll 画面插入 + 对口型视频预览 + 生成/重新生成按钮
|
||
* v3.1: 标题字号按预览容器实际宽度动态计算 previewScale(基准 720p),与成片一致
|
||
*/
|
||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||
import type { LipsyncJob, BRollSegment, AiAvatarTitleConfig } from "../types"
|
||
|
||
interface PanelLipsyncPreviewProps {
|
||
lipsyncJob: LipsyncJob | null
|
||
onGenerateLipsync: () => void
|
||
bRollSegments: BRollSegment[]
|
||
onOpenBRollModal: () => void
|
||
onRemoveBRoll: (id: string) => void
|
||
/** 标题配置(实时叠加预览用) */
|
||
titleConfig?: AiAvatarTitleConfig
|
||
/** 标题位置变更回调(拖拽结束时调用,发送百分比坐标 + position:"custom") */
|
||
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number; position: string }) => void
|
||
}
|
||
|
||
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")}`
|
||
}
|
||
|
||
/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */
|
||
const FONT_FAMILY_MAP: Record<string, string> = {
|
||
思源黑体:
|
||
"'Noto Sans CJK SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||
思源宋体: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
|
||
楷体: "KaiTi, 'STKaiti', serif",
|
||
黑体: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
|
||
}
|
||
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
|
||
|
||
export function PanelLipsyncPreview({
|
||
lipsyncJob,
|
||
onGenerateLipsync,
|
||
bRollSegments,
|
||
onOpenBRollModal,
|
||
onRemoveBRoll,
|
||
titleConfig,
|
||
onTitlePositionChange,
|
||
}: PanelLipsyncPreviewProps) {
|
||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||
const draggingTitleRef = useRef(false)
|
||
const previewContainerRef = useRef<HTMLDivElement>(null)
|
||
// 预览容器实际宽度(通过 ResizeObserver 监听),用于动态计算 previewScale
|
||
const [containerWidth, setContainerWidth] = useState(0)
|
||
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"
|
||
? "排队中…"
|
||
: "对口型生成中…"
|
||
|
||
// 监听预览容器尺寸变化,动态测量宽度以计算 previewScale(基准 720p)
|
||
useEffect(() => {
|
||
const el = previewContainerRef.current
|
||
if (!el) return
|
||
const update = () => setContainerWidth(el.clientWidth || 0)
|
||
update()
|
||
if (typeof ResizeObserver !== "undefined") {
|
||
const ro = new ResizeObserver(update)
|
||
ro.observe(el)
|
||
return () => ro.disconnect()
|
||
}
|
||
window.addEventListener("resize", update)
|
||
return () => window.removeEventListener("resize", update)
|
||
}, [])
|
||
|
||
// 预览缩放比:预览宽度 / 720(基准宽度)
|
||
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
|
||
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
|
||
|
||
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */
|
||
const titleOverlayStyle: React.CSSProperties | null =
|
||
titleConfig?.title && containerWidth > 0
|
||
? (() => {
|
||
const baseSize = titleConfig.size || 48
|
||
const fontSize = ps(baseSize)
|
||
// 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p
|
||
const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2))
|
||
// 阴影按比例缩放
|
||
const shadowBlur = ps(4)
|
||
const shadowOffsetY = ps(2)
|
||
// padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放)
|
||
const padV = ps(16) * 0.5 // ≈ 8px in ~240px container
|
||
const padH = ps(24) * 0.5
|
||
|
||
const style: React.CSSProperties = {
|
||
position: "absolute",
|
||
color: titleConfig.color || "#ffffff",
|
||
fontFamily: getFontFamily(titleConfig.font || "思源黑体"),
|
||
fontSize: `${fontSize}px`,
|
||
fontWeight: titleConfig.bold ? 700 : 400,
|
||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||
textAlign: "center",
|
||
width: "90%",
|
||
lineHeight: 1.2,
|
||
padding: `${ps(4)}px ${padH}px`,
|
||
textShadow: titleConfig.shadow
|
||
? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)`
|
||
: undefined,
|
||
WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined,
|
||
boxSizing: "border-box",
|
||
wordBreak: "break-word",
|
||
whiteSpace: "pre-wrap",
|
||
}
|
||
|
||
if (
|
||
titleConfig.position === "custom" &&
|
||
titleConfig.pos_x != null &&
|
||
titleConfig.pos_y != null
|
||
) {
|
||
style.left = `${titleConfig.pos_x}%`
|
||
style.top = `${titleConfig.pos_y}%`
|
||
style.transform = "translateX(-50%) translateY(-50%)"
|
||
} else if (titleConfig.position === "top") {
|
||
style.left = "50%"
|
||
style.top = padV
|
||
style.transform = "translateX(-50%)"
|
||
} else if (titleConfig.position === "bottom") {
|
||
style.left = "50%"
|
||
style.bottom = padV
|
||
style.transform = "translateX(-50%)"
|
||
} else {
|
||
style.left = "50%"
|
||
style.top = "50%"
|
||
style.transform = "translateX(-50%) translateY(-50%)"
|
||
}
|
||
return style
|
||
})()
|
||
: null
|
||
|
||
const handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!onTitlePositionChange || !previewContainerRef.current) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||
draggingTitleRef.current = true
|
||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||
}
|
||
const handleTitlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!draggingTitleRef.current || !previewContainerRef.current) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (titleDragRef.current) {
|
||
const rect = previewContainerRef.current.getBoundingClientRect()
|
||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||
const xpct = (relX / rect.width) * 100
|
||
const ypct = (relY / rect.height) * 100
|
||
titleDragRef.current.style.left = `${xpct}%`
|
||
titleDragRef.current.style.top = `${ypct}%`
|
||
}
|
||
}
|
||
const handleTitlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!draggingTitleRef.current) return
|
||
draggingTitleRef.current = false
|
||
if (onTitlePositionChange && previewContainerRef.current) {
|
||
const rect = previewContainerRef.current.getBoundingClientRect()
|
||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||
// 发送百分比坐标(0-100),与后端 drawtext 百分比表达式对齐
|
||
const xpct = Math.round((relX / rect.width) * 1000) / 10
|
||
const ypct = Math.round((relY / rect.height) * 1000) / 10
|
||
onTitlePositionChange({ pos_x: xpct, pos_y: ypct, position: "custom" })
|
||
}
|
||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||
}
|
||
|
||
return (
|
||
<div className="aa-script-lipsync">
|
||
{/* ── 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>
|
||
|
||
{/* ── 对口型预览(标题字号按 previewScale 动态缩放) ─ */}
|
||
<div className="aa-lipsync-section">
|
||
<div className="aa-lipsync-section__title">对口型预览</div>
|
||
|
||
<div className="aa-lipsync-preview" ref={previewContainerRef}>
|
||
{isDone && lipsyncJob?.output_video_url ? (
|
||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||
<video src={lipsyncJob.output_video_url} controls />
|
||
{titleOverlayStyle && (
|
||
<div
|
||
ref={titleDragRef}
|
||
style={{
|
||
...titleOverlayStyle,
|
||
cursor: onTitlePositionChange ? "grab" : "default",
|
||
pointerEvents: onTitlePositionChange ? "auto" : "none",
|
||
}}
|
||
onPointerDown={handleTitlePointerDown}
|
||
onPointerMove={handleTitlePointerMove}
|
||
onPointerUp={handleTitlePointerUp}
|
||
onPointerCancel={handleTitlePointerUp}
|
||
>
|
||
{titleConfig!.title.split(/[//]/).map((part, i) => (
|
||
<span key={i}>
|
||
{i > 0 && <br />}
|
||
{part}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : 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 PanelLipsyncPreview
|