Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb3bc04c83 |
@@ -5,7 +5,7 @@
|
||||
.xx-app-shell 全屏 flex 容器
|
||||
├── header (xx-top-nav) 顶部导航(Header.tsx 管理)
|
||||
└── .xx-app-body 水平 flex 行
|
||||
├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠)
|
||||
├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠)
|
||||
└── .xx-app-content 主内容区(自适应)
|
||||
|
||||
所有尺寸/颜色均使用 global.css 设计系统变量
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
/* ── 侧边栏 ───────────────────────────────────────────────── */
|
||||
.xx-app-sidebar {
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -103,12 +103,6 @@
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 展开态(侧边栏 128px)水平 padding 收窄,为菜单文字留出完整一行空间 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-content {
|
||||
padding-left: var(--space-xs);
|
||||
padding-right: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ── 主内容区 ─────────────────────────────────────────────── */
|
||||
.xx-app-content {
|
||||
flex: 1;
|
||||
@@ -142,7 +136,7 @@
|
||||
|
||||
/* 展开态恢复完整宽度 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) {
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle {
|
||||
@@ -165,7 +159,7 @@
|
||||
top: 56px; /* 移动端 Header 高度 */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-slow);
|
||||
box-shadow: none;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MainLayout - 主布局组件(Task 1.2)
|
||||
*
|
||||
* 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区
|
||||
* - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 顶部导航:复用 Header 组件(68px 固定高度)
|
||||
* - 主内容区:自适应填充剩余空间
|
||||
* - 响应式:移动端(<768px)隐藏侧边栏
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
/* 分组标题 */
|
||||
.xx-sidebar-group-title {
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md) var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
@@ -56,9 +56,9 @@
|
||||
.xx-sidebar-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-xs);
|
||||
margin: 0 var(--space-xxs);
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin: 0 var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
@@ -97,12 +97,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
@@ -119,9 +119,7 @@
|
||||
/* ── 菜单项文字 ───────────────────────────────────────────── */
|
||||
.xx-sidebar-menu-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -135,16 +133,8 @@
|
||||
/* 折叠时菜单项居中,仅图标 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠态图标恢复更大尺寸居中 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
padding: var(--space-sm);
|
||||
margin: 0 var(--space-xxs);
|
||||
}
|
||||
|
||||
/* 折叠时隐藏分组标题 */
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.admin-coming-soon-page {
|
||||
padding: 32px;
|
||||
max-width: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1021,132 +1021,3 @@
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
#1809 ④⑤⑥ B-roll 弹窗:选库行 + 文案句子列表 + 自动估算提示
|
||||
============================================================ */
|
||||
|
||||
/* 宽弹窗:左右两栏 + 句子列表需要更大空间 */
|
||||
.aa-modal--wide {
|
||||
max-width: 960px;
|
||||
width: 94%;
|
||||
}
|
||||
|
||||
.aa-broll-modal-body .aa-broll-right {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
/* 左侧素材库选择行 */
|
||||
.aa-broll-lib-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aa-broll-lib-row .aa-select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border-color, #e2e2ea);
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #1a1a2e;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 无缩略图时的素材占位 */
|
||||
.aa-broll-asset-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 文案句子列表 */
|
||||
.aa-sentence-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.aa-sentence-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border-color, #e2e2ea);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.aa-sentence-item:hover {
|
||||
border-color: #c0c0d0;
|
||||
background: #f8f8fc;
|
||||
}
|
||||
|
||||
.aa-sentence-item.active {
|
||||
border-color: #7c3aed;
|
||||
background: #f3edff;
|
||||
}
|
||||
|
||||
.aa-sentence-item__idx {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f5;
|
||||
color: #6b6b80;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.aa-sentence-item.active .aa-sentence-item__idx {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.aa-sentence-item__text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #1a1a2e;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.aa-sentence-item__time {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: #8c8ca1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.aa-sentence-empty {
|
||||
font-size: 12px;
|
||||
color: #8c8ca1;
|
||||
background: #f8f8fc;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* 选择/估算提示 */
|
||||
.aa-broll-hint {
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
background: #f8f8fc;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 5列水平面板布局
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import "./AiAvatar.css"
|
||||
import { useAiAvatar } from "./hooks/useAiAvatar"
|
||||
import { PanelVideoSelector } from "./components/PanelVideoSelector"
|
||||
@@ -14,6 +13,7 @@ import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
|
||||
import { ModalAssetPicker } from "./components/ModalAssetPicker"
|
||||
import ModalBRollEditor from "./components/ModalBRollEditor"
|
||||
import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
type PanelKey = "video" | "voice" | "script" | "title" | "cover"
|
||||
@@ -28,6 +28,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
cover: false,
|
||||
})
|
||||
|
||||
/* ── 素材库弹窗 ── */
|
||||
const [bRollAssets, setBRollAssets] = useState<import("@/api/assets").AssetItem[]>([])
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
@@ -37,26 +40,14 @@ const AiAvatarPage: React.FC = () => {
|
||||
|
||||
/* ── 对口型 ── */
|
||||
const handleGenerateLipsync = useCallback(async () => {
|
||||
// ② 缺项明确提示(#1809):不再静默 return
|
||||
const video = state.selectedVideo
|
||||
const voice = state.selectedVoice
|
||||
const text = state.scriptText.trim()
|
||||
const missing: string[] = []
|
||||
if (!video) missing.push("出镜视频")
|
||||
if (!voice) missing.push("音色")
|
||||
if (!text) missing.push("文案")
|
||||
if (missing.length > 0 || !video || !voice) {
|
||||
message.warning(`请先选择${missing.join("、")}`)
|
||||
return
|
||||
}
|
||||
if (!state.selectedVideo || !state.selectedVoice || !state.scriptText) return
|
||||
try {
|
||||
const job = await createLipsyncJob({
|
||||
voice_id: voice.voice_id,
|
||||
voice_id: state.selectedVoice.voice_id,
|
||||
script_text: state.scriptText,
|
||||
video_asset_id: video.id,
|
||||
video_asset_id: state.selectedVideo.id,
|
||||
})
|
||||
state.setLipsyncJob(job)
|
||||
message.success("对口型任务已提交,生成中…")
|
||||
// 开始轮询
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = setInterval(async () => {
|
||||
@@ -65,20 +56,13 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.setLipsyncJob(updated)
|
||||
if (updated.status === "completed" || updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (updated.status === "completed") {
|
||||
message.success("对口型视频生成完成")
|
||||
} else {
|
||||
message.error(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误(轮询期间不打扰用户)
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
// ② 接口失败弹错误提示,不只 console
|
||||
console.error("对口型任务创建失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
|
||||
@@ -90,13 +74,16 @@ const AiAvatarPage: React.FC = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 加载 B-roll 素材 ── */
|
||||
useEffect(() => {
|
||||
getAssetsByKind("video", { limit: 50 })
|
||||
.then(setBRollAssets)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const handleGenerate = useCallback(async () => {
|
||||
// ② 前置条件提示(#1809)
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
|
||||
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
|
||||
return
|
||||
}
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") return
|
||||
state.setIsGenerating(true)
|
||||
try {
|
||||
await submitRender({
|
||||
@@ -107,10 +94,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
cover_config: state.coverConfig as unknown as Record<string, unknown>,
|
||||
resolution: state.resolution,
|
||||
})
|
||||
message.success("渲染任务已提交,可在视频管理中查看进度")
|
||||
} catch (err) {
|
||||
console.error("渲染任务提交失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试")
|
||||
} finally {
|
||||
state.setIsGenerating(false)
|
||||
}
|
||||
@@ -256,8 +241,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
open={state.showBRollModal}
|
||||
onClose={() => state.setShowBRollModal(false)}
|
||||
existingSegments={state.bRollSegments}
|
||||
scriptText={state.scriptText}
|
||||
outputDuration={state.lipsyncJob?.output_duration ?? 0}
|
||||
availableAssets={bRollAssets}
|
||||
onConfirm={state.addBRollSegment}
|
||||
onRemove={state.removeBRollSegment}
|
||||
/>
|
||||
@@ -280,8 +264,8 @@ const ScriptSelectModalLazy: React.FC<{
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getScripts()
|
||||
.then((items) => setScripts(Array.isArray(items) ? items : []))
|
||||
.catch(() => setScripts([]))
|
||||
.then(setScripts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -6,12 +6,8 @@ import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types"
|
||||
|
||||
/* ── 文案库 ── */
|
||||
export const getScripts = async (): Promise<Script[]> => {
|
||||
const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts")
|
||||
// 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复)
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: Script[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
const response = await apiClient.get<Script[]>("/scripts")
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getScriptById = async (id: string): Promise<Script> => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择弹窗(#1809 ③)
|
||||
* 交互对齐智能剪辑 Step2:先选素材库(video 库)→ 再选该库内视频。
|
||||
* 搜索框 + 素材库下拉 + 竖屏 9:16 视频缩略图网格 + 底部确认选择。
|
||||
* AI数字人 — 素材库弹窗
|
||||
* 搜索框 + 类型筛选(全部/视频/图片)+ 4 列竖屏 9:16 缩略图网格 + 底部确认选择
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
|
||||
/** 素材类型筛选 */
|
||||
type AssetKindFilter = "all" | "video" | "image"
|
||||
|
||||
export interface ModalAssetPickerProps {
|
||||
open: boolean
|
||||
@@ -14,76 +17,86 @@ export interface ModalAssetPickerProps {
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: { value: AssetKindFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [kindFilter, setKindFilter] = useState<AssetKindFilter>("video")
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [pickedId, setPickedId] = useState<string | null>(null)
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
/* 弹窗打开:重置状态 */
|
||||
/* 弹窗打开:重置筛选 / 关键字,并定位高亮到已选素材 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setKeyword("")
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setKindFilter("video")
|
||||
setAssets([])
|
||||
setError("")
|
||||
setPickedId(selectedId ?? null)
|
||||
setReady(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* 第一步:加载视频素材库列表(仅 kind=video,对齐智能剪辑 #1777) */
|
||||
/* 确保默认素材库存在(视频 + 图片,供类型筛选),仅在弹窗打开时执行一次 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
setLoadingLibs(true)
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
// 默认选中第一个视频库
|
||||
if (list.length > 0) setLibraryId((prev) => prev || list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
const ensureLibraries = async () => {
|
||||
try {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
await Promise.all([
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "video" }),
|
||||
ensureDefaultLibrary({ project_id: project.id, kind: "image" }),
|
||||
])
|
||||
if (!cancelled) setReady(true)
|
||||
} catch {
|
||||
if (!cancelled) setError("素材库初始化失败,请重试")
|
||||
}
|
||||
}
|
||||
ensureLibraries()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 第二步:选中库后拉取该库视频素材(关键字防抖) */
|
||||
/* 拉取素材:类型 / 关键字变化时防抖重新请求 */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
if (!open || !ready) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
setLoading(true)
|
||||
const load = async () => {
|
||||
try {
|
||||
// getAssets 返回 { items, total };拉满一页(数字人口播视频库通常不大)
|
||||
const { items } = await getAssets(libraryId, { page_size: 100 })
|
||||
if (cancelled) return
|
||||
let list = Array.isArray(items) ? items : []
|
||||
// 仅保留视频素材(出镜视频要求)
|
||||
list = list.filter((a) => a.mime_type?.includes("video"))
|
||||
const kw = keyword.trim()
|
||||
if (kw) list = list.filter((a) => a.name?.includes(kw))
|
||||
setAssets(list)
|
||||
setError("")
|
||||
const kw = keyword.trim() || undefined
|
||||
let items: AssetItem[] = []
|
||||
if (kindFilter === "all") {
|
||||
const [videos, images] = await Promise.all([
|
||||
getAssetsByKind("video", { keyword: kw }),
|
||||
getAssetsByKind("image", { keyword: kw }),
|
||||
])
|
||||
const seen = new Set<string>()
|
||||
items = [...videos, ...images].filter((a) => {
|
||||
if (seen.has(a.id)) return false
|
||||
seen.add(a.id)
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
items = await getAssetsByKind(kindFilter, { keyword: kw })
|
||||
}
|
||||
if (!cancelled) setAssets(items)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("素材加载失败,请重试")
|
||||
setAssets([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(load, 300)
|
||||
@@ -91,7 +104,7 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, libraryId, keyword])
|
||||
}, [open, ready, kindFilter, keyword])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -107,33 +120,15 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择出镜视频</span>
|
||||
<span className="aa-modal__title">选择素材</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:素材库选择 + 搜索 + 网格 */}
|
||||
{/* 主体:搜索 + 筛选 + 网格 */}
|
||||
<div className="aa-modal__body">
|
||||
{/* 第一步:选素材库 */}
|
||||
<div className="aa-asset-search">
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 160, flex: "0 0 auto" }}
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="text"
|
||||
@@ -141,14 +136,21 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 110, flex: "0 0 auto" }}
|
||||
value={kindFilter}
|
||||
onChange={(e) => setKindFilter(e.target.value as AssetKindFilter)}
|
||||
>
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{libraries.length === 0 && !loadingLibs ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无视频素材库,请先在「素材库」中创建视频库并上传视频
|
||||
</div>
|
||||
) : loadingAssets ? (
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
@@ -160,8 +162,8 @@ export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalA
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
该素材库暂无视频素材
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无素材
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-asset-grid">
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
/**
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗(#1809 ④⑤⑥)
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗
|
||||
*
|
||||
* 布局:
|
||||
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
|
||||
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
|
||||
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
|
||||
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算)
|
||||
* - 底部:已配置的画面插入列表(可删除)
|
||||
* - 左侧:可用素材网格(已被其他 segment 使用的素材标灰 + "已选择" 遮罩,
|
||||
* pointer-events: none 防止重复选择同一段素材)
|
||||
* - 右侧:插入设置(文案段落索引 / 全屏 or 画中画 / 画中画四角位置 + 大小 / 起止时间)
|
||||
* - 底部:已配置的画面插入列表(可删除)+ 上传新素材入口
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
|
||||
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前已有的 B-roll segments(用于标灰已选素材) */
|
||||
existingSegments: BRollSegment[]
|
||||
/** 当前文案全文(用于分句) */
|
||||
scriptText: string
|
||||
/** 对口型成片总时长(秒),用于时间自动估算 */
|
||||
outputDuration: number
|
||||
/** 所有可用素材 */
|
||||
availableAssets: AssetItem[]
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
@@ -42,31 +38,18 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
existingSegments,
|
||||
scriptText,
|
||||
outputDuration,
|
||||
availableAssets,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* ── 素材库(④ 先选库再选素材) ── */
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [availableAssets, setAvailableAssets] = useState<AssetItem[]>([])
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [assetError, setAssetError] = useState("")
|
||||
|
||||
/* ── 右侧设置本地状态 ── */
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetItem | null>(null)
|
||||
const [selectedSentence, setSelectedSentence] = useState<ScriptSentence | null>(null)
|
||||
const [scriptSegmentIndex, setScriptSegmentIndex] = useState(0)
|
||||
const [mode, setMode] = useState<BRollInsertMode>("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
|
||||
/** 文案分句(⑤) */
|
||||
const sentences = useMemo(
|
||||
() => splitScriptIntoSentences(scriptText, outputDuration),
|
||||
[scriptText, outputDuration],
|
||||
)
|
||||
const [startTime, setStartTime] = useState(0)
|
||||
const [endTime, setEndTime] = useState(3)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
const usedAssetIds = useMemo(
|
||||
@@ -74,83 +57,25 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
[existingSegments],
|
||||
)
|
||||
|
||||
/* 弹窗打开:重置选择 + 加载视频库列表 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
setMode("fullscreen")
|
||||
setPipPosition("top-right")
|
||||
setPipScale(0.3)
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setAvailableAssets([])
|
||||
setAssetError("")
|
||||
setLoadingLibs(true)
|
||||
let cancelled = false
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
if (list.length > 0) setLibraryId(list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAssetError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 选中库后拉取该库视频素材 */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
getAssets(libraryId, { page_size: 100 })
|
||||
.then(({ items }) => {
|
||||
if (cancelled) return
|
||||
const list = (Array.isArray(items) ? items : []).filter((a) =>
|
||||
a.mime_type?.includes("video"),
|
||||
)
|
||||
setAvailableAssets(list)
|
||||
setAssetError("")
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAssetError("素材加载失败,请重试")
|
||||
setAvailableAssets([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, libraryId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
/** 选择素材(已选素材因 pointer-events:none 不会触发) */
|
||||
const handleSelectAsset = (asset: AssetItem) => {
|
||||
if (usedAssetIds.has(asset.id)) return
|
||||
setSelectedAsset(asset)
|
||||
// 默认起止时间:素材时长的前 3 秒(或整段)
|
||||
const dur = asset.duration ?? 3
|
||||
setEndTime(Math.min(3, dur))
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止) */
|
||||
/** 确认添加一段 B-roll */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset || !selectedSentence) return
|
||||
const startTime = selectedSentence.startTime
|
||||
const endTime = Math.max(selectedSentence.endTime, startTime + 0.5)
|
||||
if (!selectedAsset) return
|
||||
if (endTime <= startTime) return
|
||||
const segment: BRollSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
asset: selectedAsset,
|
||||
script_segment_index: selectedSentence.index,
|
||||
script_segment_index: scriptSegmentIndex,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
mode,
|
||||
@@ -158,16 +83,15 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
pip_scale: mode === "pip" ? pipScale : 0.3,
|
||||
}
|
||||
onConfirm(segment)
|
||||
// 重置素材/句子选择,保留模式设置便于连续添加
|
||||
// 重置选择,保留设置便于连续添加
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
}
|
||||
|
||||
const canConfirm = selectedAsset !== null && selectedSentence !== null
|
||||
const canConfirm = selectedAsset !== null && endTime > startTime
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal aa-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">🎞️ 画面插入(B-roll)</span>
|
||||
@@ -179,25 +103,22 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
{/* 主体:左素材 + 右设置 */}
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-broll-modal-body">
|
||||
{/* 左侧:选库 + 素材网格 */}
|
||||
{/* 左侧:素材网格 */}
|
||||
<div className="aa-broll-left">
|
||||
<div className="aa-broll-lib-row">
|
||||
<select
|
||||
className="aa-select"
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#1a1a2e" }}>
|
||||
选择素材({availableAssets.length})
|
||||
</span>
|
||||
<button type="button" className="aa-btn aa-btn--ghost aa-btn-sm">
|
||||
⬆️ 上传新素材
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-broll-asset-grid">
|
||||
{availableAssets.map((asset) => {
|
||||
@@ -220,60 +141,45 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="aa-broll-asset-placeholder">🎬</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
)}
|
||||
<span className="aa-asset-card__name">{asset.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{loadingAssets ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : availableAssets.length === 0 ? (
|
||||
{availableAssets.length === 0 && (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
{assetError || "该素材库暂无视频素材"}
|
||||
暂无可用素材,请先上传视频素材
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:插入设置 */}
|
||||
<div className="aa-broll-right">
|
||||
<div className="aa-broll-settings">
|
||||
{/* ⑤ 文案句子列表(替代段落索引数字框) */}
|
||||
{/* 文案段落索引 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">对应文案句子(点选)</label>
|
||||
{sentences.length === 0 ? (
|
||||
<div className="aa-sentence-empty">
|
||||
请先在「文案 & 对口型」面板填写或选择文案
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-sentence-list">
|
||||
{sentences.map((sent) => {
|
||||
const active = selectedSentence?.index === sent.index
|
||||
return (
|
||||
<button
|
||||
key={sent.index}
|
||||
type="button"
|
||||
className={`aa-sentence-item${active ? " active" : ""}`}
|
||||
onClick={() => setSelectedSentence(sent)}
|
||||
title={sent.text}
|
||||
>
|
||||
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
|
||||
<span className="aa-sentence-item__text">{sent.text}</span>
|
||||
{outputDuration > 0 && (
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<label className="aa-label">文案段落索引</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={scriptSegmentIndex}
|
||||
onChange={(e) => setScriptSegmentIndex(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 插入模式 */}
|
||||
@@ -318,7 +224,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<div className="aa-field-label-row">
|
||||
<div
|
||||
className="aa-field-label-row"
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<label className="aa-label">画中画大小</label>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{Math.round(pipScale * 100)}%
|
||||
@@ -337,26 +246,41 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 当前选择提示(⑥ 自动估算时间在这里展示) */}
|
||||
<div className="aa-broll-hint">
|
||||
{selectedAsset && selectedSentence ? (
|
||||
<>
|
||||
<div>已选素材:{selectedAsset.name}</div>
|
||||
<div>
|
||||
对应第 {selectedSentence.index + 1} 句 · 时间段{" "}
|
||||
{selectedSentence.startTime.toFixed(1)}s -{" "}
|
||||
{Math.max(
|
||||
selectedSentence.endTime,
|
||||
selectedSentence.startTime + 0.5,
|
||||
).toFixed(1)}
|
||||
s (按字数自动估算)
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8c8ca1" }}>
|
||||
{!selectedAsset ? "请从左侧选择一段素材" : "请在上方点选对应的文案句子"}
|
||||
</div>
|
||||
)}
|
||||
{/* 起止时间 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">开始时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">结束时间(秒)</label>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前选中素材提示 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: selectedAsset ? "#059669" : "#8c8ca1",
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
}}
|
||||
>
|
||||
{selectedAsset ? `已选素材:${selectedAsset.name}` : "请从左侧选择一段素材"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +304,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
<div className="aa-broll-item__info">
|
||||
<div style={{ fontWeight: 500, color: "#1a1a2e" }}>{seg.asset.name}</div>
|
||||
<div style={{ color: "#8c8ca1", fontSize: 11 }}>
|
||||
第 {seg.script_segment_index + 1} 句 · {MODE_LABEL[seg.mode]}
|
||||
段落 {seg.script_segment_index} · {MODE_LABEL[seg.mode]}
|
||||
{seg.mode === "pip" ? ` · ${seg.pip_position}` : ""} ·{" "}
|
||||
{seg.start_time.toFixed(1)}s - {seg.end_time.toFixed(1)}s
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function PanelVoiceSelector({
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchVoices({ type: voiceSource })
|
||||
if (!cancelled) setVoices(Array.isArray(res?.items) ? res.items : [])
|
||||
if (!cancelled) setVoices(res.items || [])
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "音色加载失败")
|
||||
} finally {
|
||||
|
||||
@@ -44,8 +44,6 @@ export interface LipsyncJob {
|
||||
status: LipsyncStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
|
||||
output_duration?: number
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
|
||||
*/
|
||||
|
||||
export interface ScriptSentence {
|
||||
/** 句子序号(从 0 开始,对应提交给后端的 script_segment_index) */
|
||||
index: number
|
||||
/** 句子文本(去掉首尾空白) */
|
||||
text: string
|
||||
/** 句子字数(按中文/字符计,去除空白) */
|
||||
charCount: number
|
||||
/** 累计起始字数(用于时间估算) */
|
||||
startChar: number
|
||||
/** 估算的对口型视频内起始时间(秒) */
|
||||
startTime: number
|
||||
/** 估算的对口型视频内结束时间(秒) */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
|
||||
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
|
||||
*/
|
||||
export function splitScriptIntoSentences(
|
||||
scriptText: string,
|
||||
outputDuration: number,
|
||||
): ScriptSentence[] {
|
||||
const text = (scriptText || "").trim()
|
||||
if (!text) return []
|
||||
|
||||
const rawParts = text
|
||||
.split(/[。!?!?;;\n\r]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
const totalChars = rawParts.reduce((sum, part) => sum + part.replace(/\s/g, "").length, 0)
|
||||
const duration = outputDuration > 0 ? outputDuration : 0
|
||||
|
||||
const sentences: ScriptSentence[] = []
|
||||
let accChar = 0
|
||||
rawParts.forEach((part, i) => {
|
||||
const charCount = part.replace(/\s/g, "").length
|
||||
const startTime = duration > 0 && totalChars > 0 ? (accChar / totalChars) * duration : 0
|
||||
const endTime =
|
||||
duration > 0 && totalChars > 0 ? ((accChar + charCount) / totalChars) * duration : 0
|
||||
sentences.push({
|
||||
index: i,
|
||||
text: part,
|
||||
charCount,
|
||||
startChar: accChar,
|
||||
startTime: round1(startTime),
|
||||
endTime: round1(endTime),
|
||||
})
|
||||
accChar += charCount
|
||||
})
|
||||
|
||||
return sentences
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
============================================================ */
|
||||
.dup-page {
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
max-width: 1400px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
/* ============================================================
|
||||
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
|
||||
|
||||
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
|
||||
两个页面共用。AI数字人页面不引入 generate.css,直接由
|
||||
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
|
||||
配色描边、选中态与智能剪辑页面完全一致。
|
||||
|
||||
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
|
||||
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
|
||||
============================================================ */
|
||||
|
||||
/* ── 区块容器 ── */
|
||||
.xx-title-style-section {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.xx-section-subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.xx-title-style-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-half-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-field-label-row label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ── 共用表单字段(位置/字体下拉) ── */
|
||||
.xx-title-style-section .xx-form-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field select,
|
||||
.xx-title-style-section .xx-form-field input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
outline: 0;
|
||||
transition: 0.15s ease;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field select:focus,
|
||||
.xx-title-style-section .xx-form-field input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
/* ── 字号滑块 ── */
|
||||
.xx-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.xx-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
/* ── 标题预设卡片网格(24 个 T 格子) ── */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 52px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
background: #404040;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-title-preset-card:hover {
|
||||
border-color: #666;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active {
|
||||
border-color: #409eff;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-style-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-style-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-style-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -5,9 +5,6 @@
|
||||
import React from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import TitlePresetsGrid from "./TitlePresetsGrid"
|
||||
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
|
||||
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
|
||||
import "./TitleStylePanel.css"
|
||||
|
||||
interface PositionOption {
|
||||
value: string
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.xx-generate-page {
|
||||
min-height: 100%;
|
||||
padding: var(--space-xl);
|
||||
max-width: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.mt-page {
|
||||
padding: var(--space-xl);
|
||||
max-width: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.task-center {
|
||||
padding: var(--space-lg);
|
||||
max-width: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user