| 1 | 'use client'; |
| 2 | |
| 3 | import React, { useState, useRef, useEffect } from 'react'; |
| 4 | import { Image as ImageIcon, RefreshCw, ChevronLeft, ChevronRight, Loader, AlertCircle, ZoomIn, ImagePlus, Edit2, Save, X, Upload } from 'lucide-react'; |
| 5 | import type { StageViewProps } from './types'; |
| 6 | import { assetUrl, assetVersionLabel } from './utils'; |
| 7 | import { uploadArtifactImage } from '@/lib/workflowApi'; |
| 8 | import StageActions from './StageActions'; |
| 9 | import StageProgress from './StageProgress'; |
| 10 | import ImageLightbox from './ImageLightbox'; |
| 11 | import RewriteResultBadge, { type RewriteResult } from './RewriteResultBadge'; |
| 12 | |
| 13 | /* ─── 类型 ─── */ |
| 14 | interface SceneItem { |
| 15 | id: string; // shot_001_01, shot_001_02, ... |
| 16 | name: string; // 场景1-镜头1 |
| 17 | index?: number; // 全局编号 |
| 18 | description: string; // 视觉提示词 |
| 19 | selected: string; // 当前选中的文件路径 |
| 20 | versions: string[]; // 所有历史版本路径 |
| 21 | status?: 'pending' | 'done' | 'failed' | 'running'; |
| 22 | rewrite_result?: RewriteResult; |
| 23 | } |
| 24 | |
| 25 | /* ─── 水平滚动图片画廊 ─── */ |
| 26 | function ImageGallery({ |
| 27 | versions, |
| 28 | selected, |
| 29 | onSelect, |
| 30 | showPlaceholder, |
| 31 | }: { |
| 32 | versions: string[]; |
| 33 | selected: string; |
| 34 | onSelect: (path: string) => void; |
| 35 | showPlaceholder?: boolean; |
| 36 | }) { |
| 37 | const scrollRef = useRef<HTMLDivElement>(null); |
| 38 | const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); |
| 39 | |
| 40 | const scroll = (dir: 'left' | 'right') => { |
| 41 | if (!scrollRef.current) return; |
| 42 | scrollRef.current.scrollBy({ left: dir === 'left' ? -260 : 260, behavior: 'smooth' }); |
| 43 | }; |
| 44 | |
| 45 | if (!versions.length) { |
| 46 | return ( |
| 47 | <div className="flex items-center justify-center h-full text-gray-400 text-xs"> |
| 48 | 暂无图片 |
| 49 | </div> |
| 50 | ); |
| 51 | } |
| 52 | |
| 53 | return ( |
| 54 | <div className="relative group"> |
| 55 | {versions.length > 1 && ( |
| 56 | <> |
| 57 | <button |
| 58 | onClick={() => scroll('left')} |
| 59 | className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-7 h-7 rounded-full bg-white/90 shadow border border-gray-200 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" |
| 60 | > |
| 61 | <ChevronLeft className="w-4 h-4 text-gray-600" /> |
| 62 | </button> |
| 63 | <button |
| 64 | onClick={() => scroll('right')} |
| 65 | className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-7 h-7 rounded-full bg-white/90 shadow border border-gray-200 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" |
| 66 | > |
| 67 | <ChevronRight className="w-4 h-4 text-gray-600" /> |
| 68 | </button> |
| 69 | </> |
| 70 | )} |
| 71 | <div |
| 72 | ref={scrollRef} |
| 73 | className="flex gap-3 overflow-x-auto scrollbar-hide py-1 px-1" |
| 74 | style={{ scrollbarWidth: 'none' }} |
| 75 | > |
| 76 | {versions.map((path, i) => { |
| 77 | const isSelected = path === selected; |
| 78 | return ( |
| 79 | <div |
| 80 | key={path} |
| 81 | onClick={() => onSelect(path)} |
| 82 | className={`flex-shrink-0 cursor-pointer rounded-lg overflow-hidden transition-all ${ |
| 83 | isSelected |
| 84 | ? 'ring-3 ring-emerald-500 shadow-lg shadow-emerald-200' |
| 85 | : 'ring-1 ring-gray-200 hover:ring-gray-300 hover:shadow-md' |
| 86 | }`} |
| 87 | > |
| 88 | <div className="relative group/img"> |
| 89 | <img |
| 90 | src={assetUrl(path)} |
| 91 | alt={`v${i + 1}`} |
| 92 | className="h-32 w-auto object-cover" |
| 93 | onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} |
| 94 | /> |
| 95 | <button |
| 96 | onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }} |
| 97 | className="absolute top-1 right-1 w-6 h-6 rounded-full bg-black/40 flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity hover:bg-black/60" |
| 98 | title="放大查看" |
| 99 | > |
| 100 | <ZoomIn className="w-3 h-3 text-white" /> |
| 101 | </button> |
| 102 | </div> |
| 103 | <div className={`text-center text-[10px] py-0.5 ${ |
| 104 | isSelected ? 'bg-emerald-500 text-white font-medium' : 'bg-gray-50 text-gray-400' |
| 105 | }`}> |
| 106 | {assetVersionLabel(path, i)} |
| 107 | </div> |
| 108 | </div> |
| 109 | ); |
| 110 | })} |
| 111 | {showPlaceholder && ( |
| 112 | <div className="flex-shrink-0 flex items-center justify-center h-32 aspect-video bg-gray-50 rounded-lg border border-dashed border-gray-200"> |
| 113 | <div className="flex items-center gap-2 text-gray-400 text-xs"> |
| 114 | <Loader className="w-4 h-4 animate-spin" /> |
| 115 | <span>生成中...</span> |
| 116 | </div> |
| 117 | </div> |
| 118 | )} |
| 119 | </div> |
| 120 | {lightboxIndex !== null && ( |
| 121 | <ImageLightbox |
| 122 | images={versions} |
| 123 | initialIndex={lightboxIndex} |
| 124 | onClose={() => setLightboxIndex(null)} |
| 125 | /> |
| 126 | )} |
| 127 | </div> |
| 128 | ); |
| 129 | } |
| 130 | |
| 131 | /* ─── 场景行 ─── */ |
| 132 | function SceneRow({ |
| 133 | scene, |
| 134 | canEdit, |
| 135 | editDesc, |
| 136 | onDescChange, |
| 137 | onRegenerate, |
| 138 | onSelectVersion, |
| 139 | onSavePrompt, |
| 140 | isStageRunning, |
| 141 | isRegenerating, |
| 142 | isEditing, |
| 143 | onToggleEdit, |
| 144 | getSelected, |
| 145 | allowMissingGenerate, |
| 146 | onCancelEdit, |
| 147 | onUploadImage, |
| 148 | isUploading, |
| 149 | }: { |
| 150 | scene: SceneItem; |
| 151 | canEdit: boolean; |
| 152 | editDesc: string; |
| 153 | onDescChange: (val: string) => void; |
| 154 | onRegenerate: () => void; |
| 155 | onSelectVersion: (path: string) => void; |
| 156 | onSavePrompt: () => void; |
| 157 | isStageRunning?: boolean; |
| 158 | isRegenerating?: boolean; |
| 159 | isEditing?: boolean; |
| 160 | onToggleEdit?: () => void; |
| 161 | getSelected: (scene: SceneItem) => string; |
| 162 | allowMissingGenerate?: boolean; |
| 163 | onCancelEdit?: () => void; |
| 164 | onUploadImage: (file: File) => void; |
| 165 | isUploading?: boolean; |
| 166 | }) { |
| 167 | const isRunning = scene.status === 'running' || isRegenerating; |
| 168 | const isPending = scene.status === 'pending'; |
| 169 | const isFailed = scene.status === 'failed' && !isRegenerating; |
| 170 | const hasImage = Boolean(getSelected(scene)) || scene.versions.length > 0; |
| 171 | const canGenerateMissing = Boolean(allowMissingGenerate) && !hasImage && !isRunning && !isRegenerating; |
| 172 | const canShowRegenerate = !isRunning && !isPending && (hasImage || isFailed || canGenerateMissing); |
| 173 | const hasChanges = editDesc !== scene.description; |
| 174 | |
| 175 | return ( |
| 176 | <div className={`flex flex-col xl:flex-row border rounded-xl overflow-hidden bg-white ${ |
| 177 | isFailed ? 'border-red-200' : 'border-gray-200' |
| 178 | }`}> |
| 179 | {/* 左侧: 提示词 */} |
| 180 | <div className="w-full xl:w-80 xl:flex-shrink-0 p-4 border-b xl:border-b-0 xl:border-r border-gray-100 flex flex-col"> |
| 181 | <div className="flex items-center gap-2 mb-2"> |
| 182 | <RewriteResultBadge rewriteResult={scene.rewrite_result} /> |
| 183 | <span className="flex items-center justify-center h-6 px-1.5 rounded-full bg-emerald-100 text-emerald-700 text-[10px] font-bold flex-shrink-0 whitespace-nowrap"> |
| 184 | {scene.index ?? scene.id.replace('Scene_', '')} |
| 185 | </span> |
| 186 | <span className="text-sm font-semibold text-gray-800 truncate">{scene.name || (scene as any).title || `片段 ${scene.index}`}</span> |
| 187 | {isPending && ( |
| 188 | <span className="text-[10px] bg-gray-50 text-gray-500 px-1.5 py-0.5 rounded">等待中</span> |
| 189 | )} |
| 190 | {isRunning && ( |
| 191 | <span className="inline-flex items-center gap-1 text-[10px] bg-amber-50 text-amber-600 px-1.5 py-0.5 rounded"> |
| 192 | <Loader className="w-2.5 h-2.5 animate-spin" />生成中 |
| 193 | </span> |
| 194 | )} |
| 195 | {isFailed && ( |
| 196 | <span className="text-[10px] bg-red-50 text-red-500 px-1.5 py-0.5 rounded">失败</span> |
| 197 | )} |
| 198 | {/* 编辑/保存按钮 */} |
| 199 | {canEdit && !isStageRunning && ( |
| 200 | isEditing ? ( |
| 201 | <div className="ml-auto flex gap-1"> |
| 202 | <button |
| 203 | onClick={onCancelEdit} |
| 204 | className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100" |
| 205 | > |
| 206 | <X className="w-3 h-3" />取消 |
| 207 | </button> |
| 208 | <button |
| 209 | onClick={onSavePrompt} |
| 210 | disabled={!hasChanges} |
| 211 | className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors ${ |
| 212 | hasChanges |
| 213 | ? 'text-white bg-emerald-500 hover:bg-emerald-600' |
| 214 | : 'text-gray-400 bg-gray-100 cursor-not-allowed' |
| 215 | }`} |
| 216 | > |
| 217 | <Save className="w-3 h-3" /> |
| 218 | 保存 |
| 219 | </button> |
| 220 | </div> |
| 221 | ) : ( |
| 222 | <button |
| 223 | onClick={onToggleEdit} |
| 224 | className="ml-auto flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors" |
| 225 | > |
| 226 | <Edit2 className="w-3 h-3" /> |
| 227 | 编辑 |
| 228 | </button> |
| 229 | ) |
| 230 | )} |
| 231 | </div> |
| 232 | {isEditing ? ( |
| 233 | <textarea |
| 234 | value={editDesc} |
| 235 | onChange={e => onDescChange(e.target.value)} |
| 236 | rows={5} |
| 237 | className="h-[120px] text-xs text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-2 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-300" |
| 238 | /> |
| 239 | ) : ( |
| 240 | <div className="h-[120px] overflow-y-auto pr-1 custom-scrollbar"> |
| 241 | <p className="text-xs text-gray-600 leading-relaxed whitespace-pre-wrap">{scene.description}</p> |
| 242 | </div> |
| 243 | )} |
| 244 | <div className="mt-3 flex flex-wrap items-center gap-2"> |
| 245 | {canShowRegenerate && ( |
| 246 | <button |
| 247 | onClick={onRegenerate} |
| 248 | className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ |
| 249 | isFailed |
| 250 | ? 'text-red-600 bg-red-50 hover:bg-red-100' |
| 251 | : 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100' |
| 252 | }`} |
| 253 | > |
| 254 | <RefreshCw className="w-3 h-3" /> |
| 255 | {isFailed ? '点击重试' : hasImage ? '重新生成' : '生成'} |
| 256 | </button> |
| 257 | )} |
| 258 | <label className={`ml-auto inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ |
| 259 | isUploading |
| 260 | ? 'text-gray-400 bg-gray-100 cursor-wait' |
| 261 | : 'text-gray-600 bg-gray-100 hover:bg-gray-200 cursor-pointer' |
| 262 | }`}> |
| 263 | {isUploading ? <Loader className="w-3 h-3 animate-spin" /> : <Upload className="w-3 h-3" />} |
| 264 | {isUploading ? '上传中...' : '上传照片'} |
| 265 | <input |
| 266 | type="file" |
| 267 | accept="image/*" |
| 268 | className="hidden" |
| 269 | disabled={isUploading} |
| 270 | onChange={e => { |
| 271 | const file = e.target.files?.[0]; |
| 272 | e.currentTarget.value = ''; |
| 273 | if (file) onUploadImage(file); |
| 274 | }} |
| 275 | /> |
| 276 | </label> |
| 277 | </div> |
| 278 | </div> |
| 279 | |
| 280 | {/* 右侧: 图片画廊 / 占位 */} |
| 281 | <div className="flex-1 min-w-0 p-3 flex items-center"> |
| 282 | {isRunning && !hasImage ? ( |
| 283 | <div className="flex items-center justify-center h-32 aspect-video bg-gray-50 rounded-lg border border-dashed border-gray-200"> |
| 284 | <div className="flex items-center gap-2 text-gray-400 text-xs"> |
| 285 | <Loader className="w-4 h-4 animate-spin" /> |
| 286 | <span>正在生成...</span> |
| 287 | </div> |
| 288 | </div> |
| 289 | ) : !hasImage ? ( |
| 290 | <div className="flex items-center justify-center h-32 aspect-video bg-gray-50/60 rounded-lg border border-dashed border-gray-200"> |
| 291 | <div className="flex flex-col items-center gap-1 text-gray-400 text-xs"> |
| 292 | {isFailed ? ( |
| 293 | <> |
| 294 | <AlertCircle className="w-4 h-4" /> |
| 295 | <span>生成失败</span> |
| 296 | {!isRunning && ( |
| 297 | <button |
| 298 | onClick={onRegenerate} |
| 299 | disabled={isRegenerating} |
| 300 | className="mt-1 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-red-600 bg-red-50 hover:bg-red-100 transition-colors disabled:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" |
| 301 | > |
| 302 | <RefreshCw className="w-2.5 h-2.5" /> |
| 303 | 点击重试 |
| 304 | </button> |
| 305 | )} |
| 306 | </> |
| 307 | ) : ( |
| 308 | <> |
| 309 | <ImagePlus className="w-4 h-4" /> |
| 310 | <span>{isPending ? '等待生成...' : '暂无图片'}</span> |
| 311 | {canGenerateMissing && ( |
| 312 | <button |
| 313 | onClick={onRegenerate} |
| 314 | disabled={isRegenerating} |
| 315 | className="mt-1 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-emerald-600 bg-emerald-50 hover:bg-emerald-100 transition-colors disabled:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" |
| 316 | > |
| 317 | <RefreshCw className="w-2.5 h-2.5" /> |
| 318 | 生成 |
| 319 | </button> |
| 320 | )} |
| 321 | </> |
| 322 | )} |
| 323 | </div> |
| 324 | </div> |
| 325 | ) : ( |
| 326 | <div className="relative w-full"> |
| 327 | <ImageGallery |
| 328 | versions={scene.versions} |
| 329 | selected={getSelected(scene)} |
| 330 | onSelect={onSelectVersion} |
| 331 | showPlaceholder={isRunning} |
| 332 | /> |
| 333 | {isFailed && ( |
| 334 | <button |
| 335 | onClick={onRegenerate} |
| 336 | className="absolute top-1 right-1 z-10 flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-white bg-red-500/80 hover:bg-red-600 shadow transition-colors" |
| 337 | > |
| 338 | <RefreshCw className="w-2.5 h-2.5" /> |
| 339 | 重试 |
| 340 | </button> |
| 341 | )} |
| 342 | </div> |
| 343 | )} |
| 344 | </div> |
| 345 | </div> |
| 346 | ); |
| 347 | } |
| 348 | |
| 349 | /* ─── 主组件 ─── */ |
| 350 | export default function ReferenceStage({ state, sessionId, onConfirm, onIntervene, onRegenerate, onUpdateArtifact, onSaveSelections, showConfirm, isRunning, hasPendingItems, hasNextStageStarted, scriptArtifact }: StageViewProps) { |
| 351 | const [editDescs, setEditDescs] = useState<Record<string, string>>({}); |
| 352 | const [selectedVersions, setSelectedVersions] = useState<Record<string, string>>({}); |
| 353 | const [regeneratingIds, setRegeneratingIds] = useState<Set<string>>(new Set()); |
| 354 | const regenerationStartCounts = useRef<Record<string, number>>({}); |
| 355 | const [editingIds, setEditingIds] = useState<Set<string>>(new Set()); |
| 356 | const [savingIds, setSavingIds] = useState<Set<string>>(new Set()); |
| 357 | const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set()); |
| 358 | |
| 359 | // 提取剧集标题映射 |
| 360 | const episodeTitleMap = React.useMemo(() => { |
| 361 | const map: Record<number, string> = {}; |
| 362 | if (scriptArtifact?.episodes) { |
| 363 | scriptArtifact.episodes.forEach((ep: any) => { |
| 364 | // 关键修复:这里的字段名应该是 episode_number 和 act_title |
| 365 | const epNum = ep.episode_number || ep.episode; |
| 366 | const epTitle = ep.act_title || ep.title; |
| 367 | if (epNum) { |
| 368 | map[Number(epNum)] = epTitle || ''; |
| 369 | } |
| 370 | }); |
| 371 | } |
| 372 | return map; |
| 373 | }, [scriptArtifact]); |
| 374 | |
| 375 | const getSelected = (scene: SceneItem) => selectedVersions[scene.id] || scene.selected; |
| 376 | |
| 377 | // 兼容旧格式: scene_images: {Scene_1: "path"} → scenes: [{id, ...}] |
| 378 | const scenes: SceneItem[] = (() => { |
| 379 | if (state.artifact?.scenes?.length) return state.artifact.scenes; |
| 380 | if (state.artifact?.scene_images) { |
| 381 | const si = state.artifact.scene_images as Record<string, string>; |
| 382 | return Object.entries(si) |
| 383 | .sort(([a], [b]) => { |
| 384 | const na = parseInt(a.replace(/\D/g, '')) || 0; |
| 385 | const nb = parseInt(b.replace(/\D/g, '')) || 0; |
| 386 | return na - nb; |
| 387 | }) |
| 388 | .map(([id, path]) => ({ |
| 389 | id, |
| 390 | name: `场景 ${id.replace('Scene_', '')}`, |
| 391 | description: '', |
| 392 | selected: path, |
| 393 | versions: [path], |
| 394 | status: 'done' as const, |
| 395 | })); |
| 396 | } |
| 397 | return []; |
| 398 | })(); |
| 399 | |
| 400 | const canEdit = state.status === 'waiting' || state.status === 'completed'; |
| 401 | |
| 402 | // 当场景数据变化时,初始化编辑描述 |
| 403 | useEffect(() => { |
| 404 | if (scenes.length > 0) { |
| 405 | setEditDescs(prev => { |
| 406 | const next: Record<string, string> = {}; |
| 407 | scenes.forEach(s => { next[s.id] = prev[s.id] ?? s.description; }); |
| 408 | return next; |
| 409 | }); |
| 410 | } |
| 411 | }, [scenes]); |
| 412 | |
| 413 | // 当对应片段新增版本或失败时,仅清除该片段的重新生成状态,支持多个任务并行。 |
| 414 | useEffect(() => { |
| 415 | if (regeneratingIds.size === 0) return; |
| 416 | setRegeneratingIds(prev => { |
| 417 | let changed = false; |
| 418 | const next = new Set(prev); |
| 419 | for (const id of prev) { |
| 420 | const scene = scenes.find(item => item.id === id); |
| 421 | if (!scene) continue; |
| 422 | const startCount = regenerationStartCounts.current[id] ?? 0; |
| 423 | if ((scene.versions?.length || 0) > startCount || scene.status === 'failed') { |
| 424 | next.delete(id); |
| 425 | delete regenerationStartCounts.current[id]; |
| 426 | changed = true; |
| 427 | } |
| 428 | } |
| 429 | return changed ? next : prev; |
| 430 | }); |
| 431 | }, [scenes, regeneratingIds.size]); |
| 432 | |
| 433 | const hasScenes = scenes.length > 0; |
| 434 | |
| 435 | // 保存单个提示词到后端 JSON |
| 436 | const handleSavePrompt = async (sceneId: string) => { |
| 437 | const newPrompt = editDescs[sceneId]; |
| 438 | if (!newPrompt) return; |
| 439 | |
| 440 | setSavingIds(prev => new Set(prev).add(sceneId)); |
| 441 | try { |
| 442 | // 调用后端 API 保存提示词 |
| 443 | const response = await fetch(`/api/project/${sessionId}/artifact/reference_generation`, { |
| 444 | method: 'PATCH', |
| 445 | headers: { 'Content-Type': 'application/json' }, |
| 446 | body: JSON.stringify({ |
| 447 | segments: scenes.map(s => ({ |
| 448 | segment_id: s.id, |
| 449 | visual_prompt: s.id === sceneId ? newPrompt : s.description |
| 450 | })) |
| 451 | }) |
| 452 | }); |
| 453 | if (response.ok) { |
| 454 | // 保存成功后关闭编辑模式 |
| 455 | setEditingIds(prev => { |
| 456 | const next = new Set(prev); |
| 457 | next.delete(sceneId); |
| 458 | return next; |
| 459 | }); |
| 460 | // 更新本地状态,使用保存后的值 |
| 461 | setEditDescs(prev => ({ ...prev, [sceneId]: newPrompt })); |
| 462 | // 同步更新 artifact 以便后续阶段能获取最新的提示词 |
| 463 | if (onUpdateArtifact && state.artifact?.scenes) { |
| 464 | const updatedScenes = state.artifact.scenes.map((s: SceneItem) => |
| 465 | s.id === sceneId ? { ...s, description: newPrompt } : s |
| 466 | ); |
| 467 | onUpdateArtifact({ scenes: updatedScenes }); |
| 468 | } |
| 469 | } |
| 470 | } catch (error) { |
| 471 | console.error('保存提示词失败:', error); |
| 472 | } finally { |
| 473 | setSavingIds(prev => { |
| 474 | const next = new Set(prev); |
| 475 | next.delete(sceneId); |
| 476 | return next; |
| 477 | }); |
| 478 | } |
| 479 | }; |
| 480 | |
| 481 | // 切换编辑模式 |
| 482 | const handleToggleEdit = (sceneId: string) => { |
| 483 | setEditingIds(prev => { |
| 484 | const next = new Set(prev); |
| 485 | if (next.has(sceneId)) { |
| 486 | next.delete(sceneId); |
| 487 | } else { |
| 488 | next.add(sceneId); |
| 489 | } |
| 490 | return next; |
| 491 | }); |
| 492 | }; |
| 493 | |
| 494 | const handleCancelEdit = (sceneId: string) => { |
| 495 | const scene = scenes.find(item => item.id === sceneId); |
| 496 | setEditDescs(prev => ({ ...prev, [sceneId]: scene?.description || '' })); |
| 497 | setEditingIds(prev => { |
| 498 | const next = new Set(prev); |
| 499 | next.delete(sceneId); |
| 500 | return next; |
| 501 | }); |
| 502 | }; |
| 503 | |
| 504 | const handleUploadImage = async (sceneId: string, file: File) => { |
| 505 | setUploadingIds(prev => new Set(prev).add(sceneId)); |
| 506 | try { |
| 507 | const result = await uploadArtifactImage(sessionId, 'reference_generation', 'scenes', sceneId, file); |
| 508 | if (result.artifact?.scenes) { |
| 509 | onUpdateArtifact?.({ scenes: result.artifact.scenes }); |
| 510 | } |
| 511 | setSelectedVersions(prev => ({ ...prev, [sceneId]: result.path })); |
| 512 | } catch (error) { |
| 513 | console.error('上传图片失败:', error); |
| 514 | } finally { |
| 515 | setUploadingIds(prev => { |
| 516 | const next = new Set(prev); |
| 517 | next.delete(sceneId); |
| 518 | return next; |
| 519 | }); |
| 520 | } |
| 521 | }; |
| 522 | |
| 523 | const handleRegenerate = (sceneId: string) => { |
| 524 | const scene = scenes.find(item => item.id === sceneId); |
| 525 | regenerationStartCounts.current[sceneId] = scene?.versions?.length || 0; |
| 526 | setRegeneratingIds(prev => { |
| 527 | const next = new Set(prev); |
| 528 | next.add(sceneId); |
| 529 | return next; |
| 530 | }); |
| 531 | onIntervene({ regenerate_scenes: [sceneId] }); |
| 532 | }; |
| 533 | |
| 534 | const handleSelectVersion = async (sceneId: string, path: string) => { |
| 535 | setSelectedVersions(prev => ({ ...prev, [sceneId]: path })); |
| 536 | // 同步更新 artifact 以便确认时能传递正确的选中图片给阶段5 |
| 537 | if (onUpdateArtifact && state.artifact?.scenes) { |
| 538 | const updatedScenes = state.artifact.scenes.map((s: SceneItem) => |
| 539 | s.id === sceneId ? { ...s, selected: path } : s |
| 540 | ); |
| 541 | onUpdateArtifact({ scenes: updatedScenes }); |
| 542 | } |
| 543 | // 自动保存选择 |
| 544 | const selections: Record<string, string> = {}; |
| 545 | scenes.forEach(s => { selections[s.id] = selectedVersions[s.id] || s.selected; }); |
| 546 | selections[sceneId] = path; |
| 547 | if (onSaveSelections) { |
| 548 | await onSaveSelections(selections); |
| 549 | } |
| 550 | }; |
| 551 | |
| 552 | return ( |
| 553 | <div className="flex flex-col h-full"> |
| 554 | <div className="flex-1 min-w-0 overflow-y-auto p-4 sm:p-6"> |
| 555 | {/* 标题栏 */} |
| 556 | <div className="flex items-center justify-between mb-1"> |
| 557 | <h2 className="text-lg font-semibold text-gray-800">参考图生成</h2> |
| 558 | </div> |
| 559 | <p className="text-sm text-gray-500 mb-6"> |
| 560 | 基于角色/场景素材 + 分镜视觉描述,使用图生图生成场景参考图 |
| 561 | </p> |
| 562 | |
| 563 | {/* 运行中 */} |
| 564 | {state.status === 'running' && ( |
| 565 | <StageProgress message={state.progressMessage} fallback="正在生成参考图..." progress={state.progress} color="emerald" /> |
| 566 | )} |
| 567 | |
| 568 | {state.error && ( |
| 569 | <div className="text-sm text-red-600 bg-red-50 border border-red-200 p-4 rounded-xl mb-4">{state.error}</div> |
| 570 | )} |
| 571 | |
| 572 | {/* ═══ 场景列表 ═══ */} |
| 573 | {hasScenes && ( |
| 574 | <div className="space-y-10"> |
| 575 | {(() => { |
| 576 | // 按剧集分组 |
| 577 | const episodes: Record<number, SceneItem[]> = {}; |
| 578 | scenes.forEach(s => { |
| 579 | const ep = (s as any).episode || 1; |
| 580 | if (!episodes[ep]) episodes[ep] = []; |
| 581 | episodes[ep].push(s); |
| 582 | }); |
| 583 | |
| 584 | return Object.keys(episodes).sort((a, b) => Number(a) - Number(b)).map(epNum => { |
| 585 | const epScenes = episodes[Number(epNum)]; |
| 586 | const fallbackTitle = (epScenes[0] as any).episode_title || `第 ${epNum} 集`; |
| 587 | const scriptTitle = episodeTitleMap[Number(epNum)]; |
| 588 | const episodeTitle = scriptTitle ? `第 ${epNum} 集:${scriptTitle}` : fallbackTitle; |
| 589 | |
| 590 | return ( |
| 591 | <div key={epNum} className="space-y-4"> |
| 592 | <div className="flex items-center justify-between py-2 px-1 border-b border-gray-100"> |
| 593 | <div className="flex items-center gap-3"> |
| 594 | <div className="w-1.5 h-6 bg-emerald-500 rounded-full" /> |
| 595 | <h3 className="text-base font-bold text-gray-800">{episodeTitle}</h3> |
| 596 | </div> |
| 597 | <span className="text-[11px] text-emerald-600 font-medium bg-emerald-50 px-2.5 py-1 rounded-full border border-emerald-100 italic"> |
| 598 | {epScenes.length} 个片段 |
| 599 | </span> |
| 600 | </div> |
| 601 | |
| 602 | <div className="space-y-4"> |
| 603 | {epScenes.map(scene => ( |
| 604 | <div key={scene.id} className="relative"> |
| 605 | <SceneRow |
| 606 | scene={scene} |
| 607 | editDesc={editDescs[scene.id] ?? scene.description} |
| 608 | onDescChange={(val) => setEditDescs(prev => ({ ...prev, [scene.id]: val }))} |
| 609 | onSavePrompt={() => handleSavePrompt(scene.id)} |
| 610 | onRegenerate={() => handleRegenerate(scene.id)} |
| 611 | onSelectVersion={(path) => handleSelectVersion(scene.id, path)} |
| 612 | isStageRunning={state.status === 'running'} |
| 613 | isRegenerating={regeneratingIds.has(scene.id)} |
| 614 | isEditing={editingIds.has(scene.id)} |
| 615 | onToggleEdit={() => handleToggleEdit(scene.id)} |
| 616 | onCancelEdit={() => handleCancelEdit(scene.id)} |
| 617 | canEdit={canEdit} |
| 618 | getSelected={getSelected} |
| 619 | allowMissingGenerate={state.status !== 'pending'} |
| 620 | onUploadImage={file => handleUploadImage(scene.id, file)} |
| 621 | isUploading={uploadingIds.has(scene.id)} |
| 622 | /> |
| 623 | </div> |
| 624 | ))} |
| 625 | </div> |
| 626 | </div> |
| 627 | ); |
| 628 | }); |
| 629 | })()} |
| 630 | </div> |
| 631 | )} |
| 632 | |
| 633 | {/* 如果有 artifact 数据(即使 status 是 pending),也显示内容 */} |
| 634 | {!hasScenes && state.status === 'pending' && ( |
| 635 | <div className="text-center text-gray-400 text-sm py-20">等待上一阶段完成...</div> |
| 636 | )} |
| 637 | </div> |
| 638 | |
| 639 | {/* 底部操作栏 */} |
| 640 | <StageActions |
| 641 | status={state.status} |
| 642 | onConfirm={onConfirm} |
| 643 | showConfirm={showConfirm} |
| 644 | onRegenerate={onRegenerate} |
| 645 | stageId="reference_generation" |
| 646 | hasPendingItems={hasPendingItems} |
| 647 | hasNextStageStarted={hasNextStageStarted} |
| 648 | isRunning={isRunning} |
| 649 | /> |
| 650 | </div> |
| 651 | ); |
| 652 | } |
| 653 |