返回 VideoClaw
StoryboardStage.tsx
根目录 / video-claw / video-claw / frontend / components / stages / StoryboardStage.tsx
1 'use client';
2
3 import React, { useState, useMemo, useCallback } from 'react';
4 import {
5 Plus, Trash2, Film, Clock, MapPin, Users, Edit3, Save, X,
6 LayoutList, Camera, ChevronDown, ChevronRight,
7 AlertCircle, Clapperboard
8 } from 'lucide-react';
9 import type { StageViewProps } from './types';
10 import StageActions from './StageActions';
11 import StageProgress from './StageProgress';
12
13 // ─── 类型定义 ───
14
15 interface Shot {
16 shot_number: number;
17 shot_type: string;
18 duration: number;
19 content: string;
20 }
21
22 interface Segment {
23 segment_id: string;
24 segment_number: number;
25 episode_number: number;
26 location: string;
27 characters: string[];
28 total_duration: number;
29 shots: Shot[];
30 }
31
32 interface Episode {
33 episode_number: number;
34 episode_title: string;
35 segments: Segment[];
36 }
37
38 // ─── 样式常量 ───
39
40 const SHOT_TYPE_DECOR = {
41 '远景': { bg: 'bg-indigo-50', text: 'text-indigo-600', border: 'border-indigo-100' },
42 '中景': { bg: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-100' },
43 '近景': { bg: 'bg-cyan-50', text: 'text-cyan-600', border: 'border-cyan-100' },
44 '过肩近景': { bg: 'bg-amber-50', text: 'text-amber-600', border: 'border-amber-100' },
45 '特写': { bg: 'bg-rose-50', text: 'text-rose-600', border: 'border-rose-100' },
46 };
47
48 // ─── 主组件 ───
49
50 export default function StoryboardStage({
51 state,
52 onConfirm,
53 onIntervene,
54 onRegenerate,
55 showConfirm,
56 isRunning,
57 hasPendingItems,
58 hasNextStageStarted
59 }: StageViewProps) {
60 const artifactData = state.artifact;
61
62 // 获取剧集数据 (新结构)
63 const episodes: Episode[] = useMemo(() => {
64 if (Array.isArray(artifactData?.episodes)) return artifactData.episodes;
65 if (artifactData?.payload?.episodes) return artifactData.payload.episodes;
66 return [];
67 }, [artifactData]);
68
69 const [isEditing, setIsEditing] = useState(false);
70 const [editMode, setEditMode] = useState<'structured' | 'raw'>('structured');
71 const [editEpisodes, setEditEpisodes] = useState<Episode[]>([]);
72 const [rawJson, setRawJson] = useState('');
73
74 // ─── 编辑逻辑 ───
75
76 const startEdit = useCallback(() => {
77 setEditEpisodes(JSON.parse(JSON.stringify(episodes)));
78 setRawJson(JSON.stringify(episodes, null, 2));
79 setIsEditing(true);
80 setEditMode('structured');
81 }, [episodes]);
82
83 const cancelEdit = useCallback(() => setIsEditing(false), []);
84
85 const handleSave = useCallback(() => {
86 let finalEpisodes: Episode[];
87 if (editMode === 'raw') {
88 try { finalEpisodes = JSON.parse(rawJson); } catch { finalEpisodes = editEpisodes; }
89 } else {
90 finalEpisodes = editEpisodes;
91 }
92 onIntervene({ modified_storyboard: finalEpisodes });
93 setIsEditing(false);
94 }, [editMode, rawJson, editEpisodes, onIntervene]);
95
96 const switchEditMode = (mode: 'structured' | 'raw') => {
97 if (mode === 'raw') {
98 setRawJson(JSON.stringify(editEpisodes, null, 2));
99 } else {
100 try {
101 const parsed = JSON.parse(rawJson);
102 if (Array.isArray(parsed)) setEditEpisodes(parsed);
103 } catch { /* ignore */ }
104 }
105 setEditMode(mode);
106 };
107
108 const updateSegmentField = (epIdx: number, segIdx: number, field: keyof Segment, value: any) => {
109 setEditEpisodes(prev => prev.map((ep, i) => {
110 if (i !== epIdx) return ep;
111 const newSegments = ep.segments.map((s, j) => j === segIdx ? { ...s, [field]: value } : s);
112 return { ...ep, segments: newSegments };
113 }));
114 };
115
116 const updateShotField = (epIdx: number, segIdx: number, shotIdx: number, field: keyof Shot, value: any) => {
117 setEditEpisodes(prev => prev.map((ep, i) => {
118 if (i !== epIdx) return ep;
119 const newSegments = ep.segments.map((seg, j) => {
120 if (j !== segIdx) return seg;
121 const newShots = seg.shots.map((shot, k) => k === shotIdx ? { ...shot, [field]: value } : shot);
122 const newTotal = newShots.reduce((sum, s) => sum + (Number(s.duration) || 0), 0);
123 return { ...seg, shots: newShots, total_duration: newTotal };
124 });
125 return { ...ep, segments: newSegments };
126 }));
127 };
128
129 const addShot = (epIdx: number, segIdx: number) => {
130 setEditEpisodes(prev => prev.map((ep, i) => {
131 if (i !== epIdx) return ep;
132 const newSegments = ep.segments.map((seg, j) => {
133 if (j !== segIdx) return seg;
134 const newShot: Shot = { shot_number: seg.shots.length + 1, shot_type: '近景', duration: 5, content: '' };
135 return { ...seg, shots: [...seg.shots, newShot], total_duration: seg.total_duration + 5 };
136 });
137 return { ...ep, segments: newSegments };
138 }));
139 };
140
141 const deleteShot = (epIdx: number, segIdx: number, shotIdx: number) => {
142 setEditEpisodes(prev => prev.map((ep, i) => {
143 if (i !== epIdx) return ep;
144 const newSegments = ep.segments.map((seg, j) => {
145 if (j !== segIdx) return seg;
146 const newShots = seg.shots.filter((_, k) => k !== shotIdx);
147 const newTotal = newShots.reduce((sum, s) => sum + (Number(s.duration) || 0), 0);
148 return { ...seg, shots: newShots.map((s, idx) => ({...s, shot_number: idx + 1})), total_duration: newTotal };
149 });
150 return { ...ep, segments: newSegments };
151 }));
152 };
153
154 // ─── 渲染部分 ───
155
156 const episodesToRender = isEditing ? editEpisodes : episodes;
157 const hasEpisodes = episodes.length > 0;
158
159 // 计算统计数据
160 const stats = useMemo(() => {
161 if (!episodesToRender.length) return { episodes: 0, segments: 0, shots: 0, duration: 0 };
162 let totalSegments = 0;
163 let totalShots = 0;
164 let totalDuration = 0;
165 episodesToRender.forEach(ep => {
166 const segs = ep.segments || [];
167 totalSegments += segs.length;
168 segs.forEach(seg => {
169 totalShots += (seg.shots || []).length;
170 totalDuration += (seg.total_duration || 0);
171 });
172 });
173 return {
174 episodes: episodesToRender.length,
175 segments: totalSegments,
176 shots: totalShots,
177 duration: totalDuration
178 };
179 }, [episodesToRender]);
180
181 return (
182 <div className="flex flex-col h-full bg-white">
183 <div className="flex-1 min-w-0 overflow-y-auto p-4 sm:p-6 custom-scrollbar">
184 {/* 标题栏 */}
185 <div className="flex flex-col xl:flex-row xl:items-start xl:justify-between gap-4 mb-6">
186 <div className="flex min-w-0 flex-col">
187 <h2 className="text-lg font-semibold text-gray-800">分镜设计</h2>
188 <p className="text-sm text-gray-500">
189 生成分段分镜脚本 (景别·时长·叙事内容) 以及指导性的视觉流转设计
190 </p>
191 </div>
192
193 <div className="flex flex-wrap items-center gap-3">
194 {hasEpisodes && !isEditing && (
195 <div className="flex flex-wrap items-center min-h-12 gap-3 sm:gap-5 px-4 sm:px-5 py-2 bg-violet-50 rounded-xl border border-violet-100 shadow-sm">
196 <div className="flex items-center gap-2">
197 <Film className="w-3.5 h-3.5 text-violet-500" />
198 <span className="text-sm text-violet-700 font-bold whitespace-nowrap">总计 {stats.episodes} 集</span>
199 </div>
200 <div className="w-px h-6 bg-violet-200" />
201 <div className="flex items-center gap-2">
202 <Clapperboard className="w-3.5 h-3.5 text-violet-500" />
203 <span className="text-sm text-violet-700 font-bold whitespace-nowrap">{stats.segments} 个片段</span>
204 </div>
205 <div className="w-px h-6 bg-violet-200" />
206 <div className="flex items-center gap-2">
207 <Camera className="w-3.5 h-3.5 text-violet-500" />
208 <span className="text-sm text-violet-700 font-bold whitespace-nowrap">{stats.shots} 个分镜</span>
209 </div>
210 <div className="w-px h-6 bg-violet-200" />
211 <div className="flex items-center gap-2 px-1">
212 <Clock className="w-3.5 h-3.5 text-violet-500" />
213 <span className="text-sm text-violet-700 font-black whitespace-nowrap">预计时长 {stats.duration}s</span>
214 </div>
215 </div>
216 )}
217
218 {isEditing && (
219 <div className="flex items-center gap-2">
220 <div className="bg-gray-100 p-1 rounded-lg flex items-center mr-2">
221 <button onClick={() => switchEditMode('structured')} className={`px-2 py-1 text-[10px] font-medium rounded-md transition-all ${editMode === 'structured' ? 'bg-white text-violet-600 shadow-sm' : 'text-gray-500'}`}>可视化</button>
222 <button onClick={() => switchEditMode('raw')} className={`px-2 py-1 text-[10px] font-medium rounded-md transition-all ${editMode === 'raw' ? 'bg-white text-violet-600 shadow-sm' : 'text-gray-500'}`}>JSON</button>
223 </div>
224 <button onClick={cancelEdit} className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100">
225 <X className="w-3.5 h-3.5" />取消
226 </button>
227 <button onClick={handleSave} className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium text-white bg-violet-500 hover:bg-violet-600">
228 <Save className="w-3.5 h-3.5" />保存
229 </button>
230 </div>
231 )}
232 </div>
233 </div>
234
235 {/* 进度提示 */}
236 {isRunning && state.progress < 100 && (
237 <StageProgress message={state.progressMessage} progress={state.progress} color="violet" />
238 )}
239
240 {/* 主体内容 */}
241 {!hasEpisodes && !isRunning && !isEditing ? (
242 <div className="flex flex-col items-center justify-center py-20 text-gray-400">
243 <Film className="w-12 h-12 text-gray-200 mb-4" />
244 <p className="text-xs">等待生成分镜剧本...</p>
245 </div>
246 ) : isEditing && editMode === 'raw' ? (
247 <div className="h-[500px] bg-gray-950 rounded-xl overflow-hidden border border-gray-800">
248 <textarea
249 className="w-full h-full bg-transparent text-gray-300 p-6 font-mono text-sm resize-none focus:outline-none"
250 value={rawJson}
251 onChange={(e) => setRawJson(e.target.value)}
252 spellCheck={false}
253 />
254 </div>
255 ) : (
256 <div className="space-y-12">
257 {episodesToRender.map((episode, epIdx) => {
258 const segs = episode.segments || [];
259 const epTotalTime = segs.reduce((sum, s) => sum + (s.total_duration || 0), 0);
260 const epShotCount = segs.reduce((sum, s) => sum + ((s.shots || []).length), 0);
261 return (
262 <div key={epIdx} className="space-y-4">
263 {/* 一级:剧集抬头 (参考第一阶段) */}
264 <div className="flex flex-wrap items-center justify-between gap-3 py-2 px-1 border-b border-gray-100">
265 <div className="flex min-w-0 items-center gap-3">
266 <div className="w-1.5 h-6 bg-violet-500 rounded-full" />
267 <h3 className="min-w-0 text-base font-bold text-gray-800">第 {String(episode.episode_number)} 集:{episode.episode_title}</h3>
268 </div>
269 <div className="flex flex-wrap items-center gap-2">
270 <span className="inline-flex items-center gap-1 text-[11px] text-violet-600 font-medium bg-violet-50 px-2.5 py-1 rounded-full border border-violet-100 italic">
271 <Clapperboard className="w-3 h-3" /> {segs.length} 个片段
272 </span>
273 <span className="inline-flex items-center gap-1 text-[11px] text-violet-600 font-medium bg-violet-50 px-2.5 py-1 rounded-full border border-violet-100 italic">
274 <Camera className="w-3 h-3" /> {epShotCount} 个分镜
275 </span>
276 <span className="inline-flex items-center gap-1 text-[11px] text-violet-600 font-medium bg-violet-50 px-2.5 py-1 rounded-full border border-violet-100 italic">
277 <Clock className="w-3 h-3" /> 总计 {epTotalTime}s
278 </span>
279 </div>
280 </div>
281
282 {/* 二级:拍摄分段 */}
283 <div className="space-y-8 pl-1">
284 {segs.map((segment, segIdx) => (
285 <div key={segment.segment_id} className="space-y-3">
286 <div className="flex items-center justify-between bg-gray-50/50 rounded-lg px-4 py-2 border border-gray-100">
287 <div className="flex items-center gap-6">
288 <span className="text-xs font-black text-gray-400">#{segment.segment_number}</span>
289 <div className="flex items-center gap-1.5">
290 <MapPin className="w-3.5 h-3.5 text-gray-400" />
291 <span className="text-sm font-bold text-gray-800">
292 {isEditing ? (
293 <input
294 value={segment.location || ''}
295 onChange={e => updateSegmentField(epIdx, segIdx, 'location', e.target.value)}
296 className="bg-transparent border-b border-gray-200 focus:border-violet-400 outline-none px-1"
297 />
298 ) : (segment.location || '未知地点')}
299 </span>
300 </div>
301 <div className="flex items-center gap-1.5">
302 <Users className="w-3.5 h-3.5 text-gray-400" />
303 <div className="flex gap-1.5">
304 {(segment.characters || []).map((c, i) => (
305 <span key={i} className="px-2 py-0.5 bg-white border border-gray-200 text-xs text-gray-600 rounded font-bold">{c}</span>
306 ))}
307 </div>
308 </div>
309 </div>
310 <span className="text-xs font-black text-violet-600 bg-violet-50 px-2.5 py-1 rounded-full">{segment.total_duration}s</span>
311 </div>
312
313 {/* 三级:分镜表格 */}
314 <div className="ml-8 border border-gray-100 rounded-xl overflow-hidden shadow-sm bg-white">
315 <table className="w-full text-left">
316 <thead className="bg-gray-50/50 border-b border-gray-100">
317 <tr className="text-[12px] font-bold text-gray-500 uppercase tracking-wider">
318 <th className="px-4 py-2 w-10 text-center">#</th>
319 <th className="px-4 py-2 w-24 text-center">景别</th>
320 <th className="px-4 py-2 w-20 text-center">时长</th>
321 <th className="px-4 py-2">分镜内容描述</th>
322 {isEditing && <th className="px-4 py-2 w-10"></th>}
323 </tr>
324 </thead>
325 <tbody className="divide-y divide-gray-50">
326 {(segment.shots || []).map((shot, sIdx) => {
327 const decor = SHOT_TYPE_DECOR[shot.shot_type as keyof typeof SHOT_TYPE_DECOR] || SHOT_TYPE_DECOR['近景'];
328 return (
329 <tr key={sIdx} className="group hover:bg-gray-50/30 transition-colors">
330 <td className="px-4 py-3 text-center text-xs font-mono text-gray-400">{shot.shot_number}</td>
331 <td className="px-4 py-3 text-center">
332 {isEditing ? (
333 <select
334 value={shot.shot_type}
335 onChange={e => updateShotField(epIdx, segIdx, sIdx, 'shot_type', e.target.value)}
336 className="w-full bg-white border border-gray-200 rounded text-xs font-bold py-1 px-1 outline-none focus:ring-1 focus:ring-violet-300"
337 >
338 {Object.keys(SHOT_TYPE_DECOR).map(t => <option key={t} value={t}>{t}</option>)}
339 </select>
340 ) : (
341 <span className={`px-2 py-0.5 rounded text-xs font-black ${decor.bg} ${decor.text}`}>{shot.shot_type}</span>
342 )}
343 </td>
344 <td className="px-4 py-3 text-center">
345 {isEditing ? (
346 <input
347 type="number"
348 value={shot.duration}
349 onChange={e => updateShotField(epIdx, segIdx, sIdx, 'duration', Number(e.target.value))}
350 className="w-12 bg-white border border-gray-200 rounded text-xs font-mono py-1 px-1 text-center focus:ring-1 focus:ring-violet-300 outline-none"
351 />
352 ) : (
353 <span className="text-xs font-mono text-gray-500">{shot.duration}s</span>
354 )}
355 </td>
356 <td className="px-4 py-3">
357 {isEditing ? (
358 <textarea
359 value={shot.content}
360 onChange={e => updateShotField(epIdx, segIdx, sIdx, 'content', e.target.value)}
361 rows={1}
362 className="w-full bg-gray-50 border border-transparent rounded px-2 py-1 text-sm text-gray-700 focus:bg-white focus:border-violet-100 outline-none resize-none"
363 />
364 ) : (
365 <p className="text-sm text-gray-700 leading-relaxed font-semibold">{shot.content}</p>
366 )}
367 </td>
368 {isEditing && (
369 <td className="px-2 py-3">
370 <button onClick={() => deleteShot(epIdx, segIdx, sIdx)} className="p-1 text-gray-300 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100">
371 <Trash2 className="w-3.5 h-3.5" />
372 </button>
373 </td>
374 )}
375 </tr>
376 );
377 })}
378 {isEditing && (
379 <tr>
380 <td colSpan={5} className="p-2">
381 <button onClick={() => addShot(epIdx, segIdx)} className="w-full py-1.5 border border-dashed border-gray-100 rounded-lg text-gray-400 text-[10px] font-bold hover:bg-violet-50 hover:text-violet-500 transition-all flex items-center justify-center gap-1">
382 <Plus className="w-3 h-3" /> 插入新分镜点
383 </button>
384 </td>
385 </tr>
386 )}
387 </tbody>
388 </table>
389 </div>
390 </div>
391 ))}
392 </div>
393 </div>
394 );
395 })}
396 </div>
397 )}
398 </div>
399
400 {/* 底部确认操作 (参考第二阶段 StageActions 放在底部) */}
401 <StageActions
402 status={state.status}
403 onConfirm={onConfirm}
404 onEdit={!isEditing ? startEdit : undefined}
405 onSave={isEditing ? handleSave : undefined}
406 onRegenerate={onRegenerate}
407 showConfirm={showConfirm}
408 isRunning={isRunning}
409 hasPendingItems={hasPendingItems}
410 hasNextStageStarted={hasNextStageStarted}
411 stageId="storyboard"
412 />
413
414 <style jsx global>{`
415 .custom-scrollbar::-webkit-scrollbar { width: 5px; }
416 .custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
417 .custom-scrollbar::-webkit-scrollbar-thumb { background: #F1F1F1; border-radius: 10px; }
418 .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #E5E7EB; }
419 `}</style>
420 </div>
421 );
422 }
423
423 lines Plain Text