forked from 77media/video-flow
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
import { useState, useRef } from "react";
|
||
import { motion } from "framer-motion";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Sparkles, X, Plus, Clock, MapPin, Sun, Moon, Cloud, CloudRain, CloudSnow, CloudLightning, Palette } from 'lucide-react';
|
||
import { cn } from "@/public/lib/utils";
|
||
import ContentEditable from 'react-contenteditable';
|
||
|
||
interface SceneAttribute {
|
||
key: string;
|
||
label: string;
|
||
value: string;
|
||
type: 'text' | 'number' | 'select';
|
||
options?: string[];
|
||
icon?: any;
|
||
}
|
||
|
||
interface SceneEnvironment {
|
||
time: {
|
||
period: '清晨' | '上午' | '中午' | '下午' | '傍晚' | '夜晚' | '深夜';
|
||
specific?: string;
|
||
};
|
||
location: {
|
||
main: string;
|
||
detail?: string;
|
||
};
|
||
weather: {
|
||
type: '晴天' | '多云' | '雨天' | '雪天' | '雷暴' | '阴天';
|
||
description?: string;
|
||
};
|
||
atmosphere: {
|
||
lighting: string;
|
||
mood: string;
|
||
};
|
||
}
|
||
|
||
interface SceneEditorProps {
|
||
initialDescription?: string;
|
||
onDescriptionChange?: (description: string) => void;
|
||
onAttributesChange?: (attributes: SceneAttribute[]) => void;
|
||
onEnvironmentChange?: (environment: SceneEnvironment) => void;
|
||
className?: string;
|
||
}
|
||
|
||
// 天气图标映射
|
||
const weatherIcons = {
|
||
'晴天': Sun,
|
||
'多云': Cloud,
|
||
'雨天': CloudRain,
|
||
'雪天': CloudSnow,
|
||
'雷暴': CloudLightning,
|
||
'阴天': Cloud
|
||
};
|
||
|
||
const mockParse = (text: string): { attributes: SceneAttribute[], environment: SceneEnvironment } => {
|
||
// 模拟结构化解析结果
|
||
const attributes: SceneAttribute[] = [];
|
||
|
||
const environment: SceneEnvironment = {
|
||
time: {
|
||
period: "下午",
|
||
specific: "午后2点左右"
|
||
},
|
||
location: {
|
||
main: "教室",
|
||
detail: "高中教室,靠窗的位置"
|
||
},
|
||
weather: {
|
||
type: "晴天",
|
||
description: "阳光明媚,微风轻拂"
|
||
},
|
||
atmosphere: {
|
||
lighting: "自然光线充足,温暖的阳光从窗户斜射入室内",
|
||
mood: "安静祥和,充满生机与活力"
|
||
}
|
||
};
|
||
|
||
// 添加环境属性到结构化标签
|
||
const environmentAttributes: SceneAttribute[] = [
|
||
{
|
||
key: "time",
|
||
label: "时间",
|
||
value: `${environment.time.period}${environment.time.specific ? `,${environment.time.specific}` : ''}`,
|
||
type: "text",
|
||
icon: Clock
|
||
},
|
||
{
|
||
key: "location",
|
||
label: "地点",
|
||
value: `${environment.location.main}${environment.location.detail ? `,${environment.location.detail}` : ''}`,
|
||
type: "text",
|
||
icon: MapPin
|
||
},
|
||
{
|
||
key: "weather",
|
||
label: "天气",
|
||
value: `${environment.weather.type}${environment.weather.description ? `,${environment.weather.description}` : ''}`,
|
||
type: "text",
|
||
icon: weatherIcons[environment.weather.type as keyof typeof weatherIcons]
|
||
},
|
||
{
|
||
key: "atmosphere",
|
||
label: "氛围",
|
||
value: `${environment.atmosphere.lighting},${environment.atmosphere.mood}`,
|
||
type: "text",
|
||
icon: Palette
|
||
}
|
||
];
|
||
|
||
return {
|
||
attributes: [...attributes, ...environmentAttributes],
|
||
environment
|
||
};
|
||
};
|
||
|
||
export default function SceneEditor({
|
||
initialDescription = "一个银白短发的精灵女性,大约20岁,肤色白皙,身材高挑,身着白色连衣裙。在教室里,阳光透过窗户洒在地板上,营造出温暖而安静的氛围。",
|
||
onDescriptionChange,
|
||
onAttributesChange,
|
||
onEnvironmentChange,
|
||
className
|
||
}: SceneEditorProps) {
|
||
const [inputText, setInputText] = useState(initialDescription);
|
||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||
const [customTags, setCustomTags] = useState<string[]>([]);
|
||
const [newTag, setNewTag] = useState("");
|
||
const parseResult = useRef(mockParse(initialDescription));
|
||
const contentEditableRef = useRef<HTMLElement>(null);
|
||
|
||
const handleTextChange = (e: { target: { value: string } }) => {
|
||
// 移除 HTML 标签,保留换行
|
||
const value = e.target.value;
|
||
setInputText(value);
|
||
onDescriptionChange?.(value);
|
||
|
||
// 重新解析文本
|
||
const newParseResult = mockParse(value);
|
||
parseResult.current = newParseResult;
|
||
onAttributesChange?.(newParseResult.attributes);
|
||
onEnvironmentChange?.(newParseResult.environment);
|
||
};
|
||
|
||
// 格式化文本为 HTML
|
||
const formatTextToHtml = (text: string) => {
|
||
return text
|
||
.split('\n')
|
||
.map(line => line || '<br>')
|
||
.join('<div>');
|
||
};
|
||
|
||
const handleSmartPolish = async () => {
|
||
setIsOptimizing(true);
|
||
try {
|
||
const polishedText = "一位拥有银白短发、白皙肌肤的高挑精灵少女,年龄约二十岁,气质神秘优雅。在阳光明媚的午后,她站在教室靠窗的位置,温暖的阳光从窗户斜射入室内,为这个安静祥和的空间注入了生机与活力。";
|
||
setInputText(polishedText);
|
||
const newParseResult = mockParse(polishedText);
|
||
parseResult.current = newParseResult;
|
||
onDescriptionChange?.(polishedText);
|
||
onAttributesChange?.(newParseResult.attributes);
|
||
onEnvironmentChange?.(newParseResult.environment);
|
||
} finally {
|
||
setIsOptimizing(false);
|
||
}
|
||
};
|
||
|
||
const handleAttributeChange = (attr: SceneAttribute, newValue: string) => {
|
||
// 移除 HTML 标签
|
||
newValue = newValue.replace(/<[^>]*>/g, '');
|
||
|
||
// 更新描述文本
|
||
let newText = inputText;
|
||
if (attr.type === "number" && attr.key === "age") {
|
||
newText = newText.replace(/\d+岁/, `${newValue}岁`);
|
||
} else {
|
||
newText = newText.replace(new RegExp(attr.value, 'g'), newValue);
|
||
}
|
||
|
||
// 更新属性值
|
||
const newAttr = { ...attr, value: newValue };
|
||
const newAttributes = parseResult.current.attributes.map(a =>
|
||
a.key === attr.key ? newAttr : a
|
||
);
|
||
|
||
// 重新解析文本以更新环境信息
|
||
const newParseResult = mockParse(newText);
|
||
parseResult.current = newParseResult;
|
||
|
||
setInputText(newText);
|
||
onDescriptionChange?.(newText);
|
||
onAttributesChange?.(newAttributes);
|
||
onEnvironmentChange?.(newParseResult.environment);
|
||
};
|
||
|
||
return (
|
||
<div className={cn("space-y-2", className)}>
|
||
{/* 自由输入区域 */}
|
||
<div className="relative">
|
||
<ContentEditable
|
||
innerRef={contentEditableRef}
|
||
html={formatTextToHtml(inputText)}
|
||
onChange={handleTextChange}
|
||
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
||
rounded-lg border border-white/10 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||
whitespace-pre-wrap break-words"
|
||
placeholder="用自然语言描述场景,比如:阳光透过窗户洒在教室的地板上..."
|
||
/>
|
||
|
||
{/* 智能润色按钮 */}
|
||
<motion.button
|
||
onClick={handleSmartPolish}
|
||
disabled={isOptimizing}
|
||
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
|
||
bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 rounded-full
|
||
transition-colors text-xs disabled:opacity-50"
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
>
|
||
<Sparkles className="w-3.5 h-3.5" />
|
||
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
|
||
</motion.button>
|
||
</div>
|
||
|
||
{/* 结构化属性标签 */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{parseResult.current.attributes.map((attr) => (
|
||
<motion.div
|
||
key={attr.key}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full
|
||
bg-white/5 hover:bg-white/10 transition-colors
|
||
border border-white/20 group"
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
>
|
||
{attr.icon && <attr.icon className="w-3.5 h-3.5 text-white/70" />}
|
||
<span className="text-sm text-white/90 flex-shrink-0">{attr.label}:</span>
|
||
<ContentEditable
|
||
html={attr.value}
|
||
onChange={(e) => handleAttributeChange(attr, e.target.value)}
|
||
className="text-sm text-white/90 min-w-[1em] focus:outline-none
|
||
border-b border-transparent focus:border-white/30
|
||
hover:border-white/20"
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
(e.target as HTMLElement).blur();
|
||
}
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 自定义标签区域 */}
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
{customTags.map((tag) => (
|
||
<motion.div
|
||
key={tag}
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
className="group flex items-center gap-1 px-3 py-1.5 bg-white/5
|
||
rounded-full hover:bg-white/10 transition-colors"
|
||
>
|
||
<span className="text-sm text-white/90">{tag}</span>
|
||
<button
|
||
onClick={() => {
|
||
setCustomTags(tags => tags.filter(t => t !== tag));
|
||
setInputText((text: string) => text.replace(new RegExp(`[,。]?${tag}[,。]?`), ""));
|
||
}}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<X className="w-3 h-3 text-white/70" />
|
||
</button>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={newTag}
|
||
onChange={(e) => setNewTag(e.target.value)}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter' && newTag.trim()) {
|
||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||
setNewTag("");
|
||
}
|
||
}}
|
||
placeholder="添加自定义标签..."
|
||
className="flex-1 px-3 py-2 bg-white/5 border-none rounded-lg text-sm
|
||
focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-white/30"
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
if (newTag.trim()) {
|
||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||
setNewTag("");
|
||
}
|
||
}}
|
||
className="p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||
>
|
||
<Plus className="w-5 h-5 text-white/70" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|