| 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 |