diff --git a/app/service/adapter/textToShot.ts b/app/service/adapter/textToShot.ts new file mode 100644 index 0000000..828f012 --- /dev/null +++ b/app/service/adapter/textToShot.ts @@ -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 + ); + } +} \ No newline at end of file diff --git a/components/pages/work-flow/use-edit-data.tsx b/components/pages/work-flow/use-edit-data.tsx new file mode 100644 index 0000000..32181c5 --- /dev/null +++ b/components/pages/work-flow/use-edit-data.tsx @@ -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(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 + } +} \ No newline at end of file diff --git a/components/ui/shot-editor/CharacterToken.tsx b/components/ui/shot-editor/CharacterToken.tsx index 946cd5b..b4d2f7a 100644 --- a/components/ui/shot-editor/CharacterToken.tsx +++ b/components/ui/shot-editor/CharacterToken.tsx @@ -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 - 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; + const roles = extension.options.roles || []; + + const tokenRef = useRef(null); + const listRef = useRef(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 ( 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 + }} >
{roles.map((role) => { @@ -93,6 +152,26 @@ export function CharacterToken(props: ReactNodeViewProps) {
); })} + + {/* 旁白 */} +
handleRoleSelect({ name: '旁白', url: '' })} + > +
+ + {name === '旁白' && ( +
+ +
+ )} +
+ 旁白 +
)} diff --git a/components/ui/shot-editor/ShotsEditor.tsx b/components/ui/shot-editor/ShotsEditor.tsx index c6ec83a..5c5d81d 100644 --- a/components/ui/shot-editor/ShotsEditor.tsx +++ b/components/ui/shot-editor/ShotsEditor.tsx @@ -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(({ roles }, ref) => { +export const ShotsEditor = forwardRef(({ roles, shotInfo, style }, ref) => { const [currentShotIndex, setCurrentShotIndex] = useState(0); - const [shots, setShots] = useState(mockShotsData); + const [shots, setShots] = useState([]); const descEditorRef = useRef(null); const dialogEditorRef = useRef(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(({ roles }, ref) => // 暴露方法给父组件 React.useImperativeHandle(ref, () => ({ addShot, + getShotInfo })); return ( -
+
{/* 分镜标签(可删除)、新增分镜标签 */}
{shots.map((shot, index) => (
(({ roles }, ref) =>
{/* 分镜内容 */} -
- {/* 分镜描述 添加角色 */} -
-
- - 分镜描述 - + {shots[currentShotIndex] && ( +
+ {/* 分镜描述 添加角色 */} +
+
+ + 分镜描述 + +
+ + {/* 分镜描述内容 可视化编辑 */} + {}} + roles={roles} + />
- {/* 分镜描述内容 可视化编辑 */} - {}} - roles={roles} - /> -
+ {/* 分镜对话 添加角色 添加对话 */} +
+
+ + 分镜对话 + + +
- {/* 分镜对话 添加角色 添加对话 */} -
-
- - 分镜对话 - - + {/* 分镜对话内容 可视化编辑 */} + {}} + roles={roles} + />
- - {/* 分镜对话内容 可视化编辑 */} - {}} - roles={roles} - /> -
+ )} +
); }); \ No newline at end of file diff --git a/components/ui/shot-tab-content.tsx b/components/ui/shot-tab-content.tsx index ce16e1c..7916a0b 100644 --- a/components/ui/shot-tab-content.tsx +++ b/components/ui/shot-tab-content.tsx @@ -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(null); + const { + loading, + shotData, + setSelectedSegment, + regenerateVideoSegment + } = useEditData('shot'); + const [selectedIndex, setSelectedIndex] = useState(0); + const videoPlayerRef = useRef(null); const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying); - const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false); const [detections, setDetections] = useState([]); 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([]); - const shotsEditorRef = useRef(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 (
-

No sketch data

+
+

Loading...

+
+ ); + } + + // 如果没有数据,显示空状态 + if (shotData.length === 0) { + return ( +
+
); } @@ -158,28 +187,41 @@ export function ShotTabContent({ handleSelectShot(i)} > - {sketches.map((sketch, index) => ( + {shotData.map((shot, index) => ( -