更新 ScriptService 以支持解析脚本块的内容类型,新增 useScriptData 自定义 Hook 以管理剧本数据,优化工作流组件以集成新的剧本数据处理逻辑,调整 MediaViewer 组件以支持暂停工作流功能,重构 mock 数据以符合新的结构。

This commit is contained in:
北枳 2025-08-07 14:04:42 +08:00
parent 6df87250fb
commit 0c006cfd5e
10 changed files with 76 additions and 229 deletions

View File

@ -385,7 +385,7 @@ export const useScriptService = (): UseScriptService => {
const scriptBlocksMemo = useMemo((): ScriptBlock[] => {
return [
parseScriptBlock('synopsis', 'Logline', synopsis || ''),
parseScriptBlock('categories', 'GENRE', categories.join(', ') || ''),
parseScriptBlock('categories', 'GENRE', categories.join(', ') || '', 'tag'),
parseScriptBlock('protagonist', 'Core Identity', protagonist || ''),
parseScriptBlock('incitingIncident', 'The Inciting Incident', incitingIncident || ''),
parseScriptBlock('problem', 'The Problem & New Goal', problem || ''),

View File

@ -1,4 +1,4 @@
import { ScriptBlock, ScriptData } from "@/components/script-renderer/types";
import { ScriptEditKey } from "../usecase/ScriptEditUseCase";
/**
*
@ -10,15 +10,15 @@ import { ScriptEditKey } from "../usecase/ScriptEditUseCase";
export function parseScriptBlock(
key: ScriptEditKey,
headerName: string,
scriptText: string
): ScriptBlock {
scriptText: string,
contentType?: 'paragraph' | 'bold' | 'italic' | 'heading' | 'tag',
) {
return {
id: key,
title: headerName,
type: "core",
content: [
{
type: "paragraph",
type: contentType || "paragraph",
text: scriptText,
},
],

View File

@ -13,6 +13,7 @@ import { usePlaybackControls } from "./work-flow/use-playback-controls";
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast } from "lucide-react";
import { motion } from "framer-motion";
import { GlassIconButton } from '@/components/ui/glass-icon-button';
import { useScriptData } from "./work-flow/use-script-data";
export default function WorkFlow() {
const containerRef = useRef<HTMLDivElement>(null);
@ -22,7 +23,6 @@ export default function WorkFlow() {
// 使用自定义 hooks 管理状态
const {
taskObject,
scriptData,
taskSketch,
taskScenes,
taskShotSketch,
@ -57,6 +57,10 @@ export default function WorkFlow() {
playTimerRef,
} = usePlaybackControls(taskSketch, taskVideos, currentStep);
const {
scriptData
} = useScriptData();
// 跟踪是否已经自动开始播放过,避免重复触发
const hasAutoStartedRef = useRef(false);
@ -188,12 +192,13 @@ export default function WorkFlow() {
onToggleVideoPlay={toggleVideoPlay}
onTogglePlay={togglePlay}
final={final}
setIsPauseWorkFlow={setIsPauseWorkFlow}
/>
</ErrorBoundary>
</div>
)}
</div>
<div className="imageGrid-ymZV9z hide-scrollbar" style={{ display: currentStep === '6' ? 'none' : 'block' }}>
<div className="imageGrid-ymZV9z hide-scrollbar" style={{ display: (currentStep === '6' || currentStep === '0') ? 'none' : 'block' }}>
<ErrorBoundary>
<ThumbnailGrid
isLoading={isLoading}

View File

@ -25,6 +25,7 @@ interface MediaViewerProps {
onToggleVideoPlay: () => void;
onTogglePlay: () => void;
final?: any;
setIsPauseWorkFlow: (isPause: boolean) => void;
}
export function MediaViewer({
@ -42,7 +43,8 @@ export function MediaViewer({
onEditModalOpen,
onToggleVideoPlay,
onTogglePlay,
final
final,
setIsPauseWorkFlow
}: MediaViewerProps) {
const mainVideoRef = useRef<HTMLVideoElement>(null);
const finalVideoRef = useRef<HTMLVideoElement>(null);
@ -806,7 +808,7 @@ export function MediaViewer({
<div className="relative w-full h-full bg-white/10 rounded-lg overflow-hidden p-2">
{
mockScriptData ? (
<ScriptRenderer data={mockScriptData} />
<ScriptRenderer data={mockScriptData} setIsPauseWorkFlow={setIsPauseWorkFlow} />
) : (
<div className="flex gap-2 w-full h-full">
<div className="w-[70%] h-full rounded-lg gap-2 flex flex-col">

View File

@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { useScriptService } from "@/app/service/Interaction/ScriptService";
import { useSearchParams } from 'next/navigation';
export const useScriptData = () => {
const searchParams = useSearchParams();
const projectId = searchParams.get('episodeId') || '';
const {
loading, // 加载状态
synopsis, //故事梗概
categories, //故事分类
protagonist, //主角
incitingIncident, //激励事件
problem, //问题与新目标
conflict, //冲突与障碍
stakes, //赌注
characterArc, //人物弧线完成
planId, //计划ID
aiOptimizing, //AI优化要求
scriptBlocksMemo, // 渲染数据
initializeFromProject,
} = useScriptService();
const [scriptData, setScriptData] = useState<any>(null);
// 初始化剧本
useEffect(() => {
initializeFromProject(projectId);
}, []);
// 监听剧本加载完毕
useEffect(() => {
if (!loading) {
console.log('scriptBlocksMemo', scriptBlocksMemo);
}
}, [loading, scriptBlocksMemo]);
return {
scriptData
}
}

View File

@ -57,7 +57,6 @@ export function useWorkflowData() {
// 更新 taskObject 的类型
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
const [scriptData, setScriptData] = useState<any>(null);
const [taskSketch, setTaskSketch] = useState<any[]>([]);
const [taskScenes, setTaskScenes] = useState<any[]>([]);
const [taskShotSketch, setTaskShotSketch] = useState<any[]>([]);
@ -557,7 +556,6 @@ export function useWorkflowData() {
return {
taskObject,
scriptData,
taskSketch,
taskScenes,
taskShotSketch,

View File

@ -9,9 +9,10 @@ import { TypewriterText } from '@/components/workflow/work-office/common/Typewri
interface ScriptRendererProps {
data: ScriptData;
setIsPauseWorkFlow: (isPause: boolean) => void;
}
export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPauseWorkFlow }) => {
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
const [hoveredBlockId, setHoveredBlockId] = useState<string | null>(null);
const contentRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
@ -21,9 +22,11 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
const [isInit, setIsInit] = useState(true);
useEffect(() => {
const themeBlock = data.blocks.find(block => block.type === 'theme');
if (themeBlock) {
setAddThemeTag(themeBlock.content.map(item => item.text || ''));
const themeBlock = data.blocks.find(block => block.id === 'categories');
if (themeBlock && themeBlock.content.length > 0) {
const themeTag = themeBlock.content[0].text.split(',').map(item => item.trim());
console.log('themeTag', themeTag);
setAddThemeTag(themeTag);
}
}, [data.blocks]);
@ -118,6 +121,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
};
const handleEditBlock = (block: ScriptBlock) => {
setIsPauseWorkFlow(true);
setIsInit(false);
setEditBlockId(block.id);
setActiveBlockId(block.id);
@ -145,8 +149,8 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
};
const renderTypeBlock = (block: ScriptBlock, isHovered: boolean, isActive: boolean, isEditing: boolean) => {
switch (block.type) {
case 'theme':
switch (block.id) {
case 'categories':
return (
<div className="flex flex-wrap gap-2 mt-2">
{addThemeTag.map((item, index) => (
@ -168,7 +172,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
placeholder="Select Theme Type"
onChange={(value) => {
console.log('主题标签更改', value);
handleThemeTagChange(value);
handleThemeTagChange(value as string[]);
}}
/>
</div>

View File

@ -5,7 +5,6 @@ export const mockScriptData: ScriptData = {
{
id: 'core',
title: "SUMMARY",
type: 'core',
content: [
{
type: 'paragraph',
@ -14,52 +13,28 @@ export const mockScriptData: ScriptData = {
]
},
{
id: 'theme',
id: 'categories',
title: 'THEME',
type: 'theme',
content: [
{
type: 'tag',
text: 'Satire'
},
{
type: 'tag',
text: 'Absurdist Comedy'
},
{
type: 'tag',
text: 'Disaster'
text: 'Satire, Absurdist Comedy, Disaster'
}
]
},
{
id: 'roles',
title: 'ROLES',
type: 'roles',
content: [
{
type: 'bold',
text: 'Anna'
},
{
type: 'paragraph',
text: 'Anna is a young woman who is trying to find her place in the world. She is a bit of a mess, but she is also a bit of a mess.'
},
{
type: 'bold',
text: 'Mark'
},
{
type: 'card',
text: 'Mark starts as resentful and aimless, harboring guilt over past family conflicts. His involvement in Annas quest rekindles his sense of responsibility and belonging. He moves from avoidance and cynicism to active support, ultimately risking his own safety for Anna and the creature. Marks arc is one of redemption and reconnection.'
}
]
},
{
id: 'scene1',
title: 'SCENE 1',
type: 'scene',
sceneNumber: 1,
content: [
{
type: 'heading',
@ -98,7 +73,6 @@ export const mockScriptData: ScriptData = {
{
id: 'summary',
title: '总结',
type: 'summary',
content: [
{
type: 'paragraph',

View File

@ -2,20 +2,12 @@ export interface ScriptBlock {
id: string;
title: string;
content: ScriptContent[];
type: 'core' | 'scene' | 'summary' | 'theme' | 'roles';
sceneNumber?: number;
}
export interface ScriptContent {
type: 'paragraph' | 'bold' | 'italic' | 'heading' | 'tag' | 'card';
text?: string;
roleInfo?: {
name: string;
gender: string;
role: string;
desc: string;
color: string;
};
type: 'paragraph' | 'bold' | 'italic' | 'heading' | 'tag';
text: string;
}
export interface ScriptData {

View File

@ -102,179 +102,7 @@ const ScriptTabContent: React.FC = () => {
}
}}
>
{/* 标题 */}
<motion.div
className="flex-none px-6 py-4"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Edit Script
</h2>
</motion.div>
{/* 内容区域 */}
<motion.div
className="flex-1 overflow-auto p-6 pt-0 pb-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1, duration: 0.2 }}
>
{/* 剧本片段渲染区域 */}
<motion.div
style={{
height: 'calc(100% - 88px)'
}}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-4"
>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
{/* 剧本片段列表 */}
<div className="space-y-3">
{scriptSlices.map((slice, index) => (
<motion.div
key={slice.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="relative"
>
<Input
value={slice.text}
onChange={(e) => handleScriptSliceChange(slice.id, e.target.value)}
placeholder={`输入${slice.type}内容...`}
className="w-full bg-white/50 dark:bg-[#5b75ac20] border border-gray-200 dark:border-gray-600 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
/>
<div className="absolute top-2 right-2">
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded">
{slice.type}
</span>
</div>
</motion.div>
))}
</div>
{/* 加载状态 */}
{loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center py-8"
>
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-600 dark:text-gray-400">
...
</span>
</div>
</motion.div>
)}
</motion.div>
{/* 修改建议输入区域 */}
<motion.div
className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent dark:from-[#5b75ac4d] dark:via-[#5b75ac4d] dark:to-transparent pt-8 pb-4 rounded-sm"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center space-x-2 px-4">
<div className="flex-1">
<Input
value={userPrompt}
onChange={(e) => handlePromptChange(e.target.value)}
placeholder="输入提示词然后点击AI生成按钮..."
className="outline-none box-shadow-none bg-white/50 dark:bg-[#5b75ac20] border-0 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
disabled={loading}
/>
</div>
<motion.div
initial={false}
animate={{
scale: userPrompt.trim() && !loading ? 1 : 0.8,
opacity: userPrompt.trim() && !loading ? 1 : 0.5,
}}
transition={{
type: "spring",
stiffness: 500,
damping: 30
}}
>
<Button
variant="ghost"
size="icon"
disabled={!userPrompt.trim() || loading}
onClick={handleAiGenerate}
className="aiGenerate relative w-9 h-9 rounded-full bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400"
>
<motion.span
initial={false}
animate={{
opacity: loading ? 0 : 1,
scale: loading ? 0.5 : 1,
}}
>
<svg
className="w-5 h-5 transform rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</motion.span>
{loading && (
<motion.div
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
>
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
</motion.div>
)}
</Button>
</motion.div>
</div>
</motion.div>
</motion.div>
{/* 底部按钮 */}
<motion.div
className="flex-none px-6 py-4 flex justify-end space-x-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Button
variant="ghost"
className="min-w-[80px] bg-white/10 text-white hover:bg-white/20 transition-colors"
onClick={handleReset}
disabled={loading}
>
Reset
</Button>
<Button
className="min-w-[80px] bg-blue-500 text-white hover:bg-blue-600 transition-colors"
onClick={handleConfirm}
disabled={loading || scriptSlices.length === 0}
>
Confirm
</Button>
</motion.div>
</motion.div>
</div>
);