即时创作模式UI 优化
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 56s
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 56s
This commit is contained in:
parent
61fca0ebaf
commit
18dd726fb3
411
src/App.tsx
411
src/App.tsx
@ -30,7 +30,6 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
X
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
|
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
@ -47,8 +46,10 @@ import {
|
|||||||
import { parseUploadedSourceFile } from './services/fileParsing';
|
import { parseUploadedSourceFile } from './services/fileParsing';
|
||||||
import { getResumeStartIndex, getVersionsToGenerate, isConversionAbortedError } from './lib/conversionWorkflow';
|
import { getResumeStartIndex, getVersionsToGenerate, isConversionAbortedError } from './lib/conversionWorkflow';
|
||||||
import { AuthUser, loadCurrentDraft, login, logout, register, restoreSession, saveCurrentDraft } from './services/api';
|
import { AuthUser, loadCurrentDraft, login, logout, register, restoreSession, saveCurrentDraft } from './services/api';
|
||||||
import { splitScriptPreviewBlocks } from './lib/scriptPreview';
|
import { normalizeSceneHeadingMarkers, splitScriptPreviewBlocks } from './lib/scriptPreview';
|
||||||
import { parseSavedConversionEpisodeResults, sanitizeSavedConversionEpisodeResults } from './lib/conversionDraft';
|
import { parseSavedConversionEpisodeResults, sanitizeSavedConversionEpisodeResults } from './lib/conversionDraft';
|
||||||
|
import { parseFinalizedOutline } from './lib/finalizedOutline';
|
||||||
|
import { getDisplayedModeLabel } from './lib/modeSource';
|
||||||
|
|
||||||
type ScriptType = '\u77ed\u5267' | '\u7535\u89c6\u5267' | '\u7535\u5f71';
|
type ScriptType = '\u77ed\u5267' | '\u7535\u89c6\u5267' | '\u7535\u5f71';
|
||||||
type ScriptFormat = '全部' | '场景' | '人物' | '台词' | '动作' | '镜头提示';
|
type ScriptFormat = '全部' | '场景' | '人物' | '台词' | '动作' | '镜头提示';
|
||||||
@ -307,11 +308,20 @@ function parseCharacterProfileCards(text: string): CharacterPreviewCard[] {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [activeTab, setActiveTab] = useState<'conversion' | 'creation' | 'finalized'>('conversion');
|
const [activeTab, setActiveTab] = useState<'conversion' | 'creation' | 'finalized'>('conversion');
|
||||||
const [previousTab, setPreviousTab] = useState<'conversion' | 'creation'>('conversion');
|
const [previousTab, setPreviousTab] = useState<'conversion' | 'creation'>('conversion');
|
||||||
const [finalizedScript, setFinalizedScript] = useState<string>(() => localStorage.getItem('scriptflow_finalized_script') || '');
|
const [finalizedScripts, setFinalizedScripts] = useState<{ conversion: string; creation: string }>(() => {
|
||||||
|
const legacy = localStorage.getItem('scriptflow_finalized_script') || '';
|
||||||
|
return {
|
||||||
|
conversion: localStorage.getItem('scriptflow_finalized_script_conversion') || legacy,
|
||||||
|
creation: localStorage.getItem('scriptflow_finalized_script_creation') || legacy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const finalizedScript = previousTab === 'creation' ? finalizedScripts.creation : finalizedScripts.conversion;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
localStorage.setItem('scriptflow_finalized_script_conversion', finalizedScripts.conversion);
|
||||||
|
localStorage.setItem('scriptflow_finalized_script_creation', finalizedScripts.creation);
|
||||||
localStorage.setItem('scriptflow_finalized_script', finalizedScript);
|
localStorage.setItem('scriptflow_finalized_script', finalizedScript);
|
||||||
}, [finalizedScript]);
|
}, [finalizedScripts, finalizedScript]);
|
||||||
|
|
||||||
// Common States (Three-level Filters)
|
// Common States (Three-level Filters)
|
||||||
const [scriptType, setScriptType] = useState<ScriptType>('\u77ed\u5267');
|
const [scriptType, setScriptType] = useState<ScriptType>('\u77ed\u5267');
|
||||||
@ -372,6 +382,7 @@ export default function App() {
|
|||||||
const conversionFileInputRef = useRef<HTMLInputElement | null>(null);
|
const conversionFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isGeneratingAll, setIsGeneratingAll] = useState(false);
|
const [isGeneratingAll, setIsGeneratingAll] = useState(false);
|
||||||
|
const [editingCreationScriptKey, setEditingCreationScriptKey] = useState<string | null>(null);
|
||||||
const [finalizedFilter, setFinalizedFilter] = useState<ScriptFormat>('全部');
|
const [finalizedFilter, setFinalizedFilter] = useState<ScriptFormat>('全部');
|
||||||
|
|
||||||
// Conversion Settings States
|
// Conversion Settings States
|
||||||
@ -462,6 +473,8 @@ export default function App() {
|
|||||||
conversionOutline,
|
conversionOutline,
|
||||||
conversionCharacterProfiles,
|
conversionCharacterProfiles,
|
||||||
finalizedScript,
|
finalizedScript,
|
||||||
|
finalizedScriptConversion: finalizedScripts.conversion,
|
||||||
|
finalizedScriptCreation: finalizedScripts.creation,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -481,7 +494,11 @@ export default function App() {
|
|||||||
setConversionWorldview(typeof draft.conversionWorldview === 'string' ? draft.conversionWorldview : '');
|
setConversionWorldview(typeof draft.conversionWorldview === 'string' ? draft.conversionWorldview : '');
|
||||||
setConversionOutline(typeof draft.conversionOutline === 'string' ? draft.conversionOutline : '');
|
setConversionOutline(typeof draft.conversionOutline === 'string' ? draft.conversionOutline : '');
|
||||||
setConversionCharacterProfiles(typeof draft.conversionCharacterProfiles === 'string' ? draft.conversionCharacterProfiles : '');
|
setConversionCharacterProfiles(typeof draft.conversionCharacterProfiles === 'string' ? draft.conversionCharacterProfiles : '');
|
||||||
setFinalizedScript(typeof draft.finalizedScript === 'string' ? draft.finalizedScript : '');
|
const legacyFinalizedScript = typeof draft.finalizedScript === 'string' ? draft.finalizedScript : '';
|
||||||
|
setFinalizedScripts({
|
||||||
|
conversion: typeof draft.finalizedScriptConversion === 'string' ? draft.finalizedScriptConversion : legacyFinalizedScript,
|
||||||
|
creation: typeof draft.finalizedScriptCreation === 'string' ? draft.finalizedScriptCreation : legacyFinalizedScript,
|
||||||
|
});
|
||||||
setEditingConversionVersionKey(null);
|
setEditingConversionVersionKey(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -886,7 +903,7 @@ export default function App() {
|
|||||||
scriptToFinalize = finalizedPages.map((p) => {
|
scriptToFinalize = finalizedPages.map((p) => {
|
||||||
const script = p.scripts[p.selectedScriptIndex!];
|
const script = p.scripts[p.selectedScriptIndex!];
|
||||||
const originalIndex = creationPages.indexOf(p);
|
const originalIndex = creationPages.indexOf(p);
|
||||||
return `## ?${originalIndex + 1}?
|
return `## 第${originalIndex + 1}集
|
||||||
|
|
||||||
${script}`;
|
${script}`;
|
||||||
}).join('\n\n---\n\n');
|
}).join('\n\n---\n\n');
|
||||||
@ -899,7 +916,7 @@ ${script}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setFinalizedScript(scriptToFinalize);
|
setFinalizedScripts((prev) => ({ ...prev, [activeTab as 'conversion' | 'creation']: scriptToFinalize }));
|
||||||
setIsFinalizing(false);
|
setIsFinalizing(false);
|
||||||
setIsEditingFinalized(false);
|
setIsEditingFinalized(false);
|
||||||
setActiveTab('finalized');
|
setActiveTab('finalized');
|
||||||
@ -978,6 +995,20 @@ ${script}`;
|
|||||||
setCreationPages(newPages);
|
setCreationPages(newPages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCreationScriptContent = (pageIndex: number, scriptIndex: number, content: string) => {
|
||||||
|
setCreationPages((prevPages) => prevPages.map((page, idx) => {
|
||||||
|
if (idx !== pageIndex) return page;
|
||||||
|
|
||||||
|
const nextScripts = [...page.scripts];
|
||||||
|
nextScripts[scriptIndex] = normalizeSceneHeadingMarkers(content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
scripts: nextScripts
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const syncCurrentPage = async () => {
|
const syncCurrentPage = async () => {
|
||||||
const currentPage = creationPages[currentPageIndex];
|
const currentPage = creationPages[currentPageIndex];
|
||||||
if (!currentPage || !currentPage.story.trim()) return;
|
if (!currentPage || !currentPage.story.trim()) return;
|
||||||
@ -989,6 +1020,7 @@ ${script}`;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEditingCreationScriptKey(null);
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
|
|
||||||
// Initialize scripts with empty strings to show loading state
|
// Initialize scripts with empty strings to show loading state
|
||||||
@ -1044,6 +1076,7 @@ ${script}`;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEditingCreationScriptKey(null);
|
||||||
setIsGeneratingAll(true);
|
setIsGeneratingAll(true);
|
||||||
const results = await generateAllScripts(
|
const results = await generateAllScripts(
|
||||||
creationPages,
|
creationPages,
|
||||||
@ -1062,7 +1095,7 @@ ${script}`;
|
|||||||
const exportAllScripts = () => {
|
const exportAllScripts = () => {
|
||||||
const fullScript = creationPages.map((p, i) => {
|
const fullScript = creationPages.map((p, i) => {
|
||||||
const script = p.selectedScriptIndex !== null ? p.scripts[p.selectedScriptIndex] : (p.scripts[0] || '\u672a\u751f\u6210');
|
const script = p.selectedScriptIndex !== null ? p.scripts[p.selectedScriptIndex] : (p.scripts[0] || '\u672a\u751f\u6210');
|
||||||
return `## ?${i + 1} ?
|
return `## 第${i + 1}集
|
||||||
|
|
||||||
${script}`;
|
${script}`;
|
||||||
}).join('\n\n---\n\n');
|
}).join('\n\n---\n\n');
|
||||||
@ -1075,35 +1108,41 @@ ${script}`;
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sidebarItems = React.useMemo(() => {
|
const finalizedOutline = React.useMemo(() => parseFinalizedOutline(finalizedScript), [finalizedScript]);
|
||||||
const items: { type: 'episode' | 'scene', text: string, id: string }[] = [];
|
const sidebarItems = React.useMemo(() => finalizedOutline.sidebarItems, [finalizedOutline]);
|
||||||
const lines = finalizedScript.split('\n');
|
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(() => new Set());
|
||||||
let sceneIdx = 0;
|
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(() => new Set());
|
||||||
let episodeIdx = 0;
|
|
||||||
|
|
||||||
lines.forEach((line) => {
|
useEffect(() => {
|
||||||
const trimmed = line.trim();
|
const nextEpisodes = new Set<string>();
|
||||||
if (!trimmed) return;
|
const nextScenes = new Set<string>();
|
||||||
|
|
||||||
// Episode Detection
|
finalizedOutline.episodes.forEach((episode) => {
|
||||||
if (trimmed.startsWith('## ?') && trimmed.includes('?')) {
|
nextEpisodes.add(episode.id);
|
||||||
items.push({
|
episode.scenes.slice(0, 2).forEach((scene) => nextScenes.add(scene.id));
|
||||||
type: 'episode',
|
|
||||||
text: trimmed.replace(/^[#*]+/, '').trim(),
|
|
||||||
id: `episode-${episodeIdx++}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Scene Detection
|
|
||||||
else if ((trimmed.startsWith('## 场景') || trimmed.startsWith('场景') || trimmed.startsWith('SCENE') || (trimmed.startsWith('#') && !trimmed.startsWith('##'))) && trimmed.length < 80) {
|
|
||||||
items.push({
|
|
||||||
type: 'scene',
|
|
||||||
text: trimmed.replace(/^[#*]+/, '').trim(),
|
|
||||||
id: `scene-${sceneIdx++}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return items;
|
|
||||||
}, [finalizedScript]);
|
setExpandedEpisodes(nextEpisodes);
|
||||||
|
setExpandedScenes(nextScenes);
|
||||||
|
}, [finalizedScript, finalizedOutline]);
|
||||||
|
|
||||||
|
const toggleEpisodeCard = (episodeId: string) => {
|
||||||
|
setExpandedEpisodes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(episodeId)) next.delete(episodeId);
|
||||||
|
else next.add(episodeId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSceneCard = (sceneId: string) => {
|
||||||
|
setExpandedScenes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(sceneId)) next.delete(sceneId);
|
||||||
|
else next.add(sceneId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const activeCharacterProfiles = activeTab === 'conversion' ? conversionCharacterProfiles : creationCharacterProfiles;
|
const activeCharacterProfiles = activeTab === 'conversion' ? conversionCharacterProfiles : creationCharacterProfiles;
|
||||||
const isCharacterProfilesEditing = activeTab === 'conversion' ? isConversionCharacterProfilesEditing : isCreationCharacterProfilesEditing;
|
const isCharacterProfilesEditing = activeTab === 'conversion' ? isConversionCharacterProfilesEditing : isCreationCharacterProfilesEditing;
|
||||||
@ -1134,11 +1173,11 @@ ${script}`;
|
|||||||
const fullScript = finalizedPages.map((p) => {
|
const fullScript = finalizedPages.map((p) => {
|
||||||
const script = p.scripts[p.selectedScriptIndex!];
|
const script = p.scripts[p.selectedScriptIndex!];
|
||||||
const originalIndex = creationPages.indexOf(p);
|
const originalIndex = creationPages.indexOf(p);
|
||||||
return `## ?${originalIndex + 1} ?
|
return `## 第${originalIndex + 1}集
|
||||||
|
|
||||||
${script}`;
|
${script}`;
|
||||||
}).join('\n\n---\n\n');
|
}).join('\n\n---\n\n');
|
||||||
setFinalizedScript(fullScript);
|
setFinalizedScripts((prev) => ({ ...prev, creation: fullScript }));
|
||||||
setActiveTab('finalized');
|
setActiveTab('finalized');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1236,7 +1275,7 @@ ${script}`;
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="h-16 border-b border-[#D2D2D7]/30 flex items-center justify-between px-8 bg-white/70 backdrop-blur-xl z-10">
|
<header className="h-16 border-b border-[#D2D2D7]/30 flex items-center justify-between px-8 bg-white/70 backdrop-blur-xl z-10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-lg font-semibold tracking-tight">ScriptFlow <span className="text-[#0071E3]">{activeTab === 'conversion' ? 'AI 剧本转换工具' : '即时创作模式'}</span></h1>
|
<h1 className="text-lg font-semibold tracking-tight">ScriptFlow <span className="text-[#0071E3]">{getDisplayedModeLabel(activeTab, previousTab)}</span></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@ -1524,7 +1563,7 @@ ${script}`;
|
|||||||
}}
|
}}
|
||||||
value={finalizedScript}
|
value={finalizedScript}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFinalizedScript(e.target.value);
|
setFinalizedScripts((prev) => ({ ...prev, [previousTab]: e.target.value }));
|
||||||
e.target.style.height = 'auto';
|
e.target.style.height = 'auto';
|
||||||
e.target.style.height = e.target.scrollHeight + 'px';
|
e.target.style.height = e.target.scrollHeight + 'px';
|
||||||
}}
|
}}
|
||||||
@ -1534,103 +1573,80 @@ ${script}`;
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full max-w-[800px] bg-white shadow-[0_20px_50px_rgba(0,0,0,0.1)] rounded-sm min-h-[1123px] h-auto relative mx-auto my-8">
|
<div className="w-full max-w-[1100px] mx-auto my-8 px-2 space-y-5">
|
||||||
{/* Page Content Container */}
|
{finalizedOutline.episodes.map((episode) => {
|
||||||
<div className="w-full p-[80px] relative z-10">
|
const episodeExpanded = expandedEpisodes.has(episode.id);
|
||||||
{/* Script Content */}
|
|
||||||
<div className="font-mono text-[14px] leading-[1.6] text-[#1D1D1F] prose prose-sm max-w-none break-words whitespace-pre-wrap">
|
|
||||||
<ReactMarkdown
|
|
||||||
components={{
|
|
||||||
p: ({ children }) => {
|
|
||||||
try {
|
|
||||||
const childrenArray = React.Children.toArray(children);
|
|
||||||
const text = childrenArray.map(c => c?.toString() || '').join('');
|
|
||||||
const trimmedText = text.trim();
|
|
||||||
|
|
||||||
// 1. Scene Detection
|
return (
|
||||||
const isScene = (trimmedText.includes('SCENE') || trimmedText.includes('场景') || trimmedText.startsWith('#')) && trimmedText.length < 80;
|
<section
|
||||||
|
key={episode.id}
|
||||||
if (isScene) {
|
id={episode.id}
|
||||||
// We need to find the index of this scene in the global list to match the sidebar ID
|
className="rounded-[20px] border border-[#D2D2D7]/40 bg-white shadow-[0_14px_42px_rgba(15,23,42,0.06)] overflow-hidden"
|
||||||
// This is a bit expensive but necessary for navigation
|
>
|
||||||
const allScenes = finalizedScript.split('\n')
|
<button
|
||||||
.filter(line => {
|
type="button"
|
||||||
const t = line.trim();
|
onClick={() => toggleEpisodeCard(episode.id)}
|
||||||
return (t.startsWith('## 场景') || t.startsWith('场景') || t.startsWith('SCENE') || (t.startsWith('#') && !t.startsWith('##'))) && t.length < 80;
|
className="w-full px-6 py-5 flex items-center justify-between gap-4 bg-[linear-gradient(135deg,rgba(0,113,227,0.06),rgba(255,255,255,0.9))] hover:bg-[linear-gradient(135deg,rgba(0,113,227,0.10),rgba(255,255,255,0.96))] transition-colors"
|
||||||
});
|
|
||||||
const sceneIdx = allScenes.findIndex(s => s.includes(trimmedText) || trimmedText.includes(s.replace(/^[#*]+/, '').trim()));
|
|
||||||
|
|
||||||
const sceneTitle = trimmedText.replace(/^[#*]+/, '').trim();
|
|
||||||
return (
|
|
||||||
<div id={`scene-${sceneIdx >= 0 ? sceneIdx : 'unknown'}`} className="mt-10 mb-6 py-2 border-b-2 border-black/5 font-bold uppercase tracking-wide text-lg">
|
|
||||||
{sceneTitle}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <p className="mb-4 text-justify leading-relaxed">{children}</p>;
|
|
||||||
} catch (e) {
|
|
||||||
return <p className="mb-4 text-justify leading-relaxed">{children}</p>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
h1: ({children}) => <h1 className="text-2xl font-bold mb-6 text-center border-b-2 border-black/10 pb-2">{children}</h1>,
|
|
||||||
h2: ({children}) => {
|
|
||||||
const text = React.Children.toArray(children).map(c => c?.toString() || '').join('');
|
|
||||||
let id = '';
|
|
||||||
if (text.startsWith('?') && text.includes('?')) {
|
|
||||||
const allEpisodes = finalizedScript.split('\n').filter(l => l.trim().startsWith('## ?') && l.trim().includes('?'));
|
|
||||||
const idx = allEpisodes.findIndex(e => e.includes(text));
|
|
||||||
id = `episode-${idx}`;
|
|
||||||
} else if (text.startsWith('场景') || text.includes('SCENE')) {
|
|
||||||
const allScenes = finalizedScript.split('\n')
|
|
||||||
.filter(line => {
|
|
||||||
const t = line.trim();
|
|
||||||
return (t.startsWith('## 场景') || t.startsWith('场景') || t.startsWith('SCENE') || (t.startsWith('#') && !t.startsWith('##'))) && t.length < 80;
|
|
||||||
});
|
|
||||||
const idx = allScenes.findIndex(s => s.includes(text));
|
|
||||||
id = `scene-${idx}`;
|
|
||||||
}
|
|
||||||
return <h2 id={id} className="text-xl font-bold mt-10 mb-4 border-b border-black/5 pb-1">{children}</h2>;
|
|
||||||
},
|
|
||||||
h3: ({children}) => <h3 className="text-lg font-bold mt-8 mb-3">{children}</h3>
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{finalizedFilter === '全部'
|
<div className="text-left">
|
||||||
? finalizedScript
|
<h3 className="text-[20px] leading-tight font-semibold text-[#1D1D1F]">{episode.title}</h3>
|
||||||
: finalizedScript.split('\n').filter(line => {
|
<p className="mt-1 text-[12px] font-medium text-[#6E6E73]">{'\u573a\u666f ' + episode.scenes.length + ' \u00b7 \u5b57\u6570 ' + episode.wordCount}</p>
|
||||||
const trimmedLine = line.trim();
|
</div>
|
||||||
if (!trimmedLine) return false;
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={cn('text-[#6E6E73] transition-transform', episodeExpanded ? 'rotate-180' : '')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
// 1. Scene
|
{episodeExpanded && (
|
||||||
const isScene = trimmedLine.includes('SCENE') || trimmedLine.includes('场景') || trimmedLine.startsWith('#');
|
<div className="px-5 pb-5 space-y-3">
|
||||||
|
{episode.scenes.map((scene, sceneIndex) => {
|
||||||
|
const sceneExpanded = expandedScenes.has(scene.id);
|
||||||
|
const sceneWordCount = scene.content.replace(/\s+/g, '').length;
|
||||||
|
|
||||||
// 2. Character
|
return (
|
||||||
// Rule: Contains colon, len <= 20, not a scene
|
<article
|
||||||
const isChar = !isScene && (trimmedLine.includes('?') || trimmedLine.includes(':')) && trimmedLine.length <= 20;
|
key={scene.id}
|
||||||
|
id={scene.id}
|
||||||
|
className="rounded-2xl border border-[#E5E5EA] bg-[#FAFAFC] overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSceneCard(scene.id)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between gap-3 hover:bg-[#F2F6FD] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-left min-w-0">
|
||||||
|
<span className="w-1.5 h-8 rounded-full bg-[#0071E3]/80" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[15px] font-semibold text-[#1D1D1F] truncate">
|
||||||
|
{scene.title || ('\u573a\u666f ' + (sceneIndex + 1))}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[#8E8E93] mt-0.5">{sceneWordCount + ' \u5b57'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={cn('text-[#8E8E93] transition-transform', sceneExpanded ? 'rotate-180' : '')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
// 3. Dialogue
|
{sceneExpanded && (
|
||||||
// Rule: Starts with quote
|
<div className="px-4 pb-4">
|
||||||
const isDialogue = !isScene && (trimmedLine.startsWith('?') || trimmedLine.startsWith('\"'));
|
<div className="rounded-xl bg-white border border-[#ECECEF] px-4 py-3">
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-[14px] leading-[1.85] font-serif text-[#1D1D1F]">
|
||||||
// 4. Camera
|
{scene.content || '\uff08\u672c\u573a\u666f\u6682\u65e0\u5185\u5bb9\uff09'}
|
||||||
// Rule: Keywords
|
</pre>
|
||||||
const cameraKeywords = ['镜头', '特写', '远景', '近景', '中景', '全景', '推镜', '拉镜', '切换'];
|
</div>
|
||||||
const isCamera = !isScene && cameraKeywords.some(k => trimmedLine.includes(k));
|
</div>
|
||||||
|
)}
|
||||||
// 5. Action
|
</article>
|
||||||
// Rule: None of the above
|
);
|
||||||
const isAction = !isScene && !isChar && !isDialogue && !isCamera;
|
})}
|
||||||
|
</div>
|
||||||
if (finalizedFilter === '场景' && !isScene) return false;
|
)}
|
||||||
if (finalizedFilter === '人物' && !isChar) return false;
|
</section>
|
||||||
if (finalizedFilter === '台词' && !isDialogue && !isChar) return false; // Keep character names with dialogue for context
|
);
|
||||||
if (finalizedFilter === '动作' && !isAction) return false;
|
})}
|
||||||
if (finalizedFilter === '镜头提示' && !isCamera) return false;
|
|
||||||
return true;
|
|
||||||
}).join('\n\n')
|
|
||||||
}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -2395,52 +2411,99 @@ ${script}`;
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
{(isSyncing || creationPages[currentPageIndex]?.scripts?.some(s => s)) ? (
|
{(isSyncing || creationPages[currentPageIndex]?.scripts?.some(s => s)) ? (
|
||||||
<div className="grid grid-cols-3 gap-4 h-full">
|
<div className="grid grid-cols-2 gap-4 h-full">
|
||||||
{creationPages[currentPageIndex]?.scripts?.map((script, idx) => (
|
{creationPages[currentPageIndex]?.scripts?.map((script, idx) => {
|
||||||
<div key={idx} className={cn(
|
const creationScriptKey = (creationPages[currentPageIndex]?.id || '') + '-' + idx;
|
||||||
"flex flex-col rounded-2xl border transition-all overflow-hidden bg-white/50",
|
const isEditingCreationScript = editingCreationScriptKey === creationScriptKey;
|
||||||
creationPages[currentPageIndex]?.selectedScriptIndex === idx
|
|
||||||
? "border-[#0071E3] ring-1 ring-[#0071E3] shadow-lg"
|
return (
|
||||||
: "border-[#D2D2D7]/30 hover:border-[#D2D2D7]"
|
<div key={idx} className={cn(
|
||||||
)}>
|
"flex flex-col rounded-2xl border transition-all overflow-hidden bg-white/50",
|
||||||
<div className="p-3 border-b border-[#D2D2D7]/30 flex items-center justify-between bg-white/40">
|
creationPages[currentPageIndex]?.selectedScriptIndex === idx
|
||||||
<div className="flex items-center gap-2">
|
? "border-[#0071E3] ring-1 ring-[#0071E3] shadow-lg"
|
||||||
<span className="text-[10px] font-bold text-[#86868B]">{['A', 'B', 'C'][idx]}版本</span>
|
: "border-[#D2D2D7]/30 hover:border-[#D2D2D7]"
|
||||||
{isSyncing && !script && <Loader2 size={10} className="animate-spin text-blue-500" />}
|
)}>
|
||||||
</div>
|
<div className="p-3 border-b border-[#D2D2D7]/30 flex items-center justify-between bg-white/40">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span className="text-[10px] font-bold text-[#86868B]">{['A', 'B', 'C'][idx] + '\u7248\u672c'}</span>
|
||||||
onClick={() => copyToClipboard(script, `${creationPages[currentPageIndex]?.id}-${idx}`)}
|
{isSyncing && !script && <Loader2 size={10} className="animate-spin text-blue-500" />}
|
||||||
className="p-1.5 rounded-full hover:bg-black/5 text-[#86868B] transition-all"
|
|
||||||
>
|
|
||||||
{copiedId === `${creationPages[currentPageIndex]?.id}-${idx}` ? <Check size={12} className="text-green-500" /> : <Copy size={12} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => selectCurrentPageScript(idx)}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 rounded-full text-[10px] font-bold transition-all",
|
|
||||||
creationPages[currentPageIndex]?.selectedScriptIndex === idx
|
|
||||||
? "bg-[#0071E3] text-white"
|
|
||||||
: "bg-white border border-[#D2D2D7] text-[#1D1D1F] hover:bg-[#F5F5F7]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{creationPages[currentPageIndex]?.selectedScriptIndex === idx ? '\u5df2\u5b9a\u7a3f' : '\u5b9a\u7a3f'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4 overflow-y-auto font-mono text-[11px] leading-relaxed prose prose-sm max-w-none">
|
|
||||||
{script ? (
|
|
||||||
<ReactMarkdown>{script}</ReactMarkdown>
|
|
||||||
) : isSyncing ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-3 bg-black/5 rounded w-3/4 animate-pulse" />
|
|
||||||
<div className="h-3 bg-black/5 rounded w-1/2 animate-pulse" />
|
|
||||||
<div className="h-3 bg-black/5 rounded w-5/6 animate-pulse" />
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(script, creationScriptKey)}
|
||||||
|
className="p-1.5 rounded-full hover:bg-black/5 text-[#86868B] transition-all"
|
||||||
|
>
|
||||||
|
{copiedId === creationScriptKey ? <Check size={12} className="text-green-500" /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingCreationScriptKey((prev) => prev === creationScriptKey ? null : creationScriptKey)}
|
||||||
|
disabled={isSyncing}
|
||||||
|
className="px-2.5 py-1 rounded-full text-[10px] font-bold transition-all border border-[#D2D2D7] bg-white text-[#1D1D1F] hover:bg-[#F5F5F7] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isEditingCreationScript ? '\u5b8c\u6210\u7f16\u8f91' : '\u7f16\u8f91'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectCurrentPageScript(idx)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 rounded-full text-[10px] font-bold transition-all",
|
||||||
|
creationPages[currentPageIndex]?.selectedScriptIndex === idx
|
||||||
|
? "bg-[#0071E3] text-white"
|
||||||
|
: "bg-white border border-[#D2D2D7] text-[#1D1D1F] hover:bg-[#F5F5F7]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{creationPages[currentPageIndex]?.selectedScriptIndex === idx ? '\u5df2\u5b9a\u7a3f' : '\u5b9a\u7a3f'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"flex-1 p-4",
|
||||||
|
isEditingCreationScript && !isSyncing ? "flex overflow-hidden" : "overflow-y-auto"
|
||||||
|
)}>
|
||||||
|
{isEditingCreationScript && !isSyncing ? (
|
||||||
|
<div className="flex h-full w-full flex-1 flex-col gap-2">
|
||||||
|
<span className="inline-flex w-fit items-center rounded-full border border-[#0071E3]/25 bg-[#0071E3]/10 px-2.5 py-1 text-[10px] font-bold text-[#0071E3]">
|
||||||
|
{'\u573a\u666f\u6807\u8bc6\uff1a##\uff08\u4e0d\u53ef\u5220\u9664\uff09'}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={script}
|
||||||
|
onChange={(event) => updateCreationScriptContent(currentPageIndex, idx, event.target.value)}
|
||||||
|
className="w-full h-full min-h-0 flex-1 resize-none rounded-2xl border border-[#D2D2D7]/50 bg-white px-4 py-3 font-mono text-[16px] leading-8 text-[#1D1D1F] outline-none focus:border-[#0071E3]/40 focus:ring-2 focus:ring-[#0071E3]/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : script ? (
|
||||||
|
<div className="font-mono text-[16px] leading-8 text-[#1D1D1F]">
|
||||||
|
{splitScriptPreviewBlocks(script).map((block, blockIndex) => {
|
||||||
|
const content = block.type === 'scene'
|
||||||
|
? block.content.replace(/^#{1,3}\s*/, '')
|
||||||
|
: block.content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={(creationPages[currentPageIndex]?.id || '') + '-' + idx + '-' + blockIndex}
|
||||||
|
className={cn(
|
||||||
|
block.type === 'scene'
|
||||||
|
? 'mb-4 mt-2 text-[21px] font-black leading-9 tracking-[0.01em] text-[#111111]'
|
||||||
|
: 'mb-3 text-[16px] leading-8 text-[#1D1D1F]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : isSyncing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-black/5 rounded w-3/4 animate-pulse" />
|
||||||
|
<div className="h-3 bg-black/5 rounded w-1/2 animate-pulse" />
|
||||||
|
<div className="h-3 bg-black/5 rounded w-5/6 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-[#86868B] space-y-4 opacity-50">
|
<div className="h-full flex flex-col items-center justify-center text-[#86868B] space-y-4 opacity-50">
|
||||||
|
|||||||
63
src/lib/creationValidation.ts
Normal file
63
src/lib/creationValidation.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
export type CreationGenerationMode = 'single' | 'batch';
|
||||||
|
export type CreationBackgroundField = 'outline' | 'worldview';
|
||||||
|
|
||||||
|
interface CreationBackgroundValues {
|
||||||
|
outline: string;
|
||||||
|
worldview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreationValidationPrompt {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actionLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<CreationBackgroundField, string> = {
|
||||||
|
outline: '\u6545\u4e8b\u5927\u7eb2',
|
||||||
|
worldview: '\u4e16\u754c\u89c2\u8bbe\u5b9a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TITLE_TEXT = '\u7f3a\u5c11\u5267\u60c5\u80cc\u666f\u8bbe\u5b9a';
|
||||||
|
const ACTION_LABEL_TEXT = '\u53bb\u8865\u5145\u8bbe\u5b9a';
|
||||||
|
|
||||||
|
export function getMissingCreationBackgroundFields(
|
||||||
|
values: CreationBackgroundValues,
|
||||||
|
): CreationBackgroundField[] {
|
||||||
|
const missingFields: CreationBackgroundField[] = [];
|
||||||
|
|
||||||
|
if (!values.outline.trim()) {
|
||||||
|
missingFields.push('outline');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.worldview.trim()) {
|
||||||
|
missingFields.push('worldview');
|
||||||
|
}
|
||||||
|
|
||||||
|
return missingFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinFieldLabels(fields: CreationBackgroundField[]) {
|
||||||
|
if (fields.length <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 1) {
|
||||||
|
return `“${FIELD_LABELS[fields[0]]}”`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields.map((field) => `“${FIELD_LABELS[field]}”`).join('\u548c');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCreationValidationPrompt(
|
||||||
|
mode: CreationGenerationMode,
|
||||||
|
missingFields: CreationBackgroundField[] = ['outline', 'worldview'],
|
||||||
|
): CreationValidationPrompt {
|
||||||
|
const actionText = mode === 'batch' ? '\u6267\u884c\u6279\u91cf\u751f\u6210' : '\u6267\u884c\u751f\u6210';
|
||||||
|
const fieldsText = joinFieldLabels(missingFields);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: TITLE_TEXT,
|
||||||
|
description: `\u8bf7\u5148\u5728“\u5267\u60c5\u80cc\u666f”\u4e2d\u586b\u5199${fieldsText}\u540e\u518d${actionText}\u3002`,
|
||||||
|
actionLabel: ACTION_LABEL_TEXT,
|
||||||
|
};
|
||||||
|
}
|
||||||
162
src/lib/finalizedOutline.ts
Normal file
162
src/lib/finalizedOutline.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
export interface FinalizedScene {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalizedEpisode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
scenes: FinalizedScene[];
|
||||||
|
content: string;
|
||||||
|
wordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalizedSidebarItem {
|
||||||
|
type: 'episode' | 'scene';
|
||||||
|
text: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalizedOutline {
|
||||||
|
episodes: FinalizedEpisode[];
|
||||||
|
sidebarItems: FinalizedSidebarItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPISODE_REGEX = /^##\s*\u7b2c\s*\d+\s*\u96c6/;
|
||||||
|
const SCENE_REGEX_CN_HEADING = /^##\s*\u573a\u666f\s*\d+/;
|
||||||
|
const SCENE_REGEX_CN = /^\u573a\u666f\s*\d+/;
|
||||||
|
const SCENE_REGEX_EN_HEADING = /^##\s*SCENE\s*\d+/i;
|
||||||
|
const SCENE_REGEX_EN = /^SCENE\s*\d+/i;
|
||||||
|
|
||||||
|
const cleanHeading = (line: string): string => line.replace(/^#+\s*/, '').trim();
|
||||||
|
const countWords = (text: string): number => text.replace(/\s+/g, '').length;
|
||||||
|
|
||||||
|
const isEpisodeHeading = (line: string): boolean => EPISODE_REGEX.test(line.trim());
|
||||||
|
const isSceneHeading = (line: string): boolean => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
return SCENE_REGEX_CN_HEADING.test(trimmed)
|
||||||
|
|| SCENE_REGEX_CN.test(trimmed)
|
||||||
|
|| SCENE_REGEX_EN_HEADING.test(trimmed)
|
||||||
|
|| SCENE_REGEX_EN.test(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseFinalizedOutline = (script: string): FinalizedOutline => {
|
||||||
|
const lines = script.split('\n');
|
||||||
|
const episodes: Array<Omit<FinalizedEpisode, 'wordCount'>> = [];
|
||||||
|
|
||||||
|
let episodeCounter = 0;
|
||||||
|
let sceneCounter = 0;
|
||||||
|
|
||||||
|
let currentEpisode: Omit<FinalizedEpisode, 'wordCount'> | null = null;
|
||||||
|
let currentScene: FinalizedScene | null = null;
|
||||||
|
let episodeBuffer: string[] = [];
|
||||||
|
|
||||||
|
const flushScene = () => {
|
||||||
|
if (!currentScene || !currentEpisode) return;
|
||||||
|
currentScene.content = currentScene.content.trim();
|
||||||
|
currentEpisode.scenes.push(currentScene);
|
||||||
|
currentScene = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushEpisode = () => {
|
||||||
|
if (!currentEpisode) return;
|
||||||
|
flushScene();
|
||||||
|
currentEpisode.content = episodeBuffer.join('\n').trim();
|
||||||
|
episodes.push(currentEpisode);
|
||||||
|
currentEpisode = null;
|
||||||
|
episodeBuffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureEpisode = () => {
|
||||||
|
if (currentEpisode) return;
|
||||||
|
episodeCounter += 1;
|
||||||
|
currentEpisode = {
|
||||||
|
id: `episode-${episodeCounter - 1}`,
|
||||||
|
title: `\u7b2c${episodeCounter}\u96c6 \u672a\u547d\u540d\u7ae0\u8282`,
|
||||||
|
scenes: [],
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
if (currentScene) {
|
||||||
|
currentScene.content += `${currentScene.content ? '\n' : ''}`;
|
||||||
|
}
|
||||||
|
if (currentEpisode) {
|
||||||
|
episodeBuffer.push('');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '---') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEpisodeHeading(line)) {
|
||||||
|
flushEpisode();
|
||||||
|
episodeCounter += 1;
|
||||||
|
const title = cleanHeading(line);
|
||||||
|
currentEpisode = {
|
||||||
|
id: `episode-${episodeCounter - 1}`,
|
||||||
|
title,
|
||||||
|
scenes: [],
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
episodeBuffer.push(title);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSceneHeading(line)) {
|
||||||
|
ensureEpisode();
|
||||||
|
flushScene();
|
||||||
|
const title = cleanHeading(line);
|
||||||
|
currentScene = {
|
||||||
|
id: `scene-${sceneCounter}`,
|
||||||
|
title,
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
sceneCounter += 1;
|
||||||
|
episodeBuffer.push(title);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEpisode();
|
||||||
|
|
||||||
|
if (currentScene) {
|
||||||
|
currentScene.content += `${currentScene.content ? '\n' : ''}${line}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeBuffer.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushEpisode();
|
||||||
|
|
||||||
|
const normalizedEpisodes: FinalizedEpisode[] = episodes.map((episode, idx) => {
|
||||||
|
const scenes = episode.scenes.length > 0
|
||||||
|
? episode.scenes
|
||||||
|
: [{ id: `scene-fallback-${idx}`, title: '\u573a\u666f 1', content: episode.content }];
|
||||||
|
|
||||||
|
const cleanedScenes = scenes.map((scene) => ({ ...scene, content: scene.content.trim() }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...episode,
|
||||||
|
scenes: cleanedScenes,
|
||||||
|
content: episode.content.trim(),
|
||||||
|
wordCount: countWords(episode.content),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidebarItems: FinalizedSidebarItem[] = [];
|
||||||
|
for (const episode of normalizedEpisodes) {
|
||||||
|
sidebarItems.push({ type: 'episode', text: episode.title, id: episode.id });
|
||||||
|
for (const scene of episode.scenes) {
|
||||||
|
sidebarItems.push({ type: 'scene', text: scene.title, id: scene.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { episodes: normalizedEpisodes, sidebarItems };
|
||||||
|
};
|
||||||
11
src/lib/modeSource.ts
Normal file
11
src/lib/modeSource.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type RootTab = 'conversion' | 'creation';
|
||||||
|
export type AppTab = RootTab | 'finalized';
|
||||||
|
|
||||||
|
export const getDisplayedMode = (activeTab: AppTab, previousTab: RootTab): RootTab => {
|
||||||
|
return activeTab === 'finalized' ? previousTab : activeTab;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDisplayedModeLabel = (activeTab: AppTab, previousTab: RootTab): string => {
|
||||||
|
const mode = getDisplayedMode(activeTab, previousTab);
|
||||||
|
return mode === 'conversion' ? '\u4e00\u952e\u8f6c\u6362\u6a21\u5f0f' : '\u5373\u65f6\u521b\u4f5c\u6a21\u5f0f';
|
||||||
|
};
|
||||||
145
src/lib/promptBuilder.ts
Normal file
145
src/lib/promptBuilder.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
AUDIENCE_PROMPT_MAP,
|
||||||
|
NARRATIVE_PROMPT_MAP,
|
||||||
|
THEME_PROMPT_MAP,
|
||||||
|
WORD_RANGE_PROMPT_MAP,
|
||||||
|
} from './promptProfiles.ts';
|
||||||
|
import type { PromptProfileGlobalSettings } from './promptProfiles.ts';
|
||||||
|
|
||||||
|
const FALLBACK_TEXT = '未设定';
|
||||||
|
|
||||||
|
export interface StructuredUserPromptInput {
|
||||||
|
sourceText: string;
|
||||||
|
scriptType: string;
|
||||||
|
scriptFormat: string;
|
||||||
|
combinedStyle?: string;
|
||||||
|
selectedThemes?: string[];
|
||||||
|
selectedNarratives?: string[];
|
||||||
|
globalSettings?: PromptProfileGlobalSettings;
|
||||||
|
previousEpisodeContent?: string;
|
||||||
|
episodeOutline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueLabels(values: string[] | undefined): string[] {
|
||||||
|
if (!values || values.length === 0) return [];
|
||||||
|
|
||||||
|
const cleaned = values
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return Array.from(new Set(cleaned));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCombinedStyle(combinedStyle: string | undefined): string[] {
|
||||||
|
if (!combinedStyle) return [];
|
||||||
|
return uniqueLabels(combinedStyle.split('+').map((value) => value.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGuidance(labels: string[], map: Record<string, string[]>, unknownPrefix: string): string[] {
|
||||||
|
if (labels.length === 0) return [];
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const label of labels) {
|
||||||
|
const guidance = map[label];
|
||||||
|
if (guidance && guidance.length > 0) {
|
||||||
|
result.push(...guidance);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(`${unknownPrefix}${label},并让该参数直接影响剧情推进。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBulletLines(lines: string[], fallbackLine: string): string {
|
||||||
|
const finalLines = lines.length > 0 ? lines : [fallbackLine];
|
||||||
|
return finalLines.map((line) => `- ${line}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickValue(value: string | undefined): string {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : FALLBACK_TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStructuredUserPrompt(input: StructuredUserPromptInput): string {
|
||||||
|
const parsedCombined = parseCombinedStyle(input.combinedStyle);
|
||||||
|
|
||||||
|
const themeLabels = uniqueLabels(
|
||||||
|
input.selectedThemes && input.selectedThemes.length > 0
|
||||||
|
? input.selectedThemes
|
||||||
|
: parsedCombined.filter((label) => Boolean(THEME_PROMPT_MAP[label])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const narrativeLabels = uniqueLabels([
|
||||||
|
...(input.selectedNarratives ?? []),
|
||||||
|
...parsedCombined.filter((label) => Boolean(NARRATIVE_PROMPT_MAP[label])),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const styleGuidanceLines = [
|
||||||
|
`已选核心风格:${themeLabels.length > 0 ? themeLabels.join('、') : FALLBACK_TEXT}`,
|
||||||
|
...resolveGuidance(themeLabels, THEME_PROMPT_MAP, '已选择参数:'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const narrativeGuidanceLines = [
|
||||||
|
`基础叙事格式:${pickValue(input.scriptFormat)}`,
|
||||||
|
`已选叙事手法:${narrativeLabels.length > 0 ? narrativeLabels.join('、') : FALLBACK_TEXT}`,
|
||||||
|
...resolveGuidance(narrativeLabels, NARRATIVE_PROMPT_MAP, '已选择叙事参数:'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const audiencePreference = pickValue(input.globalSettings?.audiencePreference);
|
||||||
|
const audienceGuidanceLines = [
|
||||||
|
`受众倾向:${audiencePreference}`,
|
||||||
|
...resolveGuidance(
|
||||||
|
audiencePreference === FALLBACK_TEXT ? [] : [audiencePreference],
|
||||||
|
AUDIENCE_PROMPT_MAP,
|
||||||
|
'受众倾向为:',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const wordRange = pickValue(input.globalSettings?.wordRange);
|
||||||
|
const wordRangeGuidanceLines = [
|
||||||
|
`字数范围:${wordRange}`,
|
||||||
|
...resolveGuidance(
|
||||||
|
wordRange === FALLBACK_TEXT ? [] : [wordRange],
|
||||||
|
WORD_RANGE_PROMPT_MAP,
|
||||||
|
'字数约束为:',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const previousEpisodeContent = pickValue(input.previousEpisodeContent);
|
||||||
|
|
||||||
|
return `请根据以下信息进行创作:
|
||||||
|
|
||||||
|
上一集衔接参考
|
||||||
|
${previousEpisodeContent}
|
||||||
|
|
||||||
|
1. 本集剧情指令
|
||||||
|
${pickValue(input.episodeOutline || input.sourceText)}
|
||||||
|
|
||||||
|
2. 剧本类型
|
||||||
|
${pickValue(input.scriptType)}
|
||||||
|
|
||||||
|
3. 核心风格执行要求
|
||||||
|
${formatBulletLines(styleGuidanceLines, '保持当前风格稳定推进。')}
|
||||||
|
|
||||||
|
4. 叙事手法执行要求
|
||||||
|
${formatBulletLines(narrativeGuidanceLines, '按当前叙事节奏完成该集。')}
|
||||||
|
|
||||||
|
5. 受众倾向执行要求
|
||||||
|
${formatBulletLines(audienceGuidanceLines, '根据目标受众调整情绪目标与爽点设计。')}
|
||||||
|
|
||||||
|
6. 字数范围执行要求
|
||||||
|
${formatBulletLines(wordRangeGuidanceLines, '根据剧情复杂度决定篇幅,保证节奏与完整度。')}
|
||||||
|
|
||||||
|
7. 剧情背景设置
|
||||||
|
${pickValue(input.globalSettings?.worldview)}
|
||||||
|
|
||||||
|
8. 故事大纲
|
||||||
|
${pickValue(input.globalSettings?.outline)}
|
||||||
|
|
||||||
|
9. 核心角色简介
|
||||||
|
${pickValue(input.globalSettings?.characters)}
|
||||||
|
|
||||||
|
请严格按照系统设定的规则和规范,生成该版本的剧本。`;
|
||||||
|
}
|
||||||
|
|
||||||
128
src/lib/promptProfiles.ts
Normal file
128
src/lib/promptProfiles.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
export interface PromptProfileGlobalSettings {
|
||||||
|
worldview: string;
|
||||||
|
outline: string;
|
||||||
|
characters: string;
|
||||||
|
audiencePreference: string;
|
||||||
|
wordRange: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptGuidanceMap = Record<string, string[]>;
|
||||||
|
|
||||||
|
export const THEME_PROMPT_MAP: PromptGuidanceMap = {
|
||||||
|
'悬疑': [
|
||||||
|
'信息披露采用延迟策略,每个关键场景至少留下一个待解疑点。',
|
||||||
|
'优先制造“已知与未知”的落差,让读者持续追问真相。',
|
||||||
|
],
|
||||||
|
'侦探': [
|
||||||
|
'围绕“线索收集-分析-验证”推进,不可只给结论不给过程。',
|
||||||
|
'每次调查推进都要带来新证据或推翻旧判断。',
|
||||||
|
],
|
||||||
|
'犯罪': [
|
||||||
|
'明确犯罪动机、手段与风险,冲突必须具备现实压力。',
|
||||||
|
'强化后果链条,避免“无代价犯罪”削弱可信度。',
|
||||||
|
],
|
||||||
|
'反转': [
|
||||||
|
'反转必须基于前文可回溯信息,不能凭空硬拐。',
|
||||||
|
'反转后要立即重排人物关系或目标,形成新一轮推进。',
|
||||||
|
],
|
||||||
|
'惊悚': [
|
||||||
|
'通过环境、时间限制与感官细节持续制造压迫感。',
|
||||||
|
'危险要逐级升级,避免同强度重复刺激。',
|
||||||
|
],
|
||||||
|
'推理': [
|
||||||
|
'结论必须由证据链推导,关键判断给出清晰因果。',
|
||||||
|
'控制误导信息比例,确保读者能在回看时自洽。',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NARRATIVE_PROMPT_MAP: PromptGuidanceMap = {
|
||||||
|
'顺叙': [
|
||||||
|
'按时间线稳定推进,重点保证因果清晰。',
|
||||||
|
'每个场景承接上一个场景的问题,并给出新的推进。',
|
||||||
|
],
|
||||||
|
'倒叙': [
|
||||||
|
'先抛结果或高压节点,再回溯关键成因。',
|
||||||
|
'倒叙段落必须服务当前主线,不可成为无关回忆。',
|
||||||
|
],
|
||||||
|
'插叙': [
|
||||||
|
'插叙只补关键动机、关系或秘密,不做冗长旁枝。',
|
||||||
|
'插叙结束后要迅速回到主线并改变当前局势。',
|
||||||
|
],
|
||||||
|
'第一人称': [
|
||||||
|
'严格限制在主角可感知范围内,保持主观一致性。',
|
||||||
|
'通过内心判断与即时反应增强代入感。',
|
||||||
|
],
|
||||||
|
'第三人称全知 / 限知': [
|
||||||
|
'全知视角用于宏观铺排,限知视角用于悬念控制。',
|
||||||
|
'视角切换必须有明确目的,避免信息过载。',
|
||||||
|
],
|
||||||
|
'多线叙事': [
|
||||||
|
'每条线都要有独立目标与阶段性冲突,不可空转。',
|
||||||
|
'在关键节点做线与线的呼应或碰撞,形成汇流。',
|
||||||
|
],
|
||||||
|
'环形叙事': [
|
||||||
|
'开篇意象或问题在结尾形成回应,但语义要升级。',
|
||||||
|
'首尾呼应不是重复,要体现人物或局势变化。',
|
||||||
|
],
|
||||||
|
'蒙太奇': [
|
||||||
|
'用并置镜头表达时间压缩、关系对照或情绪推进。',
|
||||||
|
'片段拼接后必须输出明确信息增量。',
|
||||||
|
],
|
||||||
|
'留白叙事': [
|
||||||
|
'关键事实可不直说,但要给足可推断信号。',
|
||||||
|
'留白后尽快在后续场景兑现,不长期悬空。',
|
||||||
|
],
|
||||||
|
'悬念叙事': [
|
||||||
|
'每个段落结尾尽量设置未解问题或风险升级点。',
|
||||||
|
'悬念要按“提出-加深-兑现/反转”节奏闭环。',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUDIENCE_PROMPT_MAP: PromptGuidanceMap = {
|
||||||
|
'男频': [
|
||||||
|
'目标读者:男性为主。',
|
||||||
|
'核心情绪:成就感 / 权力感 / 逆袭爽感。',
|
||||||
|
'主角需求:变强、翻盘、掌控世界。',
|
||||||
|
'爽点来源:打脸、升级、权力提升。',
|
||||||
|
'写男频,要构建一个宏大且规则残酷的世界,让主角去征服它;',
|
||||||
|
],
|
||||||
|
'女频': [
|
||||||
|
'目标读者:女性为主。',
|
||||||
|
'核心情绪:情感满足 / 关系张力 / 被偏爱。',
|
||||||
|
'主角需求:被爱、选择、情感成长。',
|
||||||
|
'爽点来源:情感拉扯、宠爱、修罗场。',
|
||||||
|
'写女频,要编织一张错综复杂的情感与社会关系网,让主角去撕破或重塑它。',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORD_RANGE_PROMPT_MAP: PromptGuidanceMap = {
|
||||||
|
'200 - 500': [
|
||||||
|
'目标字数:200 - 500 字。',
|
||||||
|
'极度压缩,只保留核心冲突、一次推进和一个结尾钩子。',
|
||||||
|
'场景尽量少,对白必须有信息密度。',
|
||||||
|
],
|
||||||
|
'500 - 1000': [
|
||||||
|
'目标字数:500 - 1000 字。',
|
||||||
|
'保持紧凑,允许一个清晰推进段和一个小高潮。',
|
||||||
|
'可以有少量氛围和动作细节,但不能稀释节奏。',
|
||||||
|
],
|
||||||
|
'1000 - 2000': [
|
||||||
|
'目标字数:1000 - 2000 字。',
|
||||||
|
'完整展开一条主冲突线,并加入适度人物反应。',
|
||||||
|
'中段必须有明确升级,不能只有起承没有转折。',
|
||||||
|
],
|
||||||
|
'2000 - 3000': [
|
||||||
|
'目标字数:2000 - 3000 字。',
|
||||||
|
'允许较完整的铺垫、升级、高潮和悬念结构。',
|
||||||
|
'可以增强动作、环境和关系细节,但必须围绕主线收束。',
|
||||||
|
],
|
||||||
|
'3000以上': [
|
||||||
|
'目标字数:3000 字以上。',
|
||||||
|
'允许多轮冲突推进和更丰富场景层次。',
|
||||||
|
'每 2 到 3 个场景最好都带来新的信息推进或冲突升级。',
|
||||||
|
],
|
||||||
|
'不限': [
|
||||||
|
'字数不设上限,按剧情复杂度决定篇幅。',
|
||||||
|
'优先保证节奏和完整度,不为了拉长而拉长,不为了压缩而压缩。',
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -1,8 +1,23 @@
|
|||||||
export type ScriptPreviewBlock =
|
export type ScriptPreviewBlock =
|
||||||
| { type: 'scene'; content: string }
|
| { type: 'scene'; content: string }
|
||||||
| { type: 'text'; content: string };
|
| { type: 'text'; content: string };
|
||||||
|
|
||||||
const SCENE_LINE_PATTERN = /^\s*(?:#{0,2}\s*)?(?:\u573A\u666F(?:\s*\d+)?|SCENE\b)/i;
|
const SCENE_LINE_PATTERN = /^\s*(?:#{0,6}\s*)?(?:\u573a\u666f(?:\s*\d+)?|SCENE\b)/i;
|
||||||
|
|
||||||
|
export function normalizeSceneHeadingMarkers(content: string): string {
|
||||||
|
return content
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return line;
|
||||||
|
if (!SCENE_LINE_PATTERN.test(trimmed)) return line;
|
||||||
|
|
||||||
|
const withoutMarker = trimmed.replace(/^#{1,6}\s*/, '');
|
||||||
|
return `## ${withoutMarker}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function splitScriptPreviewBlocks(content: string): ScriptPreviewBlock[] {
|
export function splitScriptPreviewBlocks(content: string): ScriptPreviewBlock[] {
|
||||||
return content
|
return content
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { GoogleGenAI, Type, ThinkingLevel } from "@google/genai";
|
import { GoogleGenAI, Type, ThinkingLevel } from "@google/genai";
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { isConversionAbortedError, throwIfAborted } from "../lib/conversionWorkflow";
|
import { isConversionAbortedError, throwIfAborted } from "../lib/conversionWorkflow";
|
||||||
|
import { buildStructuredUserPrompt } from "../lib/promptBuilder";
|
||||||
|
|
||||||
export interface AIConfig {
|
export interface AIConfig {
|
||||||
model: 'gemini' | 'doubao';
|
model: 'gemini' | 'doubao';
|
||||||
@ -227,6 +228,8 @@ export async function convertTextToScript(
|
|||||||
previousEpisodeContent?: string;
|
previousEpisodeContent?: string;
|
||||||
episodeTitle?: string;
|
episodeTitle?: string;
|
||||||
episodeOutline?: string;
|
episodeOutline?: string;
|
||||||
|
selectedThemes?: string[];
|
||||||
|
selectedNarratives?: string[];
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -611,32 +614,17 @@ ${seedContent}
|
|||||||
try {
|
try {
|
||||||
throwIfAborted(options?.signal);
|
throwIfAborted(options?.signal);
|
||||||
|
|
||||||
const prompt = `请根据以下信息进行创作:
|
const prompt = buildStructuredUserPrompt({
|
||||||
|
sourceText,
|
||||||
${options?.previousEpisodeContent || ''}
|
scriptType,
|
||||||
|
scriptFormat,
|
||||||
1. 本集剧情指令
|
combinedStyle,
|
||||||
${options?.episodeOutline || sourceText}
|
selectedThemes: options?.selectedThemes,
|
||||||
|
selectedNarratives: options?.selectedNarratives,
|
||||||
2. 剧本类型
|
globalSettings,
|
||||||
${scriptType}
|
previousEpisodeContent: options?.previousEpisodeContent,
|
||||||
|
episodeOutline: options?.episodeOutline || sourceText,
|
||||||
3. 核心风格
|
});
|
||||||
${combinedStyle}
|
|
||||||
|
|
||||||
4. 叙事手法
|
|
||||||
${scriptFormat}
|
|
||||||
|
|
||||||
5. 剧情背景设定
|
|
||||||
${globalSettings?.worldview || '未设定'}
|
|
||||||
|
|
||||||
6. 故事大纲
|
|
||||||
${globalSettings?.outline || '未设定'}
|
|
||||||
|
|
||||||
7. 核心角色简介
|
|
||||||
${globalSettings?.characters || '未设定'}
|
|
||||||
|
|
||||||
请严格按照系统设定的规则和规范,生成该版本的剧本。`;
|
|
||||||
|
|
||||||
const systemInstruction = baseSystemInstruction + v.instruction;
|
const systemInstruction = baseSystemInstruction + v.instruction;
|
||||||
let fullText = seedContent;
|
let fullText = seedContent;
|
||||||
@ -771,545 +759,28 @@ export async function generatePageScript(
|
|||||||
config: AIConfig
|
config: AIConfig
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const previousContext = previousScript ? `
|
const combinedStyle = [...styles, ...narratives].join(' + ');
|
||||||
【前情提要(上一集定稿剧本)】
|
|
||||||
${previousScript}
|
await convertTextToScript(
|
||||||
|
story,
|
||||||
请注意:本集剧情必须紧接上一集,保持人物性格、关系和剧情逻辑的连贯性。
|
scriptType,
|
||||||
` : '';
|
scriptFormat,
|
||||||
|
combinedStyle,
|
||||||
const baseSystemInstruction = `你是一位专业影视编剧和剧本改编专家。
|
onUpdate,
|
||||||
|
config,
|
||||||
你的任务是将用户提供的故事文本改编为结构化影视剧本。
|
globalSettings,
|
||||||
|
|
||||||
用户可能会提供:
|
|
||||||
|
|
||||||
- 世界观设定
|
|
||||||
- 剧本大纲
|
|
||||||
- 角色设定
|
|
||||||
- 剧本类型
|
|
||||||
- 风格
|
|
||||||
- 叙事手法
|
|
||||||
|
|
||||||
你需要根据这些信息生成 **当前一集的影视剧本内容**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 剧本生成范围限制(重要)
|
|
||||||
|
|
||||||
用户提供的剧本大纲是 **整个故事的大纲**。
|
|
||||||
|
|
||||||
但你生成的内容 **只允许创作当前一集的剧本内容**。
|
|
||||||
|
|
||||||
严格遵守以下规则:
|
|
||||||
|
|
||||||
- 不得一次性写完整个故事
|
|
||||||
- 不得提前写后续剧情
|
|
||||||
- 不得扩写完整剧情线
|
|
||||||
- 不得总结整个故事
|
|
||||||
- 不得输出剧情概述
|
|
||||||
|
|
||||||
如果大纲包含多个剧情阶段:
|
|
||||||
|
|
||||||
你必须:
|
|
||||||
|
|
||||||
- 只选择 **当前集对应的剧情部分**
|
|
||||||
- 将这一部分扩展为完整影视剧本
|
|
||||||
|
|
||||||
剧情推进必须自然,但 **不能超出本集范围**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 剧本创作规则
|
|
||||||
|
|
||||||
1. 输出必须为影视剧本,而不是小说。
|
|
||||||
2. 剧本必须包含:
|
|
||||||
- 场景
|
|
||||||
- 人物
|
|
||||||
- 动作
|
|
||||||
- 对话
|
|
||||||
3. 可以增加合理戏剧冲突,但不能改变故事核心。
|
|
||||||
4. 剧情必须以 **可拍摄画面** 为主,而不是叙述性文字。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 单集剧本结构规则
|
|
||||||
|
|
||||||
每一集剧本必须包含以下结构:
|
|
||||||
|
|
||||||
1. **开场钩子**
|
|
||||||
|
|
||||||
开场必须迅速进入关键情境或冲突。
|
|
||||||
|
|
||||||
避免缓慢铺垫。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
1. **剧情推进**
|
|
||||||
|
|
||||||
角色的行动推动故事发展。
|
|
||||||
|
|
||||||
必须出现新的信息或行动。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
1. **冲突升级**
|
|
||||||
|
|
||||||
出现新的问题、矛盾或障碍。
|
|
||||||
|
|
||||||
剧情张力逐渐提升。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
1. **阶段性高潮**
|
|
||||||
|
|
||||||
角色做出关键行动、发现线索或爆发冲突。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
1. **悬念断点**
|
|
||||||
|
|
||||||
本集结尾必须留下悬念,例如:
|
|
||||||
|
|
||||||
- 未解开的秘密
|
|
||||||
- 即将发生的危机
|
|
||||||
- 突然出现的新人物
|
|
||||||
- 关键线索揭示
|
|
||||||
|
|
||||||
结尾必须 **强烈驱动观众继续观看下一集**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 视觉化表达规则
|
|
||||||
|
|
||||||
剧本必须优先使用 **可拍摄动作** 表达剧情。
|
|
||||||
|
|
||||||
避免直接叙述人物心理。
|
|
||||||
|
|
||||||
错误示例:
|
|
||||||
|
|
||||||
他感到非常悲伤。
|
|
||||||
|
|
||||||
正确示例:
|
|
||||||
|
|
||||||
动作描述:
|
|
||||||
|
|
||||||
他低着头,手指紧紧抓住桌边,指节发白。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 一、短剧剧本创作规则
|
|
||||||
|
|
||||||
短剧通常为 **竖屏短剧**:
|
|
||||||
|
|
||||||
- 单集时长:1–3 分钟
|
|
||||||
- 总集数:20–100 集
|
|
||||||
|
|
||||||
短剧剧本必须遵循 **极致碎片化 + 高钩子密度结构**。
|
|
||||||
|
|
||||||
每一集建议:
|
|
||||||
|
|
||||||
- 场景数量:3–6个
|
|
||||||
- 节奏极快
|
|
||||||
- 冲突密度极高
|
|
||||||
|
|
||||||
核心原则:
|
|
||||||
|
|
||||||
### 1. 3秒强钩子开场
|
|
||||||
|
|
||||||
第一场戏必须直接进入 **最高冲突节点**。
|
|
||||||
|
|
||||||
常见方式:
|
|
||||||
|
|
||||||
- 生死危机
|
|
||||||
- 身份揭露
|
|
||||||
- 背叛现场
|
|
||||||
- 即将发生的重大反转
|
|
||||||
|
|
||||||
必须快速制造 **视觉冲击或情绪爆点**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 单集节奏结构
|
|
||||||
|
|
||||||
短剧每一集必须遵循高密度节奏:
|
|
||||||
|
|
||||||
- 30秒:第一次剧情反转
|
|
||||||
- 1分钟:形成小高潮
|
|
||||||
- 结尾:强悬念断点
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 极致标签化人物
|
|
||||||
|
|
||||||
人物必须清晰易懂:
|
|
||||||
|
|
||||||
- 正派:明显善良
|
|
||||||
- 反派:明显恶
|
|
||||||
- 核心角色:标签强烈
|
|
||||||
|
|
||||||
人物关系 **3秒内可理解**。
|
|
||||||
|
|
||||||
避免复杂心理描写。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 冲突密度极高
|
|
||||||
|
|
||||||
短剧禁止:
|
|
||||||
|
|
||||||
- 冗长铺垫
|
|
||||||
- 日常生活戏
|
|
||||||
- 无冲突对白
|
|
||||||
|
|
||||||
剧情必须始终:
|
|
||||||
|
|
||||||
- 推动冲突
|
|
||||||
- 推动剧情
|
|
||||||
- 交付情绪价值
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 情绪驱动优先
|
|
||||||
|
|
||||||
短剧优先交付:
|
|
||||||
|
|
||||||
- 爽感
|
|
||||||
- 反转
|
|
||||||
- 情绪爆发
|
|
||||||
|
|
||||||
而不是人物成长。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 二、电视剧剧本创作规则
|
|
||||||
|
|
||||||
电视剧通常为:
|
|
||||||
|
|
||||||
- 单集约45分钟
|
|
||||||
- 全剧12–100集
|
|
||||||
|
|
||||||
电视剧剧本核心是 **人物粘性 + 长线叙事**。
|
|
||||||
|
|
||||||
每一集建议:
|
|
||||||
|
|
||||||
- 场景数量:6–12个
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. 立体人物塑造
|
|
||||||
|
|
||||||
人物需要具备:
|
|
||||||
|
|
||||||
- 明确欲望
|
|
||||||
- 性格矛盾
|
|
||||||
- 成长变化
|
|
||||||
|
|
||||||
角色不能简单黑白化。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 多线叙事结构
|
|
||||||
|
|
||||||
电视剧通常包含:
|
|
||||||
|
|
||||||
- 一条核心主线
|
|
||||||
- 多条副线剧情
|
|
||||||
|
|
||||||
副线用于:
|
|
||||||
|
|
||||||
- 丰富人物
|
|
||||||
- 扩展世界观
|
|
||||||
- 支撑主题表达
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 单集闭环 + 全剧悬念
|
|
||||||
|
|
||||||
每一集必须包含:
|
|
||||||
|
|
||||||
- 一个小冲突
|
|
||||||
- 一个阶段性目标
|
|
||||||
- 一个阶段结果
|
|
||||||
|
|
||||||
同时保持:
|
|
||||||
|
|
||||||
- 长线核心悬念
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 节奏张弛有度
|
|
||||||
|
|
||||||
电视剧允许:
|
|
||||||
|
|
||||||
- 情感铺垫
|
|
||||||
- 人物关系发展
|
|
||||||
- 现实生活细节
|
|
||||||
|
|
||||||
剧情需要:
|
|
||||||
|
|
||||||
高潮 → 缓冲 → 再升级。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 现实共鸣
|
|
||||||
|
|
||||||
可以涉及:
|
|
||||||
|
|
||||||
- 家庭关系
|
|
||||||
- 职场竞争
|
|
||||||
- 社会阶层
|
|
||||||
- 情感困境
|
|
||||||
|
|
||||||
增强观众共鸣。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 三、电影剧本创作规则
|
|
||||||
|
|
||||||
电影通常为:
|
|
||||||
|
|
||||||
- 总时长90–120分钟
|
|
||||||
|
|
||||||
电影剧本遵循 **经典三幕式结构**。
|
|
||||||
|
|
||||||
如果电影剧本按集生成:
|
|
||||||
|
|
||||||
必须将三幕式结构 **拆分到不同集数推进**。
|
|
||||||
|
|
||||||
禁止:
|
|
||||||
|
|
||||||
一次写完整部电影。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 第一幕:建置
|
|
||||||
|
|
||||||
建立:
|
|
||||||
|
|
||||||
- 主角
|
|
||||||
- 世界观
|
|
||||||
- 核心冲突
|
|
||||||
- 激励事件
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 第二幕:对抗
|
|
||||||
|
|
||||||
主角不断面对挑战:
|
|
||||||
|
|
||||||
- 冲突升级
|
|
||||||
- 新障碍出现
|
|
||||||
- 多次转折
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 第三幕:高潮与结局
|
|
||||||
|
|
||||||
完成:
|
|
||||||
|
|
||||||
- 最终对决
|
|
||||||
- 冲突解决
|
|
||||||
- 人物弧光
|
|
||||||
- 主题表达
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 电影叙事核心原则
|
|
||||||
|
|
||||||
电影剧本必须:
|
|
||||||
|
|
||||||
- 高度聚焦核心主线
|
|
||||||
- 减少无关支线
|
|
||||||
- 强调视觉化冲突
|
|
||||||
- 注重主题表达
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 剧本结构规范
|
|
||||||
|
|
||||||
场景格式:
|
|
||||||
|
|
||||||
## 场景 1 地点 / 时间
|
|
||||||
|
|
||||||
|
|
||||||
环境、动作描述说明:
|
|
||||||
描述人物行为和环境变化。
|
|
||||||
|
|
||||||
人物对白格式说明:
|
|
||||||
|
|
||||||
角色名:对白内容
|
|
||||||
|
|
||||||
可以适当加入镜头提示,例如:
|
|
||||||
特写、远景、切换、推镜等。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 符合剧本结构规范的案例
|
|
||||||
|
|
||||||
## 场景 1 小巷 / 夜
|
|
||||||
|
|
||||||
动作描述:
|
|
||||||
|
|
||||||
昏暗的路灯闪烁,小巷空无一人。远处传来急促的脚步声。
|
|
||||||
|
|
||||||
特写:一只沾满血的手按在墙上。
|
|
||||||
|
|
||||||
张强跌跌撞撞冲进小巷,回头张望。
|
|
||||||
|
|
||||||
远景:黑暗中,一个模糊的身影慢慢走出阴影。
|
|
||||||
|
|
||||||
张强:
|
|
||||||
|
|
||||||
你……你不是已经死了吗?
|
|
||||||
|
|
||||||
身影停下脚步。
|
|
||||||
|
|
||||||
切换:身影缓缓抬头,露出冷笑。
|
|
||||||
|
|
||||||
神秘人:
|
|
||||||
|
|
||||||
我死了。
|
|
||||||
|
|
||||||
神秘人:
|
|
||||||
|
|
||||||
那今晚追你的人是谁?
|
|
||||||
|
|
||||||
---`;
|
|
||||||
|
|
||||||
const versionPrompts = [
|
|
||||||
{
|
{
|
||||||
id: 0,
|
targetVersionIds: [0, 1],
|
||||||
instruction: `
|
previousEpisodeContent: previousScript || '',
|
||||||
# 生成要求(A版本:严格设定)
|
episodeOutline: story,
|
||||||
|
selectedThemes: styles,
|
||||||
严格按照剧本设定内容生成,必须以当前集大纲、剧情背景设定、核心角色设定为准。
|
selectedNarratives: narratives,
|
||||||
不得新增超出设定的新人物、新关键事件、新支线,不得改写既定人物关系与关键结局走向。
|
|
||||||
允许优化表达,但不能改变设定事实、因果关系和当前集边界。
|
|
||||||
|
|
||||||
# 输出格式
|
|
||||||
|
|
||||||
直接输出剧本内容,剧本内容严格按照剧本结构规范输出,不能出现剧本以外的内容,使用 Markdown 格式。不要包含 JSON 标记,不要包含 [[VERSION]] 标签,不要包含集数标记,不要包含剧本总结,不要出现标签型词汇(如‘动作描写’、‘场景描写’、‘人物描写’等等)。
|
|
||||||
请务必使用中文。`
|
|
||||||
},
|
},
|
||||||
{
|
);
|
||||||
id: 1,
|
|
||||||
instruction: `
|
|
||||||
# 生成要求(B版本:发散增强)
|
|
||||||
|
|
||||||
在不违背剧本设定内容的前提下,可以发散思考,生成更丰富的剧本表达。
|
|
||||||
允许补充氛围描写、动作细节、人物反应、对话层次和戏剧张力,但不能突破当前集大纲边界。
|
|
||||||
不得改写核心设定、人物关系和关键结局走向,保证故事主线与设定一致。
|
|
||||||
|
|
||||||
# 输出格式
|
|
||||||
|
|
||||||
直接输出剧本内容,剧本内容严格按照剧本结构规范输出,不能出现剧本以外的内容,使用 Markdown 格式。不要包含 JSON 标记,不要包含 [[VERSION]] 标签,不要包含集数标记,不要包含剧本总结,不要出现标签型词汇(如‘动作描写’、‘场景描写’、‘人物描写’等等)。
|
|
||||||
请务必使用中文。`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
instruction: `
|
|
||||||
# 生成要求
|
|
||||||
|
|
||||||
强调情节的铺垫与反转,利用非线性叙事或不可靠叙述者,制造紧张感和悬念。
|
|
||||||
侧重于核心风格与叙事手法要求中的悬念设置和情节推动。
|
|
||||||
|
|
||||||
# 输出格式
|
|
||||||
|
|
||||||
直接输出剧本内容,剧本内容严格按照剧本结构规范输出,不能出现剧本以外的内容,使用 Markdown 格式。不要包含 JSON 标记,不要包含 [[VERSION]] 标签,不要包含集数标记,不要包含剧本总结,不要出现标签型词汇(如‘动作描写’、‘场景描写’、‘人物描写’等等)。
|
|
||||||
请务必使用中文。`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const generateVersion = async (v: { id: number, instruction: string }) => {
|
|
||||||
try {
|
|
||||||
const prompt = `请根据以下信息进行创作:
|
|
||||||
|
|
||||||
${previousContext}
|
|
||||||
|
|
||||||
1. 本集剧情指令
|
|
||||||
${story}
|
|
||||||
|
|
||||||
2. 剧本类型
|
|
||||||
${scriptType}
|
|
||||||
|
|
||||||
3. 核心风格
|
|
||||||
${styles.join(', ')}
|
|
||||||
|
|
||||||
4. 叙事手法
|
|
||||||
${narratives.join(', ')}
|
|
||||||
|
|
||||||
5. 剧情背景设定
|
|
||||||
${globalSettings?.worldview || '未设定'}
|
|
||||||
|
|
||||||
6. 故事大纲
|
|
||||||
${globalSettings?.outline || '未设定'}
|
|
||||||
|
|
||||||
7. 核心角色简介
|
|
||||||
${globalSettings?.characters || '未设定'}
|
|
||||||
|
|
||||||
请严格按照系统设定的规则和规范,生成该版本的剧本。`;
|
|
||||||
|
|
||||||
const systemInstruction = baseSystemInstruction + v.instruction;
|
|
||||||
|
|
||||||
if (config.model === 'gemini') {
|
|
||||||
const ai = getGeminiClient(config.apiKey);
|
|
||||||
const result = await ai.models.generateContentStream({
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
parts: [{ text: prompt }]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
systemInstruction: systemInstruction,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let fullText = "";
|
|
||||||
for await (const chunk of result) {
|
|
||||||
const chunkText = chunk.text;
|
|
||||||
if (chunkText) {
|
|
||||||
fullText += chunkText;
|
|
||||||
onUpdate(v.id, fullText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const client = getDoubaoClient(config.apiKey);
|
|
||||||
const stream: any = await client.chat.completions.create({
|
|
||||||
model: "doubao-seed-2-0-lite-260215",
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemInstruction },
|
|
||||||
{ role: "user", content: prompt }
|
|
||||||
],
|
|
||||||
stream: true,
|
|
||||||
thinking: { "type": "disabled" }
|
|
||||||
// extra_body: {
|
|
||||||
// "thinking": { "type": "enabled" },
|
|
||||||
// "reasoning_effort": "minimal"
|
|
||||||
// }
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
let fullText = "";
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const content = chunk.choices[0]?.delta?.content || "";
|
|
||||||
if (content) {
|
|
||||||
fullText += content;
|
|
||||||
onUpdate(v.id, fullText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error generating version ${v.id}:`, error);
|
|
||||||
onUpdate(v.id, "生成失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all(versionPrompts.map(v => generateVersion(v)));
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating page script:", error);
|
console.error("Error generating page script:", error);
|
||||||
|
onUpdate(0, "\u751f\u6210\u5931\u8d25");
|
||||||
|
onUpdate(1, "\u751f\u6210\u5931\u8d25");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1323,82 +794,40 @@ export async function generateAllScripts(
|
|||||||
globalSettings?: { worldview: string; outline: string; characters: string; audiencePreference: string; wordRange: string }
|
globalSettings?: { worldview: string; outline: string; characters: string; audiencePreference: string; wordRange: string }
|
||||||
): Promise<string[][]> {
|
): Promise<string[][]> {
|
||||||
try {
|
try {
|
||||||
const generateForPage = async (pageStory: string, pageIndex: number): Promise<string[]> => {
|
const results: string[][] = [];
|
||||||
const globalContext = globalSettings ? `
|
let previousScript = '';
|
||||||
【全局设定】
|
|
||||||
剧情背景设定: ${globalSettings.worldview || '未设定'}
|
|
||||||
故事大纲: ${globalSettings.outline || '未设定'}
|
|
||||||
核心角色简介: ${globalSettings.characters || '未设定'}
|
|
||||||
` : '';
|
|
||||||
|
|
||||||
const basePrompt = `请将以下故事情节片段改编成三个不同侧重点的专业 ${scriptType} 剧本版本。
|
for (const page of pages) {
|
||||||
|
const versions = ['', ''];
|
||||||
|
|
||||||
${globalContext}
|
await generatePageScript(
|
||||||
|
page.story,
|
||||||
当前集剧情指令: ${pageStory}
|
scriptType,
|
||||||
|
scriptFormat,
|
||||||
剧本格式要求: ${scriptFormat}
|
styles,
|
||||||
风格标签: ${styles.join(', ')}
|
narratives,
|
||||||
叙事手法: ${narratives.join(', ')}
|
globalSettings,
|
||||||
|
previousScript,
|
||||||
要求:
|
(versionIndex, content) => {
|
||||||
请严格按照以下格式输出,不要包含任何 Markdown 代码块标记或解释性文字。
|
if (versionIndex >= 0 && versionIndex < versions.length) {
|
||||||
|
versions[versionIndex] = content;
|
||||||
[[VERSION A]]
|
|
||||||
...
|
|
||||||
[[VERSION B]]
|
|
||||||
...
|
|
||||||
[[VERSION C]]
|
|
||||||
...`;
|
|
||||||
|
|
||||||
const systemInstruction = `你是一位专业影视编剧和剧本改编专家。你的任务是将故事文本改编为影视剧本。请严格遵守剧本创作规则,并按指定的 [[VERSION X]] 标签格式输出。请务必使用中文。`;
|
|
||||||
|
|
||||||
let text = "";
|
|
||||||
if (config.model === 'gemini') {
|
|
||||||
const ai = getGeminiClient(config.apiKey);
|
|
||||||
const response = await ai.models.generateContent({
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
contents: [{ role: "user", parts: [{ text: basePrompt }] }],
|
|
||||||
config: { systemInstruction }
|
|
||||||
});
|
|
||||||
text = response.text || "";
|
|
||||||
} else {
|
|
||||||
const client = getDoubaoClient(config.apiKey);
|
|
||||||
const response = await client.chat.completions.create({
|
|
||||||
model: "doubao-seed-2-0-lite-260215",
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemInstruction },
|
|
||||||
{ role: "user", content: basePrompt }
|
|
||||||
],
|
|
||||||
thinking: { "type": "disabled" }
|
|
||||||
// extra_body: {
|
|
||||||
// "thinking": { "type": "enabled" },
|
|
||||||
// "reasoning_effort": "minimal"
|
|
||||||
// }
|
|
||||||
} as any);
|
|
||||||
text = response.choices[0]?.message?.content || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const versions = ["", "", ""];
|
|
||||||
if (text) {
|
|
||||||
const versionParts = text.split(/\[\[VERSION [A-C]\]\]/);
|
|
||||||
const versionHeaders = text.match(/\[\[VERSION [A-C]\]\]/g) || [];
|
|
||||||
versionHeaders.forEach((vHeader, vIdx) => {
|
|
||||||
const versionId = vHeader.replace('[[VERSION ', '').replace(']]', '');
|
|
||||||
const content = versionParts[vIdx + 1] || "";
|
|
||||||
const vMap: Record<string, number> = { A: 0, B: 1, C: 2 };
|
|
||||||
const vIndex = vMap[versionId];
|
|
||||||
if (vIndex !== undefined) {
|
|
||||||
versions[vIndex] = content.trim();
|
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
config,
|
||||||
return versions;
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return await Promise.all(pages.map((p, i) => generateForPage(p.story, i)));
|
if (!versions.some((content) => content.trim())) {
|
||||||
|
versions[0] = "\u751f\u6210\u5931\u8d25";
|
||||||
|
versions[1] = "\u751f\u6210\u5931\u8d25";
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(versions);
|
||||||
|
previousScript = versions[0].trim() ? versions[0] : previousScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating all scripts:", error);
|
console.error("Error generating all scripts:", error);
|
||||||
return pages.map(() => ["生成失败", "生成失败", "生成失败"]);
|
return pages.map(() => ["\u751f\u6210\u5931\u8d25", "\u751f\u6210\u5931\u8d25"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user