更新文本适配器,新增高亮文本解析功能,支持将标签转换为节点数组。同时,扩展角色编辑器以支持高亮文本的处理,优化内容渲染逻辑,确保角色描述的准确性和可视化效果。

This commit is contained in:
北枳 2025-08-12 14:30:10 +08:00
parent dbd803391d
commit 4f8d2188cf
7 changed files with 211 additions and 21 deletions

View File

@ -1,4 +1,4 @@
import { ContentItem, LensType, SimpleCharacter } from '../domain/valueObject';
import { ContentItem, LensType, SimpleCharacter, TagValueObject } from '../domain/valueObject';
// 定义角色属性接口
interface CharacterAttributes {
@ -8,6 +8,12 @@ interface CharacterAttributes {
avatar: string;
}
// 定义高亮属性接口
interface HighlightAttributes {
text: string;
color: string;
}
// 定义文本节点接口
interface TextNode {
type: 'text';
@ -20,8 +26,14 @@ interface CharacterTokenNode {
attrs: CharacterAttributes;
}
// 定义高亮节点接口
interface HighlightNode {
type: 'highlightText';
attrs: HighlightAttributes;
}
// 定义内容节点类型(文本或角色标记)
type ContentNode = TextNode | CharacterTokenNode;
type ContentNode = TextNode | CharacterTokenNode | HighlightNode;
// 定义段落接口
interface Paragraph {
@ -100,6 +112,68 @@ export class TextToShotAdapter {
return nodes;
}
/**
* tag并转换为节点数组
* @param text
* @param tags
* @returns ContentNode[]
*/
public static parseHighlight(text: string, tags: TagValueObject[]): ContentNode[] {
const nodes: ContentNode[] = [];
let currentText = text;
// 按内容长度降序排序,避免短名称匹配到长名称的一部分
const sortedTags = [...tags].sort((a, b) => String(b.content).length - String(a.content).length);
while (currentText.length > 0) {
let matchFound = false;
// 尝试匹配
for (const tag of sortedTags) {
if (currentText.startsWith(String(tag.content))) {
// 如果当前文本以tag内容开头
if (currentText.length > String(tag.content).length) {
// 添加标记节点
nodes.push({
type: 'highlightText',
attrs: {
text: String(tag.content),
color: tag?.color || 'yellow'
}
});
// 移除已处理的tag内容
currentText = currentText.slice(String(tag.content).length);
matchFound = true;
break;
}
}
}
if (!matchFound) {
// 如果没有找到tag匹配处理普通文本
// 查找下一个可能的tag内容位置
let nextTagIndex = currentText.length;
for (const tag of sortedTags) {
const index = currentText.indexOf(String(tag.content));
if (index !== -1 && index < nextTagIndex) {
nextTagIndex = index;
}
}
// 添加文本节点
const textContent = currentText.slice(0, nextTagIndex);
if (textContent) {
nodes.push({
type: 'text',
text: textContent
});
}
// 移除已处理的文本
currentText = currentText.slice(nextTagIndex);
}
}
return nodes;
}
private readonly ShotData: Shot;
constructor(shotData: Shot) {
this.ShotData = shotData;
@ -225,4 +299,29 @@ export class TextToShotAdapter {
content
);
}
public static fromTextToRole(description: string, tags: TagValueObject[]): Paragraph[] {
const paragraph: Paragraph = {
type: 'paragraph',
content: []
};
const highlightNodes = TextToShotAdapter.parseHighlight(description, tags);
paragraph.content.push(...highlightNodes);
return [paragraph];
}
public static fromRoleToText(paragraphs: Paragraph[]): string {
let text = '';
paragraphs.forEach(paragraph => {
paragraph.content.forEach(node => {
if (node.type === 'highlightText') {
text += node.attrs.text;
} else if (node.type === 'text') {
text += node.text;
} else if (node.type === 'characterToken') {
text += node.attrs.name;
}
});
});
return text;
}
}

View File

@ -93,6 +93,8 @@ export interface TagValueObject {
loadingProgress: number;
/** 禁止编辑 */
disableEdit: boolean;
/** 颜色 */
color?: string;
}

View File

@ -4,6 +4,20 @@ import { useShotService } from "@/app/service/Interaction/ShotService";
import { useSearchParams } from 'next/navigation';
import { useRoleServiceHook } from "@/app/service/Interaction/RoleService";
const mockRoleData = [{
id: '1',
name: 'KAPI',
imageUrl: 'https://c.huiying.video/images/420bfb4f-b5d4-475c-a2fb-5e40af770b29.jpg',
generateText: 'A 3 to 5-year-old boy with a light to medium olive skin tone, full cheeks, and warm brown eyes. He has short, straight, dark brown hair, neatly styled with a part on his left side. His facial structure includes a small, slightly upturned nose. His lips are typically held in a slight, gentle, closed-mouth smile, which can part to show his small, white teeth.',
tags: [
{ id: '1', content: 'boy', color: 'red' },
{ id: '2', content: '3 to 5-year-old', color: 'yellow' },
{ id: '3', content: 'light to medium olive skin tone', color: 'green' },
{ id: '4', content: 'full cheeks', color: 'blue' },
{ id: '5', content: 'warm brown eyes', color: 'purple' },
]
}]
export const useEditData = (tabType: string) => {
const searchParams = useSearchParams();
const projectId = searchParams.get('episodeId') || '';
@ -26,7 +40,10 @@ export const useEditData = (tabType: string) => {
userRoleLibrary,
fetchRoleList,
selectRole,
fetchUserRoleLibrary
fetchUserRoleLibrary,
optimizeRoleText,
updateRoleText,
regenerateRole
} = useRoleServiceHook();
useEffect(() => {
@ -39,6 +56,7 @@ export const useEditData = (tabType: string) => {
setLoading(false);
});
} else if (tabType === 'role') {
fetchUserRoleLibrary();
fetchRoleList(projectId).then(() => {
setLoading(false);
}).catch((err) => {
@ -55,7 +73,8 @@ export const useEditData = (tabType: string) => {
}, [videoSegments]);
useEffect(() => {
setRoleData(roleList);
// setRoleData(roleList);
setRoleData(mockRoleData);
}, [roleList]);
return {
@ -70,6 +89,8 @@ export const useEditData = (tabType: string) => {
selectRole,
selectedRole,
userRoleLibrary,
fetchUserRoleLibrary
optimizeRoleText,
updateRoleText,
regenerateRole
}
}

View File

@ -1,11 +1,16 @@
import { useState, useRef } from "react";
import React, { useState, useRef, useEffect, forwardRef } from "react";
import { motion } from "framer-motion";
import { Sparkles, X, Plus, RefreshCw } from 'lucide-react';
import MainEditor from "./main-editor/MainEditor";
import { cn } from "@/public/lib/utils";
import { TextToShotAdapter } from "@/app/service/adapter/textToShot";
import { TagValueObject } from "@/app/service/domain/valueObject";
interface CharacterEditorProps {
className?: string;
description: string;
highlight: TagValueObject[];
onSmartPolish: (text: string) => void;
}
const mockContent = [
@ -30,20 +35,50 @@ const mockContent = [
},
];
export default function CharacterEditor({
export const CharacterEditor = forwardRef<any, CharacterEditorProps>(({
className,
}: CharacterEditorProps) {
description,
highlight,
onSmartPolish
}, ref) => {
const [isOptimizing, setIsOptimizing] = useState(false);
const [content, setContent] = useState<any[]>([]);
const [isInit, setIsInit] = useState(true);
const handleSmartPolish = async () => {
setIsOptimizing(true);
console.log('-==========handleSmartPolish===========-', content);
const text = TextToShotAdapter.fromRoleToText(content);
console.log('-==========getText===========-', text);
onSmartPolish(text);
};
useEffect(() => {
setIsInit(true);
console.log('-==========description===========-', description);
console.log('-==========highlight===========-', highlight);
const paragraphs = TextToShotAdapter.fromTextToRole(description, highlight);
console.log('-==========paragraphs===========-', paragraphs);
setContent(paragraphs);
setTimeout(() => {
setIsInit(false);
setIsOptimizing(false);
}, 100);
}, [description, highlight]);
// 暴露方法给父组件
React.useImperativeHandle(ref, () => ({
getRoleText: () => {
return TextToShotAdapter.fromRoleToText(content);
}
}));
return (
<div className={cn("space-y-2 border border-white/10 relative p-2 rounded-[0.5rem] pb-12", className)}>
{/* 自由输入区域 */}
<MainEditor content={mockContent} />
{
!isInit && <MainEditor content={content} onChangeContent={setContent} />
}
{/* 智能润色按钮 */}
<motion.button
@ -60,4 +95,4 @@ export default function CharacterEditor({
</motion.button>
</div>
);
}
});

View File

@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ImageUp, Library, Play, Pause, RefreshCw, Wand2, Users, Check, ReplaceAll, X, TriangleAlert } from 'lucide-react';
import { cn } from '@/public/lib/utils';
import CharacterEditor from './character-editor';
import { CharacterEditor } from './character-editor';
import ImageBlurTransition from './ImageBlurTransition';
import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
@ -72,6 +72,7 @@ export function CharacterTabContent({
const fileInputRef = useRef<HTMLInputElement>(null);
const [enableAnimation, setEnableAnimation] = useState(true);
const [showAddToLibrary, setShowAddToLibrary] = useState(true);
const characterEditorRef = useRef<any>(null);
const {
loading,
@ -79,15 +80,25 @@ export function CharacterTabContent({
selectRole,
selectedRole,
userRoleLibrary,
fetchUserRoleLibrary
optimizeRoleText,
updateRoleText,
regenerateRole
} = useEditData('role');
useEffect(() => {
console.log('-==========roleData===========-', roleData);
if (roleData.length > 0) {
selectRole(roleData[selectRoleIndex].id);
}
}, [selectRoleIndex, roleData]);
const handleSmartPolish = (text: string) => {
// 首先更新
updateRoleText(text);
// 然后调用优化角色文本
optimizeRoleText(text);
};
const handleConfirmGotoReplace = () => {
setIsRemindReplacePanelOpen(false);
setIsReplacePanelOpen(true);
@ -148,12 +159,16 @@ export function CharacterTabContent({
const handleOpenReplaceLibrary = () => {
setIsReplaceLibraryOpen(true);
setShowAddToLibrary(true);
fetchUserRoleLibrary();
};
const handleRegenerate = () => {
console.log('Regenerate');
setShowAddToLibrary(true);
const text = characterEditorRef.current.getRoleText();
console.log('-==========text===========-', text);
// 重生前 更新 当前项 generateText
updateRoleText(text);
// 然后调用重新生成角色
regenerateRole();
};
const handleUploadClick = () => {
@ -260,8 +275,8 @@ export function CharacterTabContent({
{/* 角色预览图 */}
<div className="w-full h-full mx-auto rounded-lg relative group">
<ImageBlurTransition
src={selectedRole?.imageUrl || ''}
alt={selectedRole?.name}
src={roleData[selectRoleIndex].imageUrl || ''}
alt={roleData[selectRoleIndex].name}
width='100%'
height='100%'
enableAnimation={enableAnimation}
@ -293,7 +308,11 @@ export function CharacterTabContent({
{/* 右列:角色信息 */}
<div className="space-y-4">
<CharacterEditor
ref={characterEditorRef}
className="min-h-[calc(100%-4rem)]"
description={roleData[selectRoleIndex].generateText || ''}
highlight={roleData[selectRoleIndex].tags || []}
onSmartPolish={handleSmartPolish}
/>
{/* 重新生成按钮、替换形象按钮 */}
<div className="grid grid-cols-2 gap-2">

View File

@ -1,19 +1,27 @@
import React, { useState, useCallback, useEffect } from 'react';
import { flushSync } from 'react-dom';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { HighlightTextExtension } from './HighlightText';
interface MainEditorProps {
content: any[];
onChangeContent?: (content: any[]) => void;
}
export default function MainEditor({ content }: MainEditorProps) {
export default function MainEditor({ content, onChangeContent }: MainEditorProps) {
const [renderContent, setRenderContent] = useState<any[]>(content);
useEffect(() => {
onChangeContent?.(renderContent);;
}, [renderContent]);
const editor = useEditor({
extensions: [
StarterKit,
HighlightTextExtension,
],
content: { type: 'doc', content: content },
content: { type: 'doc', content: renderContent },
editorProps: {
attributes: {
class: 'prose prose-invert max-w-none focus:outline-none'
@ -23,6 +31,12 @@ export default function MainEditor({ content }: MainEditorProps) {
onCreate: ({ editor }) => {
editor.setOptions({ editable: true })
},
onUpdate: ({ editor }) => {
const json = editor.getJSON();
flushSync(() => {
setRenderContent(json.content);
});
},
});
if (!editor) {

View File

@ -45,7 +45,7 @@ export function ShotTabContent({
if (shotData.length > 0) {
setSelectedSegment(shotData[selectedIndex]);
}
}, [selectedIndex]);
}, [selectedIndex, shotData]);
// 处理扫描开始
const handleScan = () => {