返回 VideoClaw
CharacterStage.tsx
根目录 / video-claw / video-claw / frontend / components / stages / CharacterStage.tsx
1 'use client';
2
3 import React, { useState, useRef, useCallback, useEffect } from 'react';
4 import { Users, MapPin, RefreshCw, Save, X, ChevronLeft, ChevronRight, Loader, AlertCircle, ZoomIn, ImagePlus, Edit2, 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 AssetVersion {
15 id: string; // 唯一标识 (character_id / setting_id)
16 name: string;
17 description: string;
18 selected: string; // 当前选中的文件路径
19 versions: string[]; // 所有历史版本路径
20 status?: 'pending' | 'done' | 'failed' | 'running'; // 生成状态
21 rewrite_result?: RewriteResult;
22 }
23
24 /* ─── 水平滚动图片画廊 ─── */
25 function ImageGallery({
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 const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
38
39 const scroll = (dir: 'left' | 'right') => {
40 if (!scrollRef.current) return;
41 const amount = 260;
42 scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, 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-violet-500 shadow-lg shadow-violet-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-violet-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 AssetRow({
133 asset,
134 type,
135 isEditing,
136 editDesc,
137 onDescChange,
138 onRegenerate,
139 onSelectVersion,
140 onSaveEdit,
141 onCancelEdit,
142 onToggleEdit,
143 onUploadImage,
144 isStageRunning,
145 isRegenerating,
146 isUploading,
147 }: {
148 asset: AssetVersion;
149 type: 'character' | 'setting';
150 isEditing: boolean;
151 editDesc: string;
152 onDescChange: (val: string) => void;
153 onRegenerate: () => void;
154 onSelectVersion: (path: string) => void;
155 onSaveEdit: () => void;
156 onCancelEdit: () => void;
157 onToggleEdit: () => void;
158 onUploadImage: (file: File) => void;
159 isStageRunning?: boolean;
160 isRegenerating?: boolean;
161 isUploading?: boolean;
162 }) {
163 const isRunning = asset.status === 'running' || isRegenerating;
164 const isPending = asset.status === 'pending';
165 const isFailed = asset.status === 'failed' && !isRegenerating;
166 const hasImage = Boolean(asset.selected) || asset.versions.length > 0;
167 const canGenerateMissing = !hasImage && !isPending && !isRunning;
168 const canShowRegenerate = !isRunning && !isPending && (hasImage || isFailed || canGenerateMissing);
169
170 return (
171 <div className={`flex flex-col xl:flex-row border rounded-xl overflow-hidden bg-white ${
172 isFailed ? 'border-red-200' : 'border-gray-200'
173 }`}>
174 {/* 左侧: 描述 */}
175 <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">
176 <div className="flex items-center gap-2 mb-2">
177 <RewriteResultBadge rewriteResult={asset.rewrite_result} />
178 {type === 'character'
179 ? <Users className="w-3.5 h-3.5 text-violet-500" />
180 : <MapPin className="w-3.5 h-3.5 text-emerald-500" />
181 }
182 <span className="text-sm font-semibold text-gray-800 truncate">{asset.name}</span>
183 {(isPending || isRunning) && (
184 <span className="inline-flex items-center gap-1 text-[10px] bg-amber-50 text-amber-600 px-1.5 py-0.5 rounded">
185 {isRunning && <Loader className="w-2.5 h-2.5 animate-spin" />}
186 {isRunning ? '生成中' : '等待中'}
187 </span>
188 )}
189 {isFailed && (
190 <span className="text-[10px] bg-red-50 text-red-500 px-1.5 py-0.5 rounded">失败</span>
191 )}
192 {!isStageRunning && (
193 isEditing ? (
194 <div className="ml-auto flex gap-1">
195 <button
196 onClick={onCancelEdit}
197 className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100"
198 >
199 <X className="w-3 h-3" />取消
200 </button>
201 <button
202 onClick={onSaveEdit}
203 className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-white bg-violet-500 hover:bg-violet-600"
204 >
205 <Save className="w-3 h-3" />保存
206 </button>
207 </div>
208 ) : (
209 <button
210 onClick={onToggleEdit}
211 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"
212 >
213 <Edit2 className="w-3 h-3" />修改
214 </button>
215 )
216 )}
217 </div>
218 {isEditing ? (
219 <textarea
220 value={editDesc}
221 onChange={e => onDescChange(e.target.value)}
222 rows={5}
223 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-violet-300"
224 />
225 ) : (
226 <div className="h-[120px] overflow-y-auto pr-1 custom-scrollbar">
227 <p className="text-xs text-gray-600 leading-relaxed">{asset.description}</p>
228 </div>
229 )}
230 <div className="mt-3 flex flex-wrap items-center gap-2">
231 {canShowRegenerate && (
232 <button
233 onClick={onRegenerate}
234 className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
235 isFailed
236 ? 'text-red-600 bg-red-50 hover:bg-red-100'
237 : 'text-violet-600 bg-violet-50 hover:bg-violet-100'
238 }`}
239 >
240 <RefreshCw className="w-3 h-3" />
241 {isFailed ? '点击重试' : hasImage ? '重新生成' : '生成'}
242 </button>
243 )}
244 <label className={`ml-auto inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
245 isUploading
246 ? 'text-gray-400 bg-gray-100 cursor-wait'
247 : 'text-gray-600 bg-gray-100 hover:bg-gray-200 cursor-pointer'
248 }`}>
249 {isUploading ? <Loader className="w-3 h-3 animate-spin" /> : <Upload className="w-3 h-3" />}
250 {isUploading ? '上传中...' : '上传照片'}
251 <input
252 type="file"
253 accept="image/*"
254 className="hidden"
255 disabled={isUploading}
256 onChange={e => {
257 const file = e.target.files?.[0];
258 e.currentTarget.value = '';
259 if (file) onUploadImage(file);
260 }}
261 />
262 </label>
263 </div>
264 </div>
265
266 {/* 右侧: 图片画廊 / 占位 */}
267 <div className="flex-1 min-w-0 p-3 flex items-center">
268 {isPending && !hasImage ? (
269 <div className="flex items-center justify-center h-32 aspect-video bg-gray-50 rounded-lg border border-dashed border-gray-200">
270 <div className="flex items-center gap-2 text-gray-400 text-xs">
271 <Loader className="w-4 h-4 animate-spin" />
272 <span>正在生成...</span>
273 </div>
274 </div>
275 ) : !hasImage ? (
276 <div className="flex items-center justify-center h-32 aspect-video bg-gray-50/60 rounded-lg border border-dashed border-gray-200">
277 <div className="flex flex-col items-center gap-1 text-gray-400 text-xs">
278 {isFailed ? (
279 <>
280 <AlertCircle className="w-4 h-4" />
281 <span>生成失败</span>
282 {!isRunning && (
283 <button
284 onClick={onRegenerate}
285 disabled={isRegenerating}
286 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"
287 >
288 <RefreshCw className="w-2.5 h-2.5" />
289 点击重试
290 </button>
291 )}
292 </>
293 ) : (
294 <>
295 <ImagePlus className="w-4 h-4" />
296 <span>暂无图片</span>
297 {canGenerateMissing && (
298 <button
299 onClick={onRegenerate}
300 disabled={isRegenerating}
301 className="mt-1 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-violet-600 bg-violet-50 hover:bg-violet-100 transition-colors disabled:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed"
302 >
303 <RefreshCw className="w-2.5 h-2.5" />
304 生成
305 </button>
306 )}
307 </>
308 )}
309 </div>
310 </div>
311 ) : (
312 <div className="relative w-full">
313 <ImageGallery
314 versions={asset.versions}
315 selected={asset.selected}
316 onSelect={onSelectVersion}
317 showPlaceholder={isRunning}
318 />
319 {isFailed && (
320 <button
321 onClick={onRegenerate}
322 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"
323 >
324 <RefreshCw className="w-2.5 h-2.5" />
325 重试
326 </button>
327 )}
328 </div>
329 )}
330 </div>
331 </div>
332 );
333 }
334
335 /* ─── 主组件 ─── */
336 export default function CharacterStage({ state, sessionId, onConfirm, onIntervene, onRegenerate, onUpdateArtifact, onSaveSelections, showConfirm, isRunning, hasPendingItems, hasNextStageStarted }: StageViewProps) {
337 const characters: AssetVersion[] = state.artifact?.characters || [];
338 const settingsData: AssetVersion[] = state.artifact?.settings || [];
339
340 const [editChars, setEditChars] = useState<Record<string, string>>({});
341 const [editSets, setEditSets] = useState<Record<string, string>>({});
342 // 跟踪前端选择的版本(覆盖后端返回的 selected)
343 const [selectedChars, setSelectedChars] = useState<Record<string, string>>({});
344 const [selectedSets, setSelectedSets] = useState<Record<string, string>>({});
345 const [regeneratingIds, setRegeneratingIds] = useState<Set<string>>(new Set());
346 const regenerationStartCounts = useRef<Record<string, number>>({});
347 const [editingIds, setEditingIds] = useState<Set<string>>(new Set());
348 const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
349
350 const allAssets = React.useMemo(() => [...characters, ...settingsData], [characters, settingsData]);
351
352 // 当对应素材新增版本或失败时,仅清除该素材的重新生成状态,支持多个任务并行。
353 useEffect(() => {
354 if (regeneratingIds.size === 0) return;
355 setRegeneratingIds(prev => {
356 let changed = false;
357 const next = new Set(prev);
358 for (const id of prev) {
359 const asset = allAssets.find(item => item.id === id);
360 if (!asset) continue;
361 const startCount = regenerationStartCounts.current[id] ?? 0;
362 if ((asset.versions?.length || 0) > startCount || asset.status === 'failed') {
363 next.delete(id);
364 delete regenerationStartCounts.current[id];
365 changed = true;
366 }
367 }
368 return changed ? next : prev;
369 });
370 }, [allAssets, regeneratingIds.size]);
371
372 const hasChars = characters.length > 0;
373 const hasSets = settingsData.length > 0;
374
375 const startEdit = useCallback((id: string) => {
376 const cd: Record<string, string> = {};
377 characters.forEach(c => { cd[c.id] = c.description; });
378 setEditChars(cd);
379 const sd: Record<string, string> = {};
380 settingsData.forEach(s => { sd[s.id] = s.description; });
381 setEditSets(sd);
382 setEditingIds(prev => new Set(prev).add(id));
383 }, [characters, settingsData]);
384
385 const cancelEdit = (id: string) => {
386 setEditingIds(prev => {
387 const next = new Set(prev);
388 next.delete(id);
389 return next;
390 });
391 };
392
393 const saveEdit = (id: string) => {
394 const selections: Record<string, any> = {};
395 characters.forEach(c => { selections[c.id] = selectedChars[c.id] || c.selected; });
396 settingsData.forEach(s => { selections[s.id] = selectedSets[s.id] || s.selected; });
397 selections._editDescs = {
398 characters: id in editChars ? { [id]: editChars[id] } : {},
399 settings: id in editSets ? { [id]: editSets[id] } : {},
400 };
401 onSaveSelections?.(selections);
402 cancelEdit(id);
403 };
404
405 const handleUploadImage = async (type: 'characters' | 'settings', id: string, file: File) => {
406 setUploadingIds(prev => new Set(prev).add(id));
407 try {
408 const result = await uploadArtifactImage(sessionId, 'character_design', type, id, file);
409 const artifact = result.artifact;
410 if (artifact) {
411 onUpdateArtifact?.({
412 characters: artifact.characters || [],
413 settings: artifact.settings || [],
414 });
415 }
416 if (artifact?.characters) {
417 const char = artifact.characters.find((item: AssetVersion) => item.id === id);
418 if (char?.selected) setSelectedChars(prev => ({ ...prev, [id]: char.selected }));
419 }
420 if (artifact?.settings) {
421 const setting = artifact.settings.find((item: AssetVersion) => item.id === id);
422 if (setting?.selected) setSelectedSets(prev => ({ ...prev, [id]: setting.selected }));
423 }
424 } catch (error) {
425 console.error('上传图片失败:', error);
426 } finally {
427 setUploadingIds(prev => {
428 const next = new Set(prev);
429 next.delete(id);
430 return next;
431 });
432 }
433 };
434
435 const handleRegenerate = (type: 'characters' | 'settings', id: string) => {
436 const source = type === 'characters' ? characters : settingsData;
437 const asset = source.find(item => item.id === id);
438 regenerationStartCounts.current[id] = asset?.versions?.length || 0;
439 setRegeneratingIds(prev => new Set(prev).add(id));
440 if (type === 'characters') {
441 onIntervene({ regenerate_characters: [id] });
442 } else {
443 onIntervene({ regenerate_settings: [id] });
444 }
445 };
446
447 const handleSelectCharVersion = async (id: string, path: string) => {
448 setSelectedChars(prev => ({ ...prev, [id]: path }));
449 // 自动保存选择
450 const selections: Record<string, string> = {};
451 characters.forEach(c => { selections[c.id] = selectedChars[c.id] || c.selected; });
452 selections[id] = path;
453 settingsData.forEach(s => { selections[s.id] = selectedSets[s.id] || s.selected; });
454 if (onSaveSelections) {
455 await onSaveSelections(selections);
456 }
457 };
458
459 const handleSelectSetVersion = async (id: string, path: string) => {
460 setSelectedSets(prev => ({ ...prev, [id]: path }));
461 // 自动保存选择
462 const selections: Record<string, string> = {};
463 characters.forEach(c => { selections[c.id] = selectedChars[c.id] || c.selected; });
464 settingsData.forEach(s => { selections[s.id] = selectedSets[s.id] || s.selected; });
465 selections[id] = path;
466 if (onSaveSelections) {
467 await onSaveSelections(selections);
468 }
469 };
470
471 const getCharSelected = (asset: AssetVersion) => selectedChars[asset.id] || asset.selected;
472 const getSetSelected = (asset: AssetVersion) => selectedSets[asset.id] || asset.selected;
473
474 return (
475 <div className="flex flex-col h-full">
476 <div className="flex-1 min-w-0 overflow-y-auto p-4 sm:p-6">
477 {/* 标题栏 */}
478 <div className="flex items-center justify-between mb-1">
479 <h2 className="text-lg font-semibold text-gray-800">角色 / 场景设计</h2>
480 </div>
481 <p className="text-sm text-gray-500 mb-6">
482 生成角色4视图 (正面特写·正面全身·侧面全身·背面全身) 和场景全景图
483 </p>
484
485 {/* 运行中 */}
486 {state.status === 'running' && (
487 <StageProgress message={state.progressMessage} fallback="正在生成角色与场景..." progress={state.progress} color="violet" />
488 )}
489
490 {state.error && (
491 <div className="text-sm text-red-600 bg-red-50 border border-red-200 p-4 rounded-xl mb-4">{state.error}</div>
492 )}
493
494 {/* ═══ 角色列表 ═══ */}
495 {hasChars && (
496 <section className="mb-8">
497 <div className="flex items-center gap-2 mb-3">
498 <Users className="w-4 h-4 text-violet-500" />
499 <h3 className="text-sm font-semibold text-gray-700">角色 ({characters.length})</h3>
500 </div>
501 <div className="space-y-3">
502 {characters.map(asset => (
503 <AssetRow
504 key={asset.id}
505 asset={{ ...asset, selected: getCharSelected(asset) }}
506 type="character"
507 isEditing={editingIds.has(asset.id)}
508 editDesc={editChars[asset.id] || asset.description}
509 onDescChange={val => setEditChars(prev => ({ ...prev, [asset.id]: val }))}
510 onRegenerate={() => handleRegenerate('characters', asset.id)}
511 onSelectVersion={path => handleSelectCharVersion(asset.id, path)}
512 onToggleEdit={() => startEdit(asset.id)}
513 onSaveEdit={() => saveEdit(asset.id)}
514 onCancelEdit={() => cancelEdit(asset.id)}
515 onUploadImage={file => handleUploadImage('characters', asset.id, file)}
516 isStageRunning={state.status === 'running'}
517 isRegenerating={regeneratingIds.has(asset.id)}
518 isUploading={uploadingIds.has(asset.id)}
519 />
520 ))}
521 </div>
522 </section>
523 )}
524
525 {/* ═══ 场景列表 ═══ */}
526 {hasSets && (
527 <section>
528 <div className="flex items-center gap-2 mb-3">
529 <MapPin className="w-4 h-4 text-emerald-500" />
530 <h3 className="text-sm font-semibold text-gray-700">场景 ({settingsData.length})</h3>
531 </div>
532 <div className="space-y-3">
533 {settingsData.map(asset => (
534 <AssetRow
535 key={asset.id}
536 asset={{ ...asset, selected: getSetSelected(asset) }}
537 type="setting"
538 isEditing={editingIds.has(asset.id)}
539 editDesc={editSets[asset.id] || asset.description}
540 onDescChange={val => setEditSets(prev => ({ ...prev, [asset.id]: val }))}
541 onRegenerate={() => handleRegenerate('settings', asset.id)}
542 onSelectVersion={path => handleSelectSetVersion(asset.id, path)}
543 onToggleEdit={() => startEdit(asset.id)}
544 onSaveEdit={() => saveEdit(asset.id)}
545 onCancelEdit={() => cancelEdit(asset.id)}
546 onUploadImage={file => handleUploadImage('settings', asset.id, file)}
547 isStageRunning={state.status === 'running'}
548 isRegenerating={regeneratingIds.has(asset.id)}
549 isUploading={uploadingIds.has(asset.id)}
550 />
551 ))}
552 </div>
553 </section>
554 )}
555
556 {state.status === 'pending' && (
557 <div className="text-center text-gray-400 text-sm py-20">等待上一阶段完成...</div>
558 )}
559 </div>
560
561 {/* 底部操作栏 */}
562 <StageActions
563 status={state.status}
564 onConfirm={onConfirm}
565 showConfirm={showConfirm}
566 onRegenerate={onRegenerate}
567 stageId="character_design"
568 hasPendingItems={hasPendingItems}
569 hasNextStageStarted={hasNextStageStarted}
570 isRunning={isRunning}
571 />
572 </div>
573 );
574 }
575
575 lines Plain Text