forked from 77media/video-flow
新增文本解析适配器和视频编辑数据处理逻辑,重构分镜编辑器以支持角色和对话内容的动态加载,优化角色标记和文本节点的处理方式。
This commit is contained in:
parent
c48b706e9a
commit
03aa092a08
221
app/service/adapter/textToShot.ts
Normal file
221
app/service/adapter/textToShot.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import { ContentItem, LensType, SimpleCharacter } from '../domain/valueObject';
|
||||
|
||||
// 定义角色属性接口
|
||||
interface CharacterAttributes {
|
||||
name: string;
|
||||
// gender: string;
|
||||
// age: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
// 定义文本节点接口
|
||||
interface TextNode {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// 定义角色标记节点接口
|
||||
interface CharacterTokenNode {
|
||||
type: 'characterToken';
|
||||
attrs: CharacterAttributes;
|
||||
}
|
||||
|
||||
// 定义内容节点类型(文本或角色标记)
|
||||
type ContentNode = TextNode | CharacterTokenNode;
|
||||
|
||||
// 定义段落接口
|
||||
interface Paragraph {
|
||||
type: 'paragraph';
|
||||
content: ContentNode[];
|
||||
}
|
||||
|
||||
// 定义shot 接口
|
||||
interface Shot {
|
||||
name: string;
|
||||
shotDescContent: Paragraph[];
|
||||
shotDialogsContent: Paragraph[];
|
||||
}
|
||||
|
||||
export class TextToShotAdapter {
|
||||
/**
|
||||
* 解析文本,识别角色并转换为节点数组
|
||||
* @param text 要解析的文本
|
||||
* @param roles 角色列表
|
||||
* @returns ContentNode[] 节点数组
|
||||
*/
|
||||
public static parseText(text: string, roles: SimpleCharacter[]): ContentNode[] {
|
||||
const nodes: ContentNode[] = [];
|
||||
let currentText = text;
|
||||
|
||||
// 按角色名称长度降序排序,避免短名称匹配到长名称的一部分
|
||||
const sortedRoles = [...roles].sort((a, b) => b.name.length - a.name.length);
|
||||
|
||||
while (currentText.length > 0) {
|
||||
let matchFound = false;
|
||||
|
||||
// 尝试匹配角色
|
||||
for (const role of sortedRoles) {
|
||||
if (currentText.startsWith(role.name)) {
|
||||
// 如果当前文本以角色名开头
|
||||
if (currentText.length > role.name.length) {
|
||||
// 添加角色标记节点
|
||||
nodes.push({
|
||||
type: 'characterToken',
|
||||
attrs: {
|
||||
name: role.name,
|
||||
avatar: role.imageUrl
|
||||
}
|
||||
});
|
||||
// 移除已处理的角色名
|
||||
currentText = currentText.slice(role.name.length);
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
// 如果没有找到角色匹配,处理普通文本
|
||||
// 查找下一个可能的角色名位置
|
||||
let nextRoleIndex = currentText.length;
|
||||
for (const role of sortedRoles) {
|
||||
const index = currentText.indexOf(role.name);
|
||||
if (index !== -1 && index < nextRoleIndex) {
|
||||
nextRoleIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加文本节点
|
||||
const textContent = currentText.slice(0, nextRoleIndex);
|
||||
if (textContent) {
|
||||
nodes.push({
|
||||
type: 'text',
|
||||
text: textContent
|
||||
});
|
||||
}
|
||||
// 移除已处理的文本
|
||||
currentText = currentText.slice(nextRoleIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
private readonly ShotData: Shot;
|
||||
constructor(shotData: Shot) {
|
||||
this.ShotData = shotData;
|
||||
}
|
||||
|
||||
toShot() {
|
||||
return this.ShotData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 LensType 转换为 Paragraph 格式
|
||||
* @param lensType LensType 实例
|
||||
* @returns Paragraph 格式的数据
|
||||
*/
|
||||
public static fromLensType(lensType: LensType, roles: SimpleCharacter[]): Shot {
|
||||
const shotDescContent: Paragraph[] = [];
|
||||
const shotDialogsContent: Paragraph[] = [];
|
||||
|
||||
// 处理镜头描述 通过roles name 匹配镜头描述中出现的角色 并添加到shotDescContent
|
||||
if (lensType.script) {
|
||||
const descNodes = TextToShotAdapter.parseText(lensType.script, roles);
|
||||
shotDescContent.push({
|
||||
type: 'paragraph',
|
||||
content: descNodes
|
||||
});
|
||||
}
|
||||
|
||||
// 处理对话内容 通过roles name 匹配对话内容中出现的角色 并添加到shotDialogsContent
|
||||
lensType.content.forEach(item => {
|
||||
const dialogNodes = TextToShotAdapter.parseText(item.content, roles);
|
||||
|
||||
// 确保对话内容以角色标记开始
|
||||
const roleMatch = roles.find(role => role.name === item.roleName);
|
||||
if (roleMatch) {
|
||||
const dialogContent: Paragraph = {
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'characterToken',
|
||||
attrs: {
|
||||
name: roleMatch.name,
|
||||
avatar: roleMatch.imageUrl
|
||||
}},
|
||||
...dialogNodes
|
||||
]
|
||||
};
|
||||
|
||||
shotDialogsContent.push(dialogContent);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: lensType.name,
|
||||
shotDescContent,
|
||||
shotDialogsContent
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Paragraph 转换为 LensType 格式
|
||||
* @param paragraphData Paragraph 格式的数据
|
||||
* @returns LensType 实例
|
||||
*/
|
||||
/**
|
||||
* 将 Shot 格式转换为 LensType 格式
|
||||
* @param shotData Shot 格式的数据
|
||||
* @returns LensType 实例
|
||||
*/
|
||||
public static toLensType(shotData: Shot): LensType {
|
||||
const content: ContentItem[] = [];
|
||||
let currentScript = '';
|
||||
|
||||
// 处理镜头描述
|
||||
if (shotData.shotDescContent.length > 0) {
|
||||
// 合并所有描述段落的文本内容
|
||||
shotData.shotDescContent.forEach(paragraph => {
|
||||
paragraph.content.forEach(node => {
|
||||
if (node.type === 'text') {
|
||||
currentScript += node.text;
|
||||
}
|
||||
if (node.type === 'characterToken') {
|
||||
currentScript += node.attrs.name;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 处理对话内容
|
||||
shotData.shotDialogsContent.forEach(paragraph => {
|
||||
let dialogRoleName = '';
|
||||
let dialogContent = '';
|
||||
|
||||
// 遍历段落内容
|
||||
paragraph.content.forEach((node, index) => {
|
||||
if (node.type === 'characterToken') {
|
||||
// 记录说话角色的名称
|
||||
index === 0 && (dialogRoleName = node.attrs.name);
|
||||
index !== 0 && (dialogContent += node.attrs.name);
|
||||
} else if (node.type === 'text') {
|
||||
// 累积对话内容
|
||||
dialogContent += node.text;
|
||||
}
|
||||
});
|
||||
|
||||
// 如果有角色名和对话内容,添加到结果中
|
||||
if (dialogRoleName && dialogContent) {
|
||||
content.push({
|
||||
roleName: dialogRoleName,
|
||||
content: dialogContent.trim()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new LensType(
|
||||
shotData.name, // 使用 Shot 中的 name
|
||||
currentScript.trim(),
|
||||
content
|
||||
);
|
||||
}
|
||||
}
|
||||
108
components/pages/work-flow/use-edit-data.tsx
Normal file
108
components/pages/work-flow/use-edit-data.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useShotService } from "@/app/service/Interaction/ShotService";
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
const mockShotData = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Shot 1',
|
||||
sketchUrl: 'https://example.com/sketch.png',
|
||||
videoUrl: ['https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1-0-20250725023719.mp4'],
|
||||
status: 1, // 0:视频加载中 1:任务已完成 2:任务失败
|
||||
lens: [
|
||||
{
|
||||
name: 'Shot 1',
|
||||
script: '镜头聚焦在 President Alfred King 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
|
||||
content: [{
|
||||
roleName: 'President Alfred King',
|
||||
content: '我需要一个镜头,镜头聚焦在 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
|
||||
}]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
id: '2',
|
||||
name: 'Shot 2',
|
||||
sketchUrl: 'https://example.com/sketch.png',
|
||||
videoUrl: ['https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3-0-20250725023725.mp4'],
|
||||
status: 1, // 0:视频加载中 1:任务已完成 2:任务失败
|
||||
lens: [
|
||||
{
|
||||
name: 'Shot 1',
|
||||
script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
|
||||
content: [{
|
||||
roleName: 'Samuel Ryan',
|
||||
content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
|
||||
}]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
id: '3',
|
||||
name: 'Shot 3',
|
||||
sketchUrl: 'https://example.com/sketch.png',
|
||||
videoUrl: [],
|
||||
status: 0, // 0:视频加载中 1:任务已完成 2:任务失败
|
||||
lens: [
|
||||
{
|
||||
name: 'Shot 1',
|
||||
script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
|
||||
content: [{
|
||||
roleName: 'Samuel Ryan',
|
||||
content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
|
||||
}]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
id: '4',
|
||||
name: 'Shot 4',
|
||||
sketchUrl: 'https://example.com/sketch.png',
|
||||
videoUrl: [],
|
||||
status: 2, // 0:视频加载中 1:任务已完成 2:任务失败
|
||||
lens: [
|
||||
{
|
||||
name: 'Shot 1',
|
||||
script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
|
||||
content: [{
|
||||
roleName: 'Samuel Ryan',
|
||||
content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const useEditData = (tabType: string) => {
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get('episodeId') || '';
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shotData, setShotData] = useState<any[]>(mockShotData);
|
||||
|
||||
const {
|
||||
videoSegments,
|
||||
getVideoSegmentList,
|
||||
setSelectedSegment,
|
||||
regenerateVideoSegment
|
||||
} = useShotService();
|
||||
|
||||
useEffect(() => {
|
||||
if (tabType === 'shot') {
|
||||
getVideoSegmentList(projectId).then(() => {
|
||||
console.log('useEditData-----videoSegments', videoSegments);
|
||||
// setShotData(videoSegments);
|
||||
setLoading(false);
|
||||
}).catch((err) => {
|
||||
console.log('useEditData-----err', err);
|
||||
setShotData(mockShotData);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
}, [tabType]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
shotData,
|
||||
setSelectedSegment,
|
||||
regenerateVideoSegment
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Check, CircleUserRound } from 'lucide-react'
|
||||
|
||||
interface CharacterAttributes {
|
||||
id: string | null;
|
||||
@ -22,10 +22,59 @@ interface CharacterTokenOptions {
|
||||
}
|
||||
|
||||
export function CharacterToken(props: ReactNodeViewProps) {
|
||||
const [showRoleList, setShowRoleList] = useState(false)
|
||||
const { name, avatar } = props.node.attrs as CharacterAttributes
|
||||
const extension = props.extension as Node<CharacterTokenOptions>
|
||||
const roles = extension.options.roles || []
|
||||
const [showRoleList, setShowRoleList] = useState(false);
|
||||
const [listPosition, setListPosition] = useState({ top: 0, left: 0 });
|
||||
const { name, avatar } = props.node.attrs as CharacterAttributes;
|
||||
const extension = props.extension as Node<CharacterTokenOptions>;
|
||||
const roles = extension.options.roles || [];
|
||||
|
||||
const tokenRef = useRef<HTMLSpanElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 计算下拉列表的位置
|
||||
const updateListPosition = () => {
|
||||
if (!tokenRef.current || !listRef.current) return;
|
||||
|
||||
const tokenRect = tokenRef.current.getBoundingClientRect();
|
||||
const listRect = listRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
// 计算理想的顶部位置(在token下方)
|
||||
let top = tokenRect.bottom + 8; // 8px 间距
|
||||
let left = tokenRect.left;
|
||||
|
||||
// 检查是否超出底部
|
||||
if (top + listRect.height > viewportHeight) {
|
||||
// 如果超出底部,将列表显示在token上方
|
||||
top = tokenRect.top - listRect.height - 8;
|
||||
}
|
||||
|
||||
// 检查是否超出右侧
|
||||
if (left + listRect.width > viewportWidth) {
|
||||
// 如果超出右侧,将列表右对齐
|
||||
left = viewportWidth - listRect.width - 8;
|
||||
}
|
||||
|
||||
// 确保不会超出左侧
|
||||
left = Math.max(8, left);
|
||||
|
||||
setListPosition({ top, left });
|
||||
};
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
if (showRoleList) {
|
||||
updateListPosition();
|
||||
window.addEventListener('resize', updateListPosition);
|
||||
window.addEventListener('scroll', updateListPosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateListPosition);
|
||||
window.removeEventListener('scroll', updateListPosition);
|
||||
};
|
||||
}
|
||||
}, [showRoleList]);
|
||||
|
||||
const handleRoleSelect = (role: Role) => {
|
||||
const { editor } = props;
|
||||
@ -47,11 +96,16 @@ export function CharacterToken(props: ReactNodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="span"
|
||||
ref={tokenRef}
|
||||
data-alt="character-token"
|
||||
contentEditable={false}
|
||||
className="relative inline text-blue-400 cursor-pointer hover:text-blue-300 transition-colors duration-200"
|
||||
onMouseLeave={() => setShowRoleList(false)}
|
||||
onMouseEnter={() => setShowRoleList(true)}
|
||||
onMouseEnter={() => {
|
||||
setShowRoleList(true);
|
||||
// 延迟一帧执行位置更新,确保列表已渲染
|
||||
requestAnimationFrame(updateListPosition);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
|
||||
@ -63,7 +117,12 @@ export function CharacterToken(props: ReactNodeViewProps) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-full left-0 mt-2 w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-2 z-50"
|
||||
ref={listRef}
|
||||
className="fixed w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-2 z-[51]"
|
||||
style={{
|
||||
top: listPosition.top,
|
||||
left: listPosition.left
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{roles.map((role) => {
|
||||
@ -93,6 +152,26 @@ export function CharacterToken(props: ReactNodeViewProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 旁白 */}
|
||||
<div
|
||||
data-alt="role-item"
|
||||
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200
|
||||
${name === '旁白' ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`}
|
||||
onClick={() => handleRoleSelect({ name: '旁白', url: '' })}
|
||||
>
|
||||
<div className="relative">
|
||||
<CircleUserRound
|
||||
className={`w-10 h-10 rounded-full border transition-all duration-200`}
|
||||
/>
|
||||
{name === '旁白' && (
|
||||
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-1">旁白</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import React, { forwardRef, useRef, useState } from "react";
|
||||
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { Plus, X, UserRoundPlus, MessageCirclePlus, MessageCircleMore, ClipboardType } from "lucide-react";
|
||||
import ShotEditor from "./ShotEditor";
|
||||
import { toast } from "sonner";
|
||||
import { TextToShotAdapter } from "@/app/service/adapter/textToShot";
|
||||
|
||||
|
||||
interface Shot {
|
||||
id: string;
|
||||
name: string;
|
||||
shotDescContent: any[];
|
||||
shotDialogsContent: any[];
|
||||
}
|
||||
@ -21,7 +23,7 @@ interface CharacterToken {
|
||||
|
||||
const mockShotsData = [
|
||||
{
|
||||
id: 'shot1',
|
||||
name: 'shot1',
|
||||
shotDescContent: [{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
@ -43,7 +45,7 @@ const mockShotsData = [
|
||||
]
|
||||
|
||||
const createEmptyShot = (): Shot => ({
|
||||
id: `shot${Date.now()}`,
|
||||
name: `shot${Date.now()}`,
|
||||
shotDescContent: [{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
@ -60,14 +62,35 @@ const createEmptyShot = (): Shot => ({
|
||||
|
||||
interface ShotsEditorProps {
|
||||
roles: any[];
|
||||
shotInfo: any[];
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles }, ref) => {
|
||||
export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles, shotInfo, style }, ref) => {
|
||||
const [currentShotIndex, setCurrentShotIndex] = useState(0);
|
||||
const [shots, setShots] = useState<Shot[]>(mockShotsData);
|
||||
const [shots, setShots] = useState<Shot[]>([]);
|
||||
const descEditorRef = useRef<any>(null);
|
||||
const dialogEditorRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('-==========shotInfo===========-', shotInfo);
|
||||
if (shotInfo) {
|
||||
const shots = shotInfo.map((shot) => {
|
||||
return TextToShotAdapter.fromLensType(shot, roles);
|
||||
});
|
||||
console.log('-==========shots===========-', shots);
|
||||
setShots(shots as Shot[]);
|
||||
}
|
||||
}, [shotInfo]);
|
||||
|
||||
const getShotInfo = () => {
|
||||
console.log('-==========shots===========-', shots);
|
||||
const shotInfo = shots.map((shot) => {
|
||||
return TextToShotAdapter.toLensType(shot);
|
||||
});
|
||||
return shotInfo;
|
||||
}
|
||||
|
||||
const addShot = () => {
|
||||
if (shots.length > 3) {
|
||||
toast.error('不能超过4个分镜', {
|
||||
@ -167,16 +190,17 @@ export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles }, ref) =>
|
||||
// 暴露方法给父组件
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
addShot,
|
||||
getShotInfo
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2" style={style}>
|
||||
{/* 分镜标签(可删除)、新增分镜标签 */}
|
||||
<div data-alt="shots-tabs" className="flex items-center gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{shots.map((shot, index) => (
|
||||
<div
|
||||
key={shot.id}
|
||||
key={shot.name}
|
||||
data-alt="shot-tab"
|
||||
className={`group flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer transition-all
|
||||
${currentShotIndex === index
|
||||
@ -212,60 +236,63 @@ export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles }, ref) =>
|
||||
</div>
|
||||
|
||||
{/* 分镜内容 */}
|
||||
<div className="flex flex-col gap-3 border border-white/10 p-2 rounded-[0.5rem]" key={currentShotIndex}>
|
||||
{/* 分镜描述 添加角色 */}
|
||||
<div data-alt="shot-description-section" className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardType className="w-5 h-5 text-white/60" />
|
||||
<span className="text-lg font-medium text-white/60">分镜描述</span>
|
||||
<button
|
||||
data-alt="add-character-desc"
|
||||
className="p-1 rounded-md"
|
||||
onClick={() => handleAddCharacterToDesc()}
|
||||
>
|
||||
<UserRoundPlus className="w-5 h-5 text-blue-600" />
|
||||
</button>
|
||||
{shots[currentShotIndex] && (
|
||||
<div className="flex flex-col gap-3 border border-white/10 p-2 rounded-[0.5rem]" key={currentShotIndex} style={{minHeight: 'calc(100% - 2rem)'}}>
|
||||
{/* 分镜描述 添加角色 */}
|
||||
<div data-alt="shot-description-section" className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardType className="w-5 h-5 text-white/60" />
|
||||
<span className="text-lg font-medium text-white/60">分镜描述</span>
|
||||
<button
|
||||
data-alt="add-character-desc"
|
||||
className="p-1 rounded-md"
|
||||
onClick={() => handleAddCharacterToDesc()}
|
||||
>
|
||||
<UserRoundPlus className="w-5 h-5 text-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分镜描述内容 可视化编辑 */}
|
||||
<ShotEditor
|
||||
ref={descEditorRef}
|
||||
content={shots[currentShotIndex].shotDescContent}
|
||||
onCharacterClick={() => {}}
|
||||
roles={roles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分镜描述内容 可视化编辑 */}
|
||||
<ShotEditor
|
||||
ref={descEditorRef}
|
||||
content={shots[currentShotIndex].shotDescContent}
|
||||
onCharacterClick={() => {}}
|
||||
roles={roles}
|
||||
/>
|
||||
</div>
|
||||
{/* 分镜对话 添加角色 添加对话 */}
|
||||
<div data-alt="shot-dialog-section" className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircleMore className="w-5 h-5 text-white/60" />
|
||||
<span className="text-lg font-medium text-white/60">分镜对话</span>
|
||||
<button
|
||||
data-alt="add-character-dialog"
|
||||
className="p-1 rounded-md"
|
||||
onClick={() => handleAddCharacterToDialog()}
|
||||
>
|
||||
<UserRoundPlus className="w-5 h-5 text-blue-600" />
|
||||
</button>
|
||||
<button
|
||||
data-alt="add-new-dialog"
|
||||
className="p-1 rounded-md"
|
||||
onClick={() => handleAddNewDialog()}
|
||||
>
|
||||
<MessageCirclePlus className="w-5 h-5 text-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分镜对话 添加角色 添加对话 */}
|
||||
<div data-alt="shot-dialog-section" className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircleMore className="w-5 h-5 text-white/60" />
|
||||
<span className="text-lg font-medium text-white/60">分镜对话</span>
|
||||
<button
|
||||
data-alt="add-character-dialog"
|
||||
className="p-1 rounded-md"
|
||||
onClick={() => handleAddCharacterToDialog()}
|
||||
>
|
||||
<UserRoundPlus className="w-5 h-5 text-blue-600" />
|
||||
</button>
|
||||
<button
|
||||
data-alt="add-new-dialog"
|
||||
className="p-1 rounded-md"
|
||||
onClick={() => handleAddNewDialog()}
|
||||
>
|
||||
<MessageCirclePlus className="w-5 h-5 text-blue-600" />
|
||||
</button>
|
||||
{/* 分镜对话内容 可视化编辑 */}
|
||||
<ShotEditor
|
||||
ref={dialogEditorRef}
|
||||
content={shots[currentShotIndex].shotDialogsContent}
|
||||
onCharacterClick={() => {}}
|
||||
roles={roles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分镜对话内容 可视化编辑 */}
|
||||
<ShotEditor
|
||||
ref={dialogEditorRef}
|
||||
content={shots[currentShotIndex].shotDialogsContent}
|
||||
onCharacterClick={() => {}}
|
||||
roles={roles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -2,18 +2,15 @@
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Video, User, MapPin, Settings, Loader2, X, Plus } from 'lucide-react';
|
||||
import { GlassIconButton } from './glass-icon-button';
|
||||
import { RefreshCw, User, Loader2, X, Plus, Video, CircleX } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { ReplaceVideoModal } from './replace-video-modal';
|
||||
import { MediaPropertiesModal } from './media-properties-modal';
|
||||
import { DramaLineChart } from './drama-line-chart';
|
||||
import { PersonDetection, PersonDetectionScene } from './person-detection';
|
||||
import { ShotsEditor } from './shot-editor/ShotsEditor';
|
||||
import { CharacterLibrarySelector } from './character-library-selector';
|
||||
import FloatingGlassPanel from './FloatingGlassPanel';
|
||||
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
|
||||
import HorizontalScroller from './HorizontalScroller';
|
||||
import { useEditData } from '@/components/pages/work-flow/use-edit-data';
|
||||
|
||||
interface ShotTabContentProps {
|
||||
taskSketch: any[];
|
||||
@ -30,10 +27,16 @@ export function ShotTabContent({
|
||||
isPlaying: externalIsPlaying = true,
|
||||
roles = []
|
||||
}: ShotTabContentProps) {
|
||||
const editorRef = useRef<any>(null);
|
||||
const {
|
||||
loading,
|
||||
shotData,
|
||||
setSelectedSegment,
|
||||
regenerateVideoSegment
|
||||
} = useEditData('shot');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const videoPlayerRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying);
|
||||
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
|
||||
|
||||
const [detections, setDetections] = useState<PersonDetection[]>([]);
|
||||
const [scanState, setScanState] = useState<'idle' | 'scanning' | 'detected'>('idle');
|
||||
@ -41,19 +44,21 @@ export function ShotTabContent({
|
||||
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
|
||||
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
|
||||
|
||||
const [shots, setShots] = useState<any[]>([]);
|
||||
|
||||
const shotsEditorRef = useRef<any>(null);
|
||||
|
||||
// 监听当前选中index变化
|
||||
useEffect(() => {
|
||||
console.log('shotTabContent-----shotData', shotData);
|
||||
if (shotData.length > 0) {
|
||||
setSelectedSegment(shotData[selectedIndex]);
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// 监听外部播放状态变化
|
||||
useEffect(() => {
|
||||
setIsPlaying(externalIsPlaying);
|
||||
}, [externalIsPlaying]);
|
||||
|
||||
// 确保 taskSketch 是数组
|
||||
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
|
||||
|
||||
// 视频播放控制
|
||||
useEffect(() => {
|
||||
if (videoPlayerRef.current) {
|
||||
@ -125,6 +130,18 @@ export function ShotTabContent({
|
||||
|
||||
};
|
||||
|
||||
// 点击按钮重新生成
|
||||
const handleRegenerate = () => {
|
||||
console.log('regenerate');
|
||||
const shotInfo = shotsEditorRef.current.getShotInfo();
|
||||
console.log('shotTabContent-----shotInfo', shotInfo);
|
||||
setSelectedSegment({
|
||||
...shotData[selectedIndex],
|
||||
lens: shotInfo
|
||||
});
|
||||
regenerateVideoSegment();
|
||||
};
|
||||
|
||||
// 新增分镜
|
||||
const handleAddShot = () => {
|
||||
console.log('add shot');
|
||||
@ -134,14 +151,26 @@ export function ShotTabContent({
|
||||
// 切换选择分镜
|
||||
const handleSelectShot = (index: number) => {
|
||||
// 切换前 判断数据是否发生变化
|
||||
onSketchSelect(index);
|
||||
|
||||
setSelectedIndex(index);
|
||||
};
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (sketches.length === 0) {
|
||||
// 如果loading 显示loading状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
||||
<p>No sketch data</p>
|
||||
<div className="w-12 h-12 mb-4 animate-spin rounded-full border-b-2 border-blue-600" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (shotData.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
||||
<Video className="w-16 h-16 mb-4" />
|
||||
<p>No video data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -158,28 +187,41 @@ export function ShotTabContent({
|
||||
<HorizontalScroller
|
||||
itemWidth={128}
|
||||
gap={0}
|
||||
selectedIndex={currentSketchIndex}
|
||||
selectedIndex={selectedIndex}
|
||||
onItemClick={(i: number) => handleSelectShot(i)}
|
||||
>
|
||||
{sketches.map((sketch, index) => (
|
||||
{shotData.map((shot, index) => (
|
||||
<motion.div
|
||||
key={sketch.id || index}
|
||||
key={shot.id || index}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
||||
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
selectedIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<video
|
||||
src={sketch.url}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onMouseEnter={(e) => e.currentTarget.play()}
|
||||
onMouseLeave={(e) => e.currentTarget.pause()}
|
||||
/>
|
||||
{shot.status === 0 && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
{shot.status === 1 && (
|
||||
<video
|
||||
src={shot.videoUrl[0]}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onMouseEnter={(e) => e.currentTarget.play()}
|
||||
onMouseLeave={(e) => e.currentTarget.pause()}
|
||||
/>
|
||||
)}
|
||||
{/* 任务失败 */}
|
||||
{shot.status === 2 && (
|
||||
<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>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<span className="text-xs text-white/90">Shot {index + 1}</span>
|
||||
</div>
|
||||
@ -202,14 +244,14 @@ export function ShotTabContent({
|
||||
<HorizontalScroller
|
||||
itemWidth={'auto'}
|
||||
gap={0}
|
||||
selectedIndex={currentSketchIndex}
|
||||
selectedIndex={selectedIndex}
|
||||
onItemClick={(i: number) => handleSelectShot(i)}
|
||||
>
|
||||
{sketches.map((video, index) => {
|
||||
const isActive = currentSketchIndex === index;
|
||||
{shotData.map((shot, index) => {
|
||||
const isActive = selectedIndex === index;
|
||||
return (
|
||||
<motion.div
|
||||
key={video.id || index}
|
||||
key={shot.id || index}
|
||||
className={cn(
|
||||
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
||||
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
||||
@ -220,10 +262,16 @@ export function ShotTabContent({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
Shot {index + 1}
|
||||
<span className="text-sm whitespace-nowrap flex items-center gap-1">
|
||||
<span className='text-white/50'>Shot {index + 1}</span>
|
||||
{shot.status === 0 && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
)}
|
||||
{shot.status === 2 && (
|
||||
<CircleX className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
</span>
|
||||
{index < sketches.length - 1 && (
|
||||
{index < shotData.length - 1 && (
|
||||
<span className="text-white/20">|</span>
|
||||
)}
|
||||
</div>
|
||||
@ -249,94 +297,66 @@ export function ShotTabContent({
|
||||
{/* 视频预览和操作 */}
|
||||
<div className="space-y-4 col-span-1">
|
||||
{/* 选中的视频预览 */}
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden relative group"
|
||||
layoutId={`video-preview-${currentSketchIndex}`}
|
||||
>
|
||||
<PersonDetectionScene
|
||||
videoSrc={sketches[currentSketchIndex]?.url}
|
||||
detections={detections}
|
||||
triggerScan={scanState === 'scanning'}
|
||||
onScanTimeout={handleScanTimeout}
|
||||
onScanExit={handleScanTimeout}
|
||||
onDetectionsChange={handleDetectionsChange}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
|
||||
{/* <video
|
||||
ref={videoPlayerRef}
|
||||
src={sketches[currentSketchIndex]?.url}
|
||||
className="w-full h-full object-cover"
|
||||
loop
|
||||
autoPlay={false}
|
||||
playsInline
|
||||
controls
|
||||
muted={isMuted}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
/> */}
|
||||
|
||||
<motion.div className='absolute top-4 right-4 flex gap-2'>
|
||||
{/* 人物替换按钮 */}
|
||||
<motion.button
|
||||
onClick={() => handleScan()}
|
||||
className={`p-2 backdrop-blur-sm transition-colors z-10 rounded-full
|
||||
${scanState === 'detected'
|
||||
? 'bg-cyan-500/50 hover:bg-cyan-500/70 text-white'
|
||||
: 'bg-black/50 hover:bg-black/70 text-white'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
<>
|
||||
{shotData[selectedIndex]?.status === 0 && (
|
||||
<div className="w-full h-full flex items-center gap-1 justify-center rounded-lg bg-black/30">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-white/50">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
{shotData[selectedIndex]?.status === 1 && (
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden relative group"
|
||||
layoutId={`video-preview-${selectedIndex}`}
|
||||
>
|
||||
{scanState === 'scanning' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : scanState === 'detected' ? (
|
||||
<X className="w-4 h-4" />
|
||||
) : (
|
||||
<User className="w-4 h-4" />
|
||||
)}
|
||||
</motion.button>
|
||||
{/* 场景替换按钮 */}
|
||||
{/* <motion.button
|
||||
onClick={() => console.log('Replace scene')}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
</motion.button> */}
|
||||
{/* 运镜按钮 */}
|
||||
{/* <motion.button
|
||||
onClick={() => console.log('Replace shot')}
|
||||
disabled={true}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
</motion.button> */}
|
||||
|
||||
{/* 更多设置 点击打开 More properties 弹窗 */}
|
||||
{/* <motion.button
|
||||
className='p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10'
|
||||
style={{textDecorationLine: 'underline'}}
|
||||
onClick={() => setIsMediaPropertiesModalOpen(true)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</motion.button> */}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<PersonDetectionScene
|
||||
videoSrc={shotData[selectedIndex]?.videoUrl[0]}
|
||||
detections={detections}
|
||||
triggerScan={scanState === 'scanning'}
|
||||
onScanTimeout={handleScanTimeout}
|
||||
onScanExit={handleScanTimeout}
|
||||
onDetectionsChange={handleDetectionsChange}
|
||||
onPersonClick={handlePersonClick}
|
||||
/>
|
||||
<motion.div className='absolute top-4 right-4 flex gap-2'>
|
||||
{/* 人物替换按钮 */}
|
||||
<motion.button
|
||||
onClick={() => handleScan()}
|
||||
className={`p-2 backdrop-blur-sm transition-colors z-10 rounded-full
|
||||
${scanState === 'detected'
|
||||
? 'bg-cyan-500/50 hover:bg-cyan-500/70 text-white'
|
||||
: 'bg-black/50 hover:bg-black/70 text-white'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{scanState === 'scanning' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : scanState === 'detected' ? (
|
||||
<X className="w-4 h-4" />
|
||||
) : (
|
||||
<User className="w-4 h-4" />
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
{shotData[selectedIndex]?.status === 2 && (
|
||||
<div className="w-full h-full flex gap-1 items-center justify-center rounded-lg bg-red-500/10">
|
||||
<CircleX className="w-4 h-4 text-red-500" />
|
||||
<span className="text-white/50">任务失败,点击重新生成</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* 基础配置 */}
|
||||
<div className='space-y-4 col-span-1'>
|
||||
<div className='space-y-4 col-span-1' key={selectedIndex}>
|
||||
<ShotsEditor
|
||||
ref={shotsEditorRef}
|
||||
roles={roles}
|
||||
shotInfo={shotData[selectedIndex].lens}
|
||||
style={{height: 'calc(100% - 4rem)'}}
|
||||
/>
|
||||
|
||||
{/* 重新生成按钮、新增分镜按钮 */}
|
||||
@ -352,7 +372,7 @@ export function ShotTabContent({
|
||||
<span>Add Shot</span>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
onClick={() => handleRegenerate()}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
@ -367,15 +387,6 @@ export function ShotTabContent({
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* Media Properties 弹窗 */}
|
||||
<MediaPropertiesModal
|
||||
isOpen={isMediaPropertiesModalOpen}
|
||||
onClose={() => setIsMediaPropertiesModalOpen(false)}
|
||||
taskSketch={taskSketch}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
onSketchSelect={onSketchSelect}
|
||||
/>
|
||||
|
||||
<FloatingGlassPanel
|
||||
open={isReplacePanelOpen}
|
||||
width='66vw'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user