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