This commit is contained in:
海龙 2025-08-18 21:10:24 +08:00
commit 541568f0f5
13 changed files with 139 additions and 128 deletions

View File

@ -987,7 +987,7 @@ export const batchUpdateVideoSegments = async (request: {
/** 新的视频地址列表 */ /** 新的视频地址列表 */
video_urls: string[]; video_urls: string[];
/** 新的状态 0:视频加载中 1:任务已完成 2:任务失败 */ /** 新的状态 0:视频加载中 1:任务已完成 2:任务失败 */
status: number; status: number | null;
/** 优化后的描述文本 */ /** 优化后的描述文本 */
optimized_description?: string; optimized_description?: string;
/** 关键词列表 */ /** 关键词列表 */
@ -1033,7 +1033,8 @@ export const getCharacterShots = async (request: {
video_id: string; video_id: string;
video_status: number|null; video_status: number|null;
}[]; }[];
/** 视频状态 */
video_status: number|null;
}>; }>;
/** 总数量 */ /** 总数量 */
total_count: number; total_count: number;

View File

@ -98,7 +98,7 @@ export const useRoleShotServiceHook = (projectId: string,selectRole?:RoleEntity,
name: `视频片段_${scene.video_id}`, name: `视频片段_${scene.video_id}`,
sketchUrl: "", sketchUrl: "",
videoUrl: scene.video_urls,// 保持为string[]类型 videoUrl: scene.video_urls,// 保持为string[]类型
status:scene.video_urls.length>0?1:0, // 默认为已完成状态 status: scene.video_status !== null? scene.video_status : scene.video_urls.length>0?1:0, // 默认为已完成状态
lens: [], lens: [],
selected: false, selected: false,
applied: true // 由于是通过角色查询到的,所以都是已应用的 applied: true // 由于是通过角色查询到的,所以都是已应用的

View File

@ -6,7 +6,7 @@ import {
MatchedPerson, MatchedPerson,
RoleRecognitionResponse RoleRecognitionResponse
} from "@/api/DTO/movieEdit"; } from "@/api/DTO/movieEdit";
import { VideoSegmentEntity } from "../domain/Entities"; import { ScriptRoleEntity, VideoSegmentEntity } from "../domain/Entities";
import { LensType, SimpleCharacter } from "../domain/valueObject"; import { LensType, SimpleCharacter } from "../domain/valueObject";
import { getUploadToken, uploadToQiniu } from "@/api/common"; import { getUploadToken, uploadToQiniu } from "@/api/common";
import { SaveEditUseCase } from "../usecase/SaveEditUseCase"; import { SaveEditUseCase } from "../usecase/SaveEditUseCase";
@ -21,6 +21,8 @@ export interface UseShotService {
loading: boolean; loading: boolean;
/** 视频片段列表 */ /** 视频片段列表 */
videoSegments: VideoSegmentEntity[]; videoSegments: VideoSegmentEntity[];
/** 剧本中角色列表 */
scriptRoles: ScriptRoleEntity[];
/** 当前选中的视频片段 */ /** 当前选中的视频片段 */
selectedSegment: VideoSegmentEntity | null; selectedSegment: VideoSegmentEntity | null;
/** 识别出的人物信息 */ /** 识别出的人物信息 */
@ -69,6 +71,7 @@ export const useShotService = (): UseShotService => {
// 响应式状态 // 响应式状态
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [videoSegments, setVideoSegments] = useState<VideoSegmentEntity[]>([]); const [videoSegments, setVideoSegments] = useState<VideoSegmentEntity[]>([]);
const [scriptRoles, setScriptRoles] = useState<ScriptRoleEntity[]>([]);
const [selectedSegment, setSelectedSegment] = const [selectedSegment, setSelectedSegment] =
useState<VideoSegmentEntity | null>(null); useState<VideoSegmentEntity | null>(null);
const [projectId, setProjectId] = useState<string>(""); const [projectId, setProjectId] = useState<string>("");
@ -90,9 +93,10 @@ export const useShotService = (): UseShotService => {
try { try {
setLoading(true); setLoading(true);
const segments = await vidoEditUseCase.getVideoSegmentList(projectId); const { segments, roles } = await vidoEditUseCase.getVideoSegmentList(projectId);
setProjectId(projectId); setProjectId(projectId);
setVideoSegments(segments); setVideoSegments(segments);
setScriptRoles(roles);
setIntervalIdHandler(projectId); setIntervalIdHandler(projectId);
} catch (error) { } catch (error) {
console.error("获取视频片段列表失败:", error); console.error("获取视频片段列表失败:", error);
@ -112,7 +116,7 @@ export const useShotService = (): UseShotService => {
// 定义定时任务每5秒执行一次 // 定义定时任务每5秒执行一次
const newIntervalId = setInterval(async () => { const newIntervalId = setInterval(async () => {
try { try {
const segments = await vidoEditUseCase.getVideoSegmentList(projectId,()=>{ const { segments } = await vidoEditUseCase.getVideoSegmentList(projectId,()=>{
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
setIntervalId(null); setIntervalId(null);
@ -474,6 +478,7 @@ export const useShotService = (): UseShotService => {
// 响应式状态 // 响应式状态
loading, loading,
videoSegments, videoSegments,
scriptRoles,
selectedSegment, selectedSegment,
matched_persons, matched_persons,
// 操作方法 // 操作方法

View File

@ -1,7 +1,7 @@
/**============因协同任务开发流程没有明确管理,导致的必要的适配=================**/ /**============因协同任务开发流程没有明确管理,导致的必要的适配=================**/
import { VideoSegmentEntity } from "../domain/Entities"; import { ScriptRoleEntity, VideoSegmentEntity } from "../domain/Entities";
import { LensType, ContentItem } from "../domain/valueObject"; import { LensType, ContentItem } from "../domain/valueObject";
@ -73,6 +73,7 @@ export class VideoSegmentEntityAdapter {
video_status: number|null; video_status: number|null;
}>; }>;
}> = []; }> = [];
project_characters: ScriptRoleEntity[] = [];
/** /**
* @description shotContent * @description shotContent
@ -208,7 +209,7 @@ export class VideoSegmentEntityAdapter {
// 如果有对话内容,添加到镜头描述后面 // 如果有对话内容,添加到镜头描述后面
if (lensItem.content && lensItem.content.length > 0) { if (lensItem.content && lensItem.content.length > 0) {
const dialogueLines = lensItem.content.map(dialogue => const dialogueLines = lensItem.content.map(dialogue =>
`${dialogue.roleName} [CH-01]: ${dialogue.content}` `${dialogue.roleName} : ${dialogue.content}`
); );
fullContent += '\n' + dialogueLines.join('\n'); fullContent += '\n' + dialogueLines.join('\n');
} }
@ -312,7 +313,7 @@ export class VideoSegmentEntityAdapter {
// 如果有对话内容,添加到镜头描述后面 // 如果有对话内容,添加到镜头描述后面
if (lensType.content && lensType.content.length > 0) { if (lensType.content && lensType.content.length > 0) {
const dialogueLines = lensType.content.map(dialogue => const dialogueLines = lensType.content.map(dialogue =>
`${dialogue.roleName} [CH-01]: ${dialogue.content}` `${dialogue.roleName} : ${dialogue.content}`
); );
fullContent += '\n' + dialogueLines.join('\n'); fullContent += '\n' + dialogueLines.join('\n');
} }

View File

@ -1,12 +1,5 @@
import { ContentItem, LensType, SimpleCharacter, TagValueObject } from '../domain/valueObject'; import { ContentItem, LensType, SimpleCharacter, TagValueObject } from '../domain/valueObject';
import { ScriptRoleEntity } from '../domain/Entities';
// 定义角色属性接口
interface CharacterAttributes {
name: string;
// gender: string;
// age: string;
avatar: string;
}
// 定义高亮属性接口 // 定义高亮属性接口
interface HighlightAttributes { interface HighlightAttributes {
@ -23,7 +16,7 @@ interface TextNode {
// 定义角色标记节点接口 // 定义角色标记节点接口
interface CharacterTokenNode { interface CharacterTokenNode {
type: 'characterToken'; type: 'characterToken';
attrs: CharacterAttributes; attrs: ScriptRoleEntity;
} }
// 定义高亮节点接口 // 定义高亮节点接口
@ -55,7 +48,7 @@ export class TextToShotAdapter {
* @param roles * @param roles
* @returns ContentNode[] * @returns ContentNode[]
*/ */
public static parseText(text: string, roles: SimpleCharacter[]): ContentNode[] { public static parseText(text: string, roles: ScriptRoleEntity[]): ContentNode[] {
const nodes: ContentNode[] = []; const nodes: ContentNode[] = [];
let currentText = text; let currentText = text;
@ -63,31 +56,32 @@ export class TextToShotAdapter {
// 既要兼容 每个单词 首字母大写 其余小写、还要兼容 全部大写 // 既要兼容 每个单词 首字母大写 其余小写、还要兼容 全部大写
const sortedRoles = [...roles].sort((a, b) => b.name.length - a.name.length).map(role => ({ const sortedRoles = [...roles].sort((a, b) => b.name.length - a.name.length).map(role => ({
...role, ...role,
name: role.name.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') match_name: role.name.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') + ' [' + role.id + ']'
})).concat([...roles].map(role => ({ })).concat([...roles].map(role => ({
...role, ...role,
name: role.name.toUpperCase() match_name: role.name.toUpperCase() + ' [' + role.id + ']'
}))); })));
console.log('shots-匹配角色', text, sortedRoles);
while (currentText.length > 0) { while (currentText.length > 0) {
let matchFound = false; let matchFound = false;
// 尝试匹配角色 // 尝试匹配角色
for (const role of sortedRoles) { for (const role of sortedRoles) {
if (currentText.startsWith(role.name)) { if (currentText.startsWith(role.match_name)) {
// 如果当前文本以角色名开头 // 如果当前文本以角色名开头
if (currentText.length > role.name.length) { if (currentText.length > role.match_name.length) {
// 添加角色标记节点 // 添加角色标记节点
nodes.push({ nodes.push({
type: 'characterToken', type: 'characterToken',
attrs: { attrs: {
name: role.name, ...role
avatar: role.imageUrl
} }
}); });
// 移除已处理的角色名 // 移除已处理的角色名
currentText = currentText.slice(role.name.length); currentText = currentText.slice(role.match_name.length);
matchFound = true; matchFound = true;
break; break;
} }
@ -99,7 +93,7 @@ export class TextToShotAdapter {
// 查找下一个可能的角色名位置 // 查找下一个可能的角色名位置
let nextRoleIndex = currentText.length; let nextRoleIndex = currentText.length;
for (const role of sortedRoles) { for (const role of sortedRoles) {
const index = currentText.indexOf(role.name); const index = currentText.indexOf(role.match_name);
if (index !== -1 && index < nextRoleIndex) { if (index !== -1 && index < nextRoleIndex) {
nextRoleIndex = index; nextRoleIndex = index;
} }
@ -196,7 +190,7 @@ export class TextToShotAdapter {
* @param lensType LensType * @param lensType LensType
* @returns Paragraph * @returns Paragraph
*/ */
public static fromLensType(lensType: LensType, roles: SimpleCharacter[]): Shot { public static fromLensType(lensType: LensType, roles: ScriptRoleEntity[]): Shot {
const shotDescContent: Paragraph[] = []; const shotDescContent: Paragraph[] = [];
const shotDialogsContent: Paragraph[] = []; const shotDialogsContent: Paragraph[] = [];
@ -213,16 +207,15 @@ export class TextToShotAdapter {
lensType.content.forEach(item => { lensType.content.forEach(item => {
const dialogNodes = TextToShotAdapter.parseText(item.content, roles); const dialogNodes = TextToShotAdapter.parseText(item.content, roles);
// 确保对话内容以角色标记开始 // 确保对话内容以角色标记开始 角色名都大写再匹配
const roleMatch = roles.find(role => role.name === item.roleName); const roleMatch = roles.find(role => role.name.toUpperCase().includes(item.roleName.toUpperCase()));
if (roleMatch) { if (roleMatch) {
const dialogContent: Paragraph = { const dialogContent: Paragraph = {
type: 'paragraph', type: 'paragraph',
content: [{ content: [{
type: 'characterToken', type: 'characterToken',
attrs: { attrs: {
name: roleMatch.name, ...roleMatch
avatar: roleMatch.imageUrl
}}, }},
...dialogNodes ...dialogNodes
] ]
@ -263,7 +256,7 @@ export class TextToShotAdapter {
currentScript += node.text; currentScript += node.text;
} }
if (node.type === 'characterToken') { if (node.type === 'characterToken') {
currentScript += node.attrs.name; currentScript = currentScript + node.attrs.name + ' ' + node.attrs.id;
} }
}); });
} }
@ -282,10 +275,10 @@ export class TextToShotAdapter {
if (node.type === 'characterToken') { if (node.type === 'characterToken') {
// 记录说话角色的名称 // 记录说话角色的名称
if (!firstFindRole) { if (!firstFindRole) {
dialogRoleName = node.attrs.name; dialogRoleName = node.attrs.name + ' [' + node.attrs.id + ']';
firstFindRole = true; firstFindRole = true;
} else { } else {
dialogContent += node.attrs.name; dialogContent += node.attrs.name + ' [' + node.attrs.id + ']';
} }
} else if (node.type === 'text') { } else if (node.type === 'text') {
// 累积对话内容 // 累积对话内容
@ -329,7 +322,7 @@ export class TextToShotAdapter {
} else if (node.type === 'text') { } else if (node.type === 'text') {
text += node.text; text += node.text;
} else if (node.type === 'characterToken') { } else if (node.type === 'characterToken') {
text += node.attrs.name; text += node.attrs.name + ' ' + node.attrs.id;
} }
}); });
} }

View File

@ -43,6 +43,18 @@ export interface RoleEntity {
isChangeRole: boolean; isChangeRole: boolean;
} }
/**
*
*/
export interface ScriptRoleEntity {
/** 唯一标识 */
readonly id: string;
/** 角色名称 */
name: string;
/** 角色照片 */
image_url: string;
}
/** /**
* *
*/ */
@ -76,7 +88,7 @@ export interface VideoSegmentEntity {
video_status: number | null; video_status: number | null;
}[]; }[];
/**视频片段状态 0:视频加载中 1:任务已完成 2:任务失败 */ /**视频片段状态 0:视频加载中 1:任务已完成 2:任务失败 */
status: number; status: number|null;
/**镜头项 */ /**镜头项 */
lens: LensType[]; lens: LensType[];
} }

View File

@ -1,6 +1,6 @@
import { VideoFlowProjectResponse } from "@/api/DTO/movieEdit"; import { VideoFlowProjectResponse } from "@/api/DTO/movieEdit";
import { task_item, VideoSegmentEntityAdapter } from "../adapter/oldErrAdapter"; import { task_item, VideoSegmentEntityAdapter } from "../adapter/oldErrAdapter";
import { VideoSegmentEntity } from "../domain/Entities"; import { ScriptRoleEntity, VideoSegmentEntity } from "../domain/Entities";
import { LensType } from "../domain/valueObject"; import { LensType } from "../domain/valueObject";
import { import {
getShotList, getShotList,
@ -24,7 +24,7 @@ export class VideoSegmentEditUseCase {
* @param projectId ID * @param projectId ID
* @returns Promise<VideoSegmentEntity[]> * @returns Promise<VideoSegmentEntity[]>
*/ */
async getVideoSegmentList(projectId: string,callback?:()=>void): Promise<VideoSegmentEntity[]> { async getVideoSegmentList(projectId: string,callback?:()=>void): Promise<{ segments: VideoSegmentEntity[], roles: ScriptRoleEntity[] }> {
try { try {
this.loading = true; this.loading = true;
@ -35,7 +35,6 @@ export class VideoSegmentEditUseCase {
} }
if(response.data.task_status=="COMPLETED"){ if(response.data.task_status=="COMPLETED"){
callback?.(); callback?.();
} }
const Segments = VideoSegmentEntityAdapter.toVideoSegmentEntity(response.data) || []; const Segments = VideoSegmentEntityAdapter.toVideoSegmentEntity(response.data) || [];
const detail = await detailScriptEpisodeNew({ project_id: projectId }); const detail = await detailScriptEpisodeNew({ project_id: projectId });
@ -43,7 +42,10 @@ export class VideoSegmentEditUseCase {
throw new Error(detail.message || "获取视频片段列表失败"); throw new Error(detail.message || "获取视频片段列表失败");
} }
// 匹配视频片段ID // 匹配视频片段ID
return this.matchVideoSegmentsWithIds(Segments, detail.data); return {
segments: this.matchVideoSegmentsWithIds(Segments, detail.data),
roles: response.data.project_characters
};
} catch (error) { } catch (error) {
console.error("获取视频片段列表失败:", error); console.error("获取视频片段列表失败:", error);
throw error; throw error;
@ -265,7 +267,7 @@ export class VideoSegmentEditUseCase {
this.loading = true; this.loading = true;
// 获取当前项目的视频片段列表 // 获取当前项目的视频片段列表
const segments = await this.getVideoSegmentList(projectId); const { segments } = await this.getVideoSegmentList(projectId);
// 过滤出除当前选中片段外的所有片段 // 过滤出除当前选中片段外的所有片段
const otherSegments = segments.filter(segment => segment.id !== currentSegmentId); const otherSegments = segments.filter(segment => segment.id !== currentSegmentId);

View File

@ -24,6 +24,7 @@ export const useEditData = (tabType: string, originalText?: string) => {
const { const {
videoSegments, videoSegments,
scriptRoles,
getVideoSegmentList, getVideoSegmentList,
setSelectedSegment, setSelectedSegment,
regenerateVideoSegment, regenerateVideoSegment,
@ -104,9 +105,10 @@ export const useEditData = (tabType: string, originalText?: string) => {
}, [scriptBlocksMemo]); }, [scriptBlocksMemo]);
useEffect(() => { useEffect(() => {
console.log('useEditData-----videoSegments', videoSegments); console.log('useEditData-----videoSegments', videoSegments, scriptRoles);
setShotData(videoSegments); setShotData(videoSegments);
}, [videoSegments]); setRoleData(scriptRoles);
}, [videoSegments, scriptRoles]);
useEffect(() => { useEffect(() => {
setRoleData(roleList); setRoleData(roleList);
@ -121,6 +123,7 @@ export const useEditData = (tabType: string, originalText?: string) => {
applyScript, applyScript,
// shot // shot
shotData, shotData,
scriptRoles,
setSelectedSegment, setSelectedSegment,
regenerateVideoSegment, regenerateVideoSegment,
filterRole, filterRole,

View File

@ -165,8 +165,8 @@ CharacterTabContentProps
// 处理替换确认逻辑 // 处理替换确认逻辑
console.log('Selected shots:', selectedShots); console.log('Selected shots:', selectedShots);
console.log('Add to library:', addToLibrary); console.log('Add to library:', addToLibrary);
await applyRoleToSelectedShots(selectedRole || {} as RoleEntity);
setIsReplacePanelOpen(false); setIsReplacePanelOpen(false);
await applyRoleToSelectedShots(selectedRole || {} as RoleEntity);
if(addToLibrary){ if(addToLibrary){
await saveRoleToLibrary(); await saveRoleToLibrary();
} }

View File

@ -57,6 +57,7 @@ export function EditModal({
const characterTabContentRef = useRef<any>(null); const characterTabContentRef = useRef<any>(null);
// 添加一个状态来标记是否是从切换tab触发的提醒 // 添加一个状态来标记是否是从切换tab触发的提醒
const [pendingSwitchTabId, setPendingSwitchTabId] = useState<string | null>(null); const [pendingSwitchTabId, setPendingSwitchTabId] = useState<string | null>(null);
const [disabledBtn, setDisabledBtn] = useState(false);
useEffect(() => { useEffect(() => {
setCurrentIndex(currentSketchIndex); setCurrentIndex(currentSketchIndex);
@ -129,6 +130,7 @@ const [pendingSwitchTabId, setPendingSwitchTabId] = useState<string | null>(null
} }
const handleConfirmGotoFallback = () => { const handleConfirmGotoFallback = () => {
setDisabledBtn(true);
console.log('handleConfirmGotoFallback'); console.log('handleConfirmGotoFallback');
SaveEditUseCase.saveData(); SaveEditUseCase.saveData();
if (activeTab === '0') { if (activeTab === '0') {
@ -140,6 +142,7 @@ const [pendingSwitchTabId, setPendingSwitchTabId] = useState<string | null>(null
setIsRemindFallbackOpen(false); setIsRemindFallbackOpen(false);
// 关闭弹窗 // 关闭弹窗
onClose(); onClose();
setDisabledBtn(false);
} }
const handleCloseRemindFallbackPanel = () => { const handleCloseRemindFallbackPanel = () => {
if (pendingSwitchTabId) { if (pendingSwitchTabId) {
@ -316,18 +319,20 @@ const [pendingSwitchTabId, setPendingSwitchTabId] = useState<string | null>(null
<div className="p-4 border-t border-white/10 bg-black/20"> <div className="p-4 border-t border-white/10 bg-black/20">
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<motion.button <motion.button
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors" className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={handleReset} onClick={handleReset}
disabled={disabledBtn}
> >
Reset Reset
</motion.button> </motion.button>
<motion.button <motion.button
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors" className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => {handleSave()}} onClick={() => {handleSave()}}
disabled={disabledBtn}
> >
Apply Apply
</motion.button> </motion.button>

View File

@ -1,7 +1,8 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Check, X, CircleAlert, ArrowLeft, ArrowRight } from 'lucide-react'; import { Check, X, CircleAlert, ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
import { cn } from '@/public/lib/utils'; import { cn } from '@/public/lib/utils';
import { throttle } from 'lodash';
interface ReplacePanelProps { interface ReplacePanelProps {
isLoading: boolean; isLoading: boolean;
@ -29,33 +30,12 @@ export function ReplacePanel({
); );
const [addToLibrary, setAddToLibrary] = useState(false); const [addToLibrary, setAddToLibrary] = useState(false);
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null); const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
const [isAtStart, setIsAtStart] = useState(true);
const [isAtEnd, setIsAtEnd] = useState(false);
const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({}); const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({});
const shotsRef = useRef<HTMLDivElement>(null); const shotsRef = useRef<HTMLDivElement>(null);
// 检查滚动位置 useEffect(() => {
const checkScrollPosition = () => { console.log('replace-panel-shots', shots);
if (!shotsRef.current) return; }, [shots]);
const { scrollLeft, scrollWidth, clientWidth } = shotsRef.current;
setIsAtStart(scrollLeft <= 0);
setIsAtEnd(Math.ceil(scrollLeft + clientWidth) >= scrollWidth);
};
// 添加滚动事件监听
React.useEffect(() => {
const shotsElement = shotsRef.current;
if (!shotsElement) return;
shotsElement.addEventListener('scroll', checkScrollPosition);
// 初始检查
checkScrollPosition();
return () => {
shotsElement.removeEventListener('scroll', checkScrollPosition);
};
}, []);
const handleShotToggle = (shotId: string) => { const handleShotToggle = (shotId: string) => {
// setSelectedShots(prev => // setSelectedShots(prev =>
@ -65,10 +45,6 @@ export function ReplacePanel({
// ); // );
}; };
const handleSelectAllShots = (checked: boolean) => {
setSelectedShots(checked ? shots.map(shot => shot.id) : []);
};
const handleMouseEnter = (shotId: string) => { const handleMouseEnter = (shotId: string) => {
setHoveredVideoId(shotId); setHoveredVideoId(shotId);
if (videoRefs.current[shotId]) { if (videoRefs.current[shotId]) {
@ -84,8 +60,22 @@ export function ReplacePanel({
} }
}; };
const handleConfirm = () => { const throttledConfirm = React.useCallback(
throttle(() => {
onConfirm(selectedShots, addToLibrary); onConfirm(selectedShots, addToLibrary);
}, 1000, { trailing: false }), // 1秒内只能触发一次不要执行最后一次调用
[selectedShots, addToLibrary, onConfirm]
);
// 在组件卸载时取消节流函数中的定时器
React.useEffect(() => {
return () => {
throttledConfirm.cancel();
};
}, [throttledConfirm]);
const handleConfirm = () => {
throttledConfirm();
}; };
const handleLeftArrowClick = () => { const handleLeftArrowClick = () => {
@ -158,7 +148,22 @@ export function ReplacePanel({
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
{shot.videoUrl && shot.videoUrl.length > 0 && ( <>
{(shot.status === 0 || !shot.videoUrl.length) && (
<div className="w-full h-full absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="text-white text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
</div>
)}
{shot.status === 2 && (
<div className="w-full h-full absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="text-white text-sm">
<CircleAlert className="w-4 h-4" />
</div>
</div>
)}
{shot.status === 1 && shot.videoUrl.length && (
<video <video
ref={el => { ref={el => {
if (el) videoRefs.current[shot.id] = el; if (el) videoRefs.current[shot.id] = el;
@ -170,17 +175,7 @@ export function ReplacePanel({
playsInline playsInline
/> />
)} )}
{(!shot.videoUrl || shot.videoUrl.length === 0) && (
<>
<img
src={shot.sketchUrl}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="text-white text-sm">Generating...</div>
</div>
</> </>
)}
</motion.div> </motion.div>
))} ))}
</div> </div>
@ -189,18 +184,18 @@ export function ReplacePanel({
<div <div
className={cn( className={cn(
"absolute top-1/2 -translate-y-1/2 left-0 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center transition-opacity duration-200", "absolute top-1/2 -translate-y-1/2 left-0 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center transition-opacity duration-200",
isAtStart ? "opacity-30 cursor-not-allowed" : "opacity-100 cursor-pointer hover:bg-black/70" "opacity-100 cursor-pointer hover:bg-black/70"
)} )}
onClick={() => !isAtStart && handleLeftArrowClick()} onClick={() => handleLeftArrowClick()}
> >
<ArrowLeft className="w-4 h-4 text-white" /> <ArrowLeft className="w-4 h-4 text-white" />
</div> </div>
<div <div
className={cn( className={cn(
"absolute top-1/2 -translate-y-1/2 right-0 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center transition-opacity duration-200", "absolute top-1/2 -translate-y-1/2 right-0 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center transition-opacity duration-200",
isAtEnd ? "opacity-30 cursor-not-allowed" : "opacity-100 cursor-pointer hover:bg-black/70" "opacity-100 cursor-pointer hover:bg-black/70"
)} )}
onClick={() => !isAtEnd && handleRightArrowClick()} onClick={() => handleRightArrowClick()}
> >
<ArrowRight className="w-4 h-4 text-white" /> <ArrowRight className="w-4 h-4 text-white" />
</div> </div>

View File

@ -3,28 +3,16 @@ import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tip
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { Check, CircleUserRound } from 'lucide-react' import { Check, CircleUserRound } from 'lucide-react'
import { ScriptRoleEntity } from '@/app/service/domain/Entities';
interface CharacterAttributes {
id: string | null;
name: string;
avatar: string;
gender: string;
age: string;
}
interface Role {
name: string;
url: string;
}
interface CharacterTokenOptions { interface CharacterTokenOptions {
roles?: Role[]; roles?: ScriptRoleEntity[];
} }
export function CharacterToken(props: ReactNodeViewProps) { export function CharacterToken(props: ReactNodeViewProps) {
const [showRoleList, setShowRoleList] = useState(false); const [showRoleList, setShowRoleList] = useState(false);
const [listPosition, setListPosition] = useState({ top: 0, left: 0 }); const [listPosition, setListPosition] = useState({ top: 0, left: 0 });
const { name, avatar } = props.node.attrs as CharacterAttributes; const { name } = props.node.attrs as ScriptRoleEntity;
const extension = props.extension as Node<CharacterTokenOptions>; const extension = props.extension as Node<CharacterTokenOptions>;
const roles = extension.options.roles || []; const roles = extension.options.roles || [];
@ -76,7 +64,7 @@ export function CharacterToken(props: ReactNodeViewProps) {
} }
}, [showRoleList]); }, [showRoleList]);
const handleRoleSelect = (role: Role) => { const handleRoleSelect = (role: ScriptRoleEntity) => {
const { editor } = props; const { editor } = props;
const pos = props.getPos(); const pos = props.getPos();
@ -84,8 +72,9 @@ export function CharacterToken(props: ReactNodeViewProps) {
const { tr } = editor.state; const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, { tr.setNodeMarkup(pos, undefined, {
...props.node.attrs, ...props.node.attrs,
id: role.id,
name: role.name, name: role.name,
avatar: role.url, image_url: role.image_url,
}); });
editor.view.dispatch(tr); editor.view.dispatch(tr);
} }
@ -137,7 +126,7 @@ export function CharacterToken(props: ReactNodeViewProps) {
> >
<div className="relative"> <div className="relative">
<img <img
src={role.url} src={role.image_url}
alt={role.name} alt={role.name}
className={`w-10 h-10 rounded-full border transition-all duration-200 className={`w-10 h-10 rounded-full border transition-all duration-200
${isSelected ? 'border-blue-400 border-2' : 'border-white/20'}`} ${isSelected ? 'border-blue-400 border-2' : 'border-white/20'}`}
@ -158,7 +147,7 @@ export function CharacterToken(props: ReactNodeViewProps) {
data-alt="role-item" data-alt="role-item"
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200 className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200
${name === 'Voiceover' ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`} ${name === 'Voiceover' ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`}
onClick={() => handleRoleSelect({ name: 'Voiceover', url: '' })} onClick={() => handleRoleSelect({ name: 'Voiceover', image_url: '', id: 'voiceover' } as ScriptRoleEntity)}
> >
<div className="relative"> <div className="relative">
<CircleUserRound <CircleUserRound

View File

@ -23,6 +23,7 @@ export const ShotTabContent = (props: ShotTabContentProps) => {
const { const {
loading, loading,
shotData, shotData,
scriptRoles,
setSelectedSegment, setSelectedSegment,
regenerateVideoSegment, regenerateVideoSegment,
filterRole, filterRole,
@ -50,8 +51,8 @@ export const ShotTabContent = (props: ShotTabContentProps) => {
const [pendingRegeneration, setPendingRegeneration] = useState(false); const [pendingRegeneration, setPendingRegeneration] = useState(false);
useEffect(() => { useEffect(() => {
console.log('shotTabContent-----roles', roles); console.log('shotTabContent-----scriptRoles', scriptRoles);
}, [roles]); }, [scriptRoles]);
useEffect(() => { useEffect(() => {
if (pendingRegeneration) { if (pendingRegeneration) {
@ -243,7 +244,7 @@ export const ShotTabContent = (props: ShotTabContentProps) => {
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> <Loader2 className="w-4 h-4 animate-spin text-blue-500" />
</div> </div>
)} )}
{shot.status === 1 && ( {shot.status === 1 && shot.videoUrl[0] ? (
<video <video
src={shot.videoUrl[0].video_url} src={shot.videoUrl[0].video_url}
className="w-full h-full object-cover" className="w-full h-full object-cover"
@ -253,6 +254,10 @@ export const ShotTabContent = (props: ShotTabContentProps) => {
onMouseEnter={(e) => e.currentTarget.play()} onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => e.currentTarget.pause()} onMouseLeave={(e) => e.currentTarget.pause()}
/> />
) : (
<div className="w-full h-full flex items-center justify-center bg-red-500/10">
<CircleX className="w-4 h-4 text-red-500" />
</div>
)} )}
{/* 任务失败 */} {/* 任务失败 */}
{shot.status === 2 && ( {shot.status === 2 && (
@ -395,7 +400,7 @@ export const ShotTabContent = (props: ShotTabContentProps) => {
<div className='space-y-4 col-span-1' key={selectedIndex}> <div className='space-y-4 col-span-1' key={selectedIndex}>
<ShotsEditor <ShotsEditor
ref={shotsEditorRef} ref={shotsEditorRef}
roles={roles} roles={scriptRoles}
shotInfo={shotData[selectedIndex].lens} shotInfo={shotData[selectedIndex].lens}
style={{height: 'calc(100% - 4rem)'}} style={{height: 'calc(100% - 4rem)'}}
/> />