返回 VideoClaw
PipelinePage.tsx
1 'use client';
2
3 import { useEffect, useMemo, useState } from 'react';
4 import { useSearchParams } from 'next/navigation';
5 import {
6 ArrowRight,
7 CheckCircle,
8 Clapperboard,
9 Clock,
10 Image as ImageIcon,
11 Lightbulb,
12 Loader2,
13 Play,
14 RefreshCw,
15 Repeat2,
16 Settings2,
17 SlidersHorizontal,
18 Upload,
19 UserRound,
20 Video,
21 Volume2,
22 X,
23 XCircle,
24 } from 'lucide-react';
25 import clsx from 'clsx';
26 import {
27 fetchApiModels,
28 fetchStandardTemplates,
29 deletePipelineTask,
30 fetchPipelineTask,
31 fetchPipelineTasks,
32 startActionTransferPipeline,
33 startDigitalHumanPipeline,
34 startStandardPipeline,
35 subscribePipelineTask,
36 uploadMedia,
37 type PipelineTask,
38 type PipelineTaskEvent,
39 type ApiModelOption,
40 type StandardTemplateOption,
41 } from '@/lib/workflowApi';
42 import { fetchModelGroupsByType, groupModelOptions } from '@/lib/modelRegistry';
43 import {
44 VIDEO_RATIOS,
45 VIDEO_RESOLUTIONS,
46 type ProviderGroup,
47 } from '@/config/models';
48 import BrandHeader from '@/components/BrandHeader';
49
50 type PipelineId = 'standard' | 'action_transfer' | 'digital_human';
51
52 interface PipelinePageProps {
53 pipeline: PipelineId;
54 title: string;
55 subtitle: string;
56 }
57
58 const STANDARD_STYLE_PRESETS = [
59 {
60 label: '印象油画',
61 prompt:
62 'Impressionist painting style, visible broken brushstrokes, pure colors juxtaposed. The scene is bathed in natural, fleeting light with a shimmering, grainy texture in the air. Soft, blurred outlines, emphasizing color contrast over precise lines. Slight oil paint canvas texture, warm and luminous tone.',
63 },
64 {
65 label: '极简线条',
66 prompt:
67 'Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style',
68 },
69 {
70 label: '中国水墨',
71 prompt:
72 'Traditional Chinese ink wash painting style, visible xuan paper texture. Layered ink tones from deep black to pale gray, with dry brush strokes (feibai) and natural ink bleeding. Expressive and spontaneous brushwork, large negative space (liubai), interplay between solid and void. Wet and dry contrast, calligraphic rhythm in lines. Minimal color palette, a hint of light ochre or floral blue.',
73 },
74 {
75 label: '写实',
76 prompt:
77 'Photorealistic cinematic style, 8K resolution, physically accurate. Natural or practical lighting with sharp shadow edges. Materials exhibit realistic specular reflection and roughness, subtle surface textures and imperfections visible. Natural depth of field (sharp foreground with blurred background or vice versa). Motion includes inertial easing, mimicking real human eye observation. No stylized filters, true-to-life color reproduction.',
78 },
79 ];
80
81 const DEFAULT_STANDARD_STYLE_CONTROL = STANDARD_STYLE_PRESETS[0].prompt;
82
83 const TEMPLATE_TEXT_DEFAULTS = {
84 text: '心之所向,素履而往',
85 };
86
87 const TEMPLATE_FIELD_LABELS: Record<string, string> = {
88 author: 'author',
89 describe: 'describe',
90 brand: 'brand',
91 signature: 'signature',
92 subtitle: 'subtitle',
93 };
94
95 const TTS_VOICE_GROUPS: ProviderGroup[] = [
96 {
97 provider: 'zh-cn',
98 label: '普通话',
99 models: [
100 { id: 'zh-CN-XiaoxiaoNeural', label: '晓晓 · 女声' },
101 { id: 'zh-CN-XiaoyiNeural', label: '晓伊 · 女声' },
102 { id: 'zh-CN-YunjianNeural', label: '云健 · 男声', default: true },
103 { id: 'zh-CN-YunxiNeural', label: '云希 · 男声' },
104 { id: 'zh-CN-YunxiaNeural', label: '云夏 · 男声' },
105 { id: 'zh-CN-YunyangNeural', label: '云扬 · 男声' },
106 ],
107 },
108 {
109 provider: 'zh-cn-regional',
110 label: '方言/地区普通话',
111 models: [
112 { id: 'zh-CN-liaoning-XiaobeiNeural', label: '晓北 · 女声 · 东北官话' },
113 { id: 'zh-CN-shaanxi-XiaoniNeural', label: '晓妮 · 女声 · 陕西中原官话' },
114 ],
115 },
116 {
117 provider: 'zh-hk',
118 label: '香港中文/粤语',
119 models: [
120 { id: 'zh-HK-HiuGaaiNeural', label: '晓佳 · 女声 · 粤语' },
121 { id: 'zh-HK-HiuMaanNeural', label: '晓曼 · 女声' },
122 { id: 'zh-HK-WanLungNeural', label: '云龙 · 男声' },
123 ],
124 },
125 {
126 provider: 'zh-tw',
127 label: '台湾中文',
128 models: [
129 { id: 'zh-TW-HsiaoChenNeural', label: '晓臻 · 女声' },
130 { id: 'zh-TW-HsiaoYuNeural', label: '晓雨 · 女声' },
131 { id: 'zh-TW-YunJheNeural', label: '云哲 · 男声' },
132 ],
133 },
134 ];
135
136 const STATUS_STYLE: Record<string, string> = {
137 pending: 'bg-gray-100 text-gray-500',
138 running: 'bg-blue-50 text-blue-600',
139 completed: 'bg-green-50 text-green-600',
140 failed: 'bg-red-50 text-red-600',
141 };
142
143 const PIPELINE_TITLE_ICONS = {
144 standard: Clapperboard,
145 action_transfer: Repeat2,
146 digital_human: UserRound,
147 };
148
149 function SelectField({
150 label,
151 value,
152 onChange,
153 groups,
154 }: {
155 label: string;
156 value: string;
157 onChange: (value: string) => void;
158 groups: ProviderGroup[];
159 }) {
160 return (
161 <label className="flex flex-col gap-1.5 min-w-0">
162 <span className="text-xs font-medium text-gray-500">{label}</span>
163 <select
164 value={value}
165 onChange={e => onChange(e.target.value)}
166 className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300"
167 >
168 {groups.map(group => (
169 <optgroup key={group.provider} label={group.label}>
170 {group.models.map(model => (
171 <option key={model.id} value={model.id}>{model.label}</option>
172 ))}
173 </optgroup>
174 ))}
175 </select>
176 </label>
177 );
178 }
179
180 function groupApiModels(models: ApiModelOption[]): ProviderGroup[] {
181 return groupModelOptions(models).map(group => ({
182 ...group,
183 models: group.models.map(model => {
184 const source = models.find(item => item.id === model.id);
185 return { ...model, default: Boolean(source?.api_contract_verified) };
186 }),
187 }));
188 }
189
190 function firstModelId(groups: ProviderGroup[], preferred?: string) {
191 const models = groups.flatMap(group => group.models);
192 if (preferred && models.some(model => model.id === preferred)) return preferred;
193 return models.find(model => model.default)?.id || models[0]?.id || '';
194 }
195
196 function TextInput({
197 label,
198 value,
199 onChange,
200 placeholder,
201 required,
202 }: {
203 label: string;
204 value: string;
205 onChange: (value: string) => void;
206 placeholder?: string;
207 required?: boolean;
208 }) {
209 return (
210 <label className="flex flex-col gap-1.5 min-w-0">
211 <span className="text-xs font-medium text-gray-500">{label}{required ? ' *' : ''}</span>
212 <input
213 value={value}
214 onChange={e => onChange(e.target.value)}
215 placeholder={placeholder}
216 className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300"
217 />
218 </label>
219 );
220 }
221
222 function MediaUploadField({
223 label,
224 value,
225 onChange,
226 accept,
227 placeholder,
228 required,
229 }: {
230 label: string;
231 value: string;
232 onChange: (value: string) => void;
233 accept: string;
234 placeholder: string;
235 required?: boolean;
236 }) {
237 const [uploading, setUploading] = useState(false);
238 const [filename, setFilename] = useState('');
239 const [error, setError] = useState('');
240
241 const handleUpload = async (file?: File) => {
242 if (!file) return;
243 setUploading(true);
244 setError('');
245 try {
246 const result = await uploadMedia(file);
247 setFilename(result.filename);
248 onChange(result.file_path);
249 } catch (e: any) {
250 setError(e.message || '上传失败');
251 } finally {
252 setUploading(false);
253 }
254 };
255
256 return (
257 <label className="flex flex-col gap-1.5 min-w-0">
258 <span className="text-xs font-medium text-gray-500">{label}{required ? ' *' : ''}</span>
259 <div className="flex gap-2">
260 <input
261 value={value}
262 onChange={e => {
263 setFilename('');
264 onChange(e.target.value);
265 }}
266 placeholder={placeholder}
267 className="h-10 min-w-0 flex-1 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300"
268 />
269 <div className="relative flex-shrink-0">
270 <input
271 type="file"
272 accept={accept}
273 onChange={e => handleUpload(e.target.files?.[0])}
274 className="absolute inset-0 opacity-0 cursor-pointer"
275 disabled={uploading}
276 />
277 <button
278 type="button"
279 className={clsx(
280 'h-10 w-10 rounded-lg border flex items-center justify-center transition-colors',
281 uploading
282 ? 'border-gray-100 bg-gray-50 text-gray-300'
283 : 'border-gray-200 bg-white text-gray-500 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600'
284 )}
285 title="上传媒体"
286 >
287 {uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
288 </button>
289 </div>
290 {value && (
291 <button
292 type="button"
293 onClick={() => {
294 setFilename('');
295 onChange('');
296 }}
297 className="h-10 w-10 rounded-lg border border-gray-200 bg-white text-gray-400 hover:bg-gray-50 hover:text-red-500 flex items-center justify-center flex-shrink-0"
298 title="清除"
299 >
300 <X className="w-4 h-4" />
301 </button>
302 )}
303 </div>
304 {(filename || error) && (
305 <span className={clsx('text-[10px] truncate', error ? 'text-red-500' : 'text-gray-400')}>
306 {error || filename}
307 </span>
308 )}
309 </label>
310 );
311 }
312
313 function NumberField({
314 label,
315 value,
316 onChange,
317 min,
318 max,
319 }: {
320 label: string;
321 value: number;
322 onChange: (value: number) => void;
323 min: number;
324 max: number;
325 }) {
326 return (
327 <label className="flex flex-col gap-1.5 min-w-0">
328 <span className="text-xs font-medium text-gray-500">{label}</span>
329 <input
330 type="number"
331 min={min}
332 max={max}
333 value={value}
334 onChange={e => onChange(Number(e.target.value))}
335 className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300"
336 />
337 </label>
338 );
339 }
340
341 function assetHref(path?: string) {
342 if (!path) return '';
343 if (/^(https?:|data:|file:)/.test(path)) return path;
344 const marker = '/code/';
345 const idx = path.indexOf(marker);
346 if (idx >= 0) return `/code/${path.slice(idx + marker.length)}`;
347 return path;
348 }
349
350 function statusText(status?: string) {
351 if (status === 'pending') return '等待中';
352 if (status === 'running') return '生成中';
353 if (status === 'completed') return '已完成';
354 if (status === 'failed') return '失败';
355 return status || '未知';
356 }
357
358 function taskTitle(task: PipelineTask) {
359 const input = task.input || {};
360 const output = task.output || {};
361 return output.title || input.title || input.goods_title || input.text || input.prompt_text || input.goods_text || task.task_id;
362 }
363
364 type PipelineArtifact = NonNullable<PipelineTask['artifacts']>[number];
365
366 function isFinalVideoArtifact(item: PipelineArtifact) {
367 if (item.kind !== 'video') return false;
368 const name = (item.name || '').toLowerCase();
369 const path = (item.path || '').toLowerCase();
370 return name === 'final' || path.endsWith('/final.mp4') || path.endsWith('\\final.mp4');
371 }
372
373 function FinalVideoResult({ item }: { item?: PipelineArtifact }) {
374 return (
375 <section className="mb-4 rounded-xl border border-blue-100 bg-blue-50/40 p-3">
376 <div className="mb-2 flex items-center gap-2">
377 <Video className="w-4 h-4 text-blue-600" />
378 <h3 className="text-sm font-semibold text-gray-800">最终视频</h3>
379 </div>
380 {item ? (
381 <div className="overflow-hidden rounded-lg border border-gray-200 bg-black">
382 <video src={assetHref(item.path)} controls className="w-full max-h-72 object-contain" />
383 </div>
384 ) : (
385 <div className="h-36 rounded-lg border border-dashed border-blue-200 bg-white/60 flex items-center justify-center text-sm text-gray-400">
386 最终视频生成后显示
387 </div>
388 )}
389 </section>
390 );
391 }
392
393 function TaskResult({ task }: { task: PipelineTask | null }) {
394 const progress = Math.max(0, Math.min(100, task?.progress || 0));
395
396 if (!task) {
397 return (
398 <div className="bg-white rounded-2xl border border-gray-200 p-5 shadow-sm h-full">
399 <div className="flex items-center justify-between gap-3 mb-3">
400 <div className="flex items-center gap-2 min-w-0">
401 <SlidersHorizontal className="w-4 h-4 text-gray-400" />
402 <h2 className="text-sm font-semibold text-gray-700">任务状态</h2>
403 </div>
404 <span className="text-xs font-medium text-gray-400">0%</span>
405 </div>
406 <div className="mb-4 h-1.5 rounded-full bg-gray-100 overflow-hidden">
407 <div className="h-full w-0 rounded-full bg-blue-500" />
408 </div>
409 <div className="h-48 rounded-xl border border-dashed border-gray-200 bg-gray-50 flex items-center justify-center text-sm text-gray-400">
410 等待启动
411 </div>
412 </div>
413 );
414 }
415
416 const artifacts = task.artifacts || [];
417 const finalVideoArtifact = artifacts.find(isFinalVideoArtifact);
418 const mediaArtifacts = artifacts
419 .map((item, index) => ({ ...item, orderIndex: index }))
420 .filter(item => ['audio', 'image', 'video'].includes(item.kind))
421 .filter(item => !isFinalVideoArtifact(item))
422 .sort((a, b) => {
423 const aTime = a.created_at ? Date.parse(a.created_at) : Number.NaN;
424 const bTime = b.created_at ? Date.parse(b.created_at) : Number.NaN;
425 if (!Number.isNaN(aTime) && !Number.isNaN(bTime) && aTime !== bTime) return aTime - bTime;
426 if (!Number.isNaN(aTime) && Number.isNaN(bTime)) return -1;
427 if (Number.isNaN(aTime) && !Number.isNaN(bTime)) return 1;
428 return a.orderIndex - b.orderIndex;
429 });
430
431 return (
432 <div className="bg-white rounded-2xl border border-gray-200 p-5 shadow-sm h-full">
433 <div className="flex items-center justify-between gap-3 mb-3">
434 <div className="flex items-center gap-2 min-w-0">
435 {task.status === 'running' ? (
436 <Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
437 ) : task.status === 'completed' ? (
438 <CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
439 ) : task.status === 'failed' ? (
440 <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
441 ) : (
442 <Clock className="w-4 h-4 text-gray-400 flex-shrink-0" />
443 )}
444 <h2 className="text-sm font-semibold text-gray-700">任务状态</h2>
445 </div>
446 <div className="flex items-center gap-2 flex-shrink-0">
447 <span className={clsx('px-2 py-1 rounded-full text-xs font-medium', STATUS_STYLE[task.status] || STATUS_STYLE.pending)}>
448 {statusText(task.status)}
449 </span>
450 <span className="text-xs font-semibold text-gray-500">{progress}%</span>
451 </div>
452 </div>
453
454 <div className="mb-4 h-1.5 rounded-full bg-gray-100 overflow-hidden">
455 <div
456 className={clsx('h-full rounded-full transition-all', task.status === 'failed' ? 'bg-red-500' : 'bg-blue-500')}
457 style={{ width: `${progress}%` }}
458 />
459 </div>
460
461 <FinalVideoResult item={finalVideoArtifact} />
462
463 {mediaArtifacts.length > 0 ? (
464 <div className="max-h-[28rem] overflow-y-auto pr-1">
465 <div className="mb-2 flex items-center gap-2">
466 <SlidersHorizontal className="w-4 h-4 text-gray-400" />
467 <h3 className="text-sm font-semibold text-gray-700">中间产物</h3>
468 </div>
469 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
470 {mediaArtifacts.map((item, index) => (
471 <div
472 key={`${item.kind}-${item.name || index}-${item.path}`}
473 className="h-44 rounded-xl border border-gray-200 bg-gray-50 overflow-hidden min-w-0"
474 >
475 <div className="h-8 px-3 border-b border-gray-200 bg-white flex items-center gap-2">
476 {item.kind === 'video' && <Video className="w-3.5 h-3.5 text-blue-500" />}
477 {item.kind === 'image' && <ImageIcon className="w-3.5 h-3.5 text-emerald-500" />}
478 {item.kind === 'audio' && <Volume2 className="w-3.5 h-3.5 text-amber-500" />}
479 <span className="text-xs font-medium text-gray-500 truncate">{item.name || item.kind}</span>
480 </div>
481 {item.kind === 'video' && (
482 <video src={assetHref(item.path)} controls className="w-full h-36 bg-black object-contain" />
483 )}
484 {item.kind === 'image' && (
485 <img src={assetHref(item.path)} alt={item.name || 'image'} className="w-full h-36 object-contain" />
486 )}
487 {item.kind === 'audio' && (
488 <div className="h-36 px-3 flex items-center">
489 <audio src={assetHref(item.path)} controls className="w-full" />
490 </div>
491 )}
492 </div>
493 ))}
494 </div>
495 </div>
496 ) : (
497 <div className="h-48 rounded-xl bg-gray-50 border border-dashed border-gray-200 flex items-center justify-center text-sm text-gray-400">
498 结果生成后显示
499 </div>
500 )}
501 </div>
502 );
503 }
504
505 function TemplatePreviewCard({
506 template,
507 selected,
508 onClick,
509 }: {
510 template: StandardTemplateOption;
511 selected?: boolean;
512 onClick?: () => void;
513 }) {
514 const ratioClass = template.ratio === '16:9'
515 ? 'aspect-[16/9] w-36'
516 : template.ratio === '1:1'
517 ? 'aspect-square w-28'
518 : 'aspect-[9/16] w-24';
519 const scale = template.ratio === '16:9'
520 ? 0.075
521 : template.ratio === '1:1'
522 ? 0.105
523 : 0.089;
524
525 return (
526 <button
527 type="button"
528 onClick={onClick}
529 className={clsx(
530 'group flex-shrink-0 rounded-lg border bg-white p-1.5 text-left transition-all',
531 selected ? 'border-blue-400 ring-2 ring-blue-100' : 'border-gray-200 hover:border-blue-300 hover:shadow-sm'
532 )}
533 title={template.label}
534 >
535 <div className={clsx('relative overflow-hidden rounded-md bg-gray-100', ratioClass)}>
536 <iframe
537 src={template.preview_url}
538 title={template.label}
539 className="pointer-events-none absolute left-0 top-0 border-0 bg-white"
540 style={{
541 width: template.width,
542 height: template.height,
543 transform: `scale(${scale})`,
544 transformOrigin: 'top left',
545 }}
546 />
547 </div>
548 <div className="mt-1 max-w-36 truncate text-[10px] font-medium text-gray-500 group-hover:text-blue-600">
549 {template.label}
550 </div>
551 </button>
552 );
553 }
554
555 function PipelineHistory({
556 pipeline,
557 activeTaskId,
558 onSelect,
559 onDeleted,
560 }: {
561 pipeline: PipelineId;
562 activeTaskId?: string;
563 onSelect: (task: PipelineTask) => void;
564 onDeleted?: (taskId: string) => void;
565 }) {
566 const [tasks, setTasks] = useState<PipelineTask[]>([]);
567 const [loading, setLoading] = useState(false);
568 const [manageMode, setManageMode] = useState(false);
569 const [deleting, setDeleting] = useState<string | null>(null);
570
571 const load = async () => {
572 setLoading(true);
573 try {
574 const records = await fetchPipelineTasks(100);
575 setTasks(records.filter(task => task.pipeline === pipeline));
576 } finally {
577 setLoading(false);
578 }
579 };
580
581 useEffect(() => {
582 load().catch(() => {});
583 }, [pipeline]);
584
585 if (!tasks.length) return null;
586
587 const remove = async (taskId: string) => {
588 setDeleting(taskId);
589 try {
590 await deletePipelineTask(taskId);
591 setTasks(prev => prev.filter(task => task.task_id !== taskId));
592 onDeleted?.(taskId);
593 } finally {
594 setDeleting(null);
595 }
596 };
597
598 return (
599 <section className="w-full max-w-6xl mx-auto px-6 pb-12">
600 <div className="flex items-center gap-2 mb-4">
601 <Clock className="w-4 h-4 text-gray-400" />
602 <h3 className="text-sm font-medium text-gray-600">历史记录</h3>
603 <button
604 onClick={() => setManageMode(value => !value)}
605 className={clsx(
606 'ml-auto px-2.5 h-8 rounded-lg text-xs font-medium transition-colors',
607 manageMode ? 'bg-red-50 text-red-600 hover:bg-red-100' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
608 )}
609 >
610 {manageMode ? '完成' : '管理'}
611 </button>
612 <button
613 onClick={() => load().catch(() => {})}
614 className="w-8 h-8 rounded-lg bg-gray-100 text-gray-500 hover:bg-gray-200 flex items-center justify-center"
615 title="刷新历史"
616 >
617 <RefreshCw className={clsx('w-3.5 h-3.5', loading && 'animate-spin')} />
618 </button>
619 </div>
620 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
621 {tasks.map(task => (
622 <div
623 key={task.task_id}
624 onClick={() => !manageMode && onSelect(task)}
625 className={clsx(
626 'group text-left p-4 bg-white rounded-xl border hover:border-blue-300 hover:shadow-sm transition-all',
627 manageMode ? 'cursor-default' : 'cursor-pointer',
628 activeTaskId === task.task_id ? 'border-blue-300 ring-2 ring-blue-50' : 'border-gray-200'
629 )}
630 >
631 <div className="flex items-start justify-between gap-3">
632 <div className="min-w-0">
633 <div className="text-sm font-medium text-gray-700 group-hover:text-blue-600 transition-colors truncate">
634 {String(taskTitle(task)).slice(0, 48)}
635 </div>
636 <div className="mt-1.5 flex flex-wrap items-center gap-2">
637 <span className={clsx('text-[10px] px-1.5 py-0.5 rounded', STATUS_STYLE[task.status] || STATUS_STYLE.pending)}>
638 {statusText(task.status)}
639 </span>
640 <span className="text-[10px] text-gray-400">
641 {task.created_at ? new Date(task.created_at).toLocaleString('zh-CN') : task.task_id}
642 </span>
643 </div>
644 <div className="mt-2 h-1 rounded-full bg-gray-100 overflow-hidden">
645 <div className="h-full bg-blue-500 rounded-full" style={{ width: `${task.progress || 0}%` }} />
646 </div>
647 </div>
648 {manageMode ? (
649 <button
650 onClick={event => {
651 event.stopPropagation();
652 remove(task.task_id).catch(() => {});
653 }}
654 disabled={deleting === task.task_id}
655 className="w-8 h-8 rounded-lg text-red-500 bg-red-50 hover:bg-red-100 flex items-center justify-center flex-shrink-0"
656 title="删除任务"
657 >
658 {deleting === task.task_id ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
659 </button>
660 ) : (
661 <ArrowRight className="w-4 h-4 text-gray-300 group-hover:text-blue-400 flex-shrink-0 mt-0.5" />
662 )}
663 </div>
664 </div>
665 ))}
666 </div>
667 </section>
668 );
669 }
670
671 export default function PipelinePage({ pipeline, title, subtitle }: PipelinePageProps) {
672 const searchParams = useSearchParams();
673 const TitleIcon = PIPELINE_TITLE_ICONS[pipeline];
674 const [showSettings, setShowSettings] = useState(false);
675 const [running, setRunning] = useState(false);
676 const [error, setError] = useState('');
677 const [task, setTask] = useState<PipelineTask | null>(null);
678 const [imageModelGroups, setImageModelGroups] = useState<ProviderGroup[]>([]);
679 const [videoModelGroups, setVideoModelGroups] = useState<ProviderGroup[]>([]);
680 const [llmModelGroups, setLlmModelGroups] = useState<ProviderGroup[]>([]);
681
682 const [text, setText] = useState('');
683 const [standardMode, setStandardMode] = useState<'inspiration' | 'copy'>('inspiration');
684 const [standardVideoMode, setStandardVideoMode] = useState<'image_concat' | 'dynamic_video'>('image_concat');
685 const [templateMode, setTemplateMode] = useState(false);
686 const [templateMediaKind, setTemplateMediaKind] = useState<'image' | 'video'>('image');
687 const [templates, setTemplates] = useState<StandardTemplateOption[]>([]);
688 const [selectedTemplateId, setSelectedTemplateId] = useState('');
689 const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, string>>({});
690 const [titleValue, setTitleValue] = useState('');
691 const [standardSegmentCount, setStandardSegmentCount] = useState(6);
692 const [enableSubtitles, setEnableSubtitles] = useState(false);
693 const [subtitleRenderMode, setSubtitleRenderMode] = useState<'postprocess' | 'image_model'>('postprocess');
694 const [promptText, setPromptText] = useState('');
695 const [imagePath, setImagePath] = useState('');
696 const [videoPath, setVideoPath] = useState('');
697 const [characterImage, setCharacterImage] = useState('');
698 const [goodsImage, setGoodsImage] = useState('');
699 const [goodsTitle, setGoodsTitle] = useState('');
700 const [goodsText, setGoodsText] = useState('');
701
702 const [llmModel, setLlmModel] = useState('');
703 const [imageModel, setImageModel] = useState('');
704 const [videoModel, setVideoModel] = useState('');
705 const [ratio, setRatio] = useState('9:16');
706 const [videoResolution, setVideoResolution] = useState('720P');
707 const [duration, setDuration] = useState(5);
708 const [ttsVoice, setTtsVoice] = useState('zh-CN-YunjianNeural');
709 const [ttsSpeed, setTtsSpeed] = useState(1);
710 const [negativePrompt, setNegativePrompt] = useState(pipeline === 'standard' ? DEFAULT_STANDARD_STYLE_CONTROL : '');
711
712 useEffect(() => {
713 const imageAbility = pipeline === 'digital_human' ? 'reference_image' : 'text_to_image';
714 const videoAbility = pipeline === 'standard'
715 ? 'first_frame_i2v'
716 : pipeline === 'action_transfer'
717 ? 'action_transfer'
718 : 'digital_human';
719
720 if (pipeline === 'standard') {
721 setNegativePrompt(current => current || DEFAULT_STANDARD_STYLE_CONTROL);
722 }
723
724 fetchApiModels({ mediaType: 'image', ability: imageAbility, verifiedOnly: true })
725 .then(models => {
726 const groups = groupApiModels(models);
727 setImageModelGroups(groups);
728 setImageModel(current => firstModelId(groups, current));
729 })
730 .catch(() => {});
731
732 if (pipeline !== 'standard' || standardVideoMode === 'dynamic_video' || (templateMode && templateMediaKind === 'video')) {
733 fetchApiModels({ mediaType: 'video', ability: videoAbility, verifiedOnly: true })
734 .then(models => {
735 const groups = groupApiModels(models);
736 setVideoModelGroups(groups);
737 setVideoModel(current => firstModelId(groups, current));
738 })
739 .catch(() => {});
740 }
741 }, [pipeline, standardVideoMode, templateMode, templateMediaKind]);
742
743 useEffect(() => {
744 let cancelled = false;
745 fetchModelGroupsByType('llm')
746 .then(groups => {
747 if (cancelled) return;
748 setLlmModelGroups(groups);
749 setLlmModel(current => firstModelId(groups, current));
750 })
751 .catch(() => {});
752 return () => { cancelled = true; };
753 }, []);
754
755 useEffect(() => {
756 if (pipeline !== 'standard') return;
757 fetchStandardTemplates()
758 .then(items => {
759 setTemplates(items);
760 setSelectedTemplateId(current => current || items.find(item => item.ratio === ratio)?.id || items[0]?.id || '');
761 })
762 .catch(() => {});
763 }, [pipeline, ratio]);
764
765 useEffect(() => {
766 if (!templateMode) return;
767 setEnableSubtitles(true);
768 setStandardVideoMode('image_concat');
769 setText(current => current || TEMPLATE_TEXT_DEFAULTS.text);
770 setSelectedTemplateId(current => {
771 if (current && templates.some(item => item.id === current && item.ratio === ratio)) return current;
772 return templates.find(item => item.ratio === ratio)?.id || current;
773 });
774 }, [templateMode, ratio, templates]);
775
776 const portraitTemplates = useMemo(
777 () => templates.filter(item => item.size === '1080x1920'),
778 [templates]
779 );
780 const ratioTemplates = useMemo(
781 () => templates.filter(item => item.ratio === ratio),
782 [templates, ratio]
783 );
784 const selectedTemplate = useMemo(
785 () => templates.find(item => item.id === selectedTemplateId) || null,
786 [templates, selectedTemplateId]
787 );
788 const templateVideoEnabled = templateMode && templateMediaKind === 'video';
789 const selectedTemplateFields = useMemo(
790 () => selectedTemplate?.fields || [],
791 [selectedTemplate]
792 );
793
794 useEffect(() => {
795 if (!templateMode || templateMediaKind !== 'video') return;
796 if (selectedTemplate && !selectedTemplate.supports_video) {
797 setTemplateMediaKind('image');
798 }
799 }, [templateMode, templateMediaKind, selectedTemplate]);
800
801 useEffect(() => {
802 if (!templateMode) return;
803 if (!selectedTemplateFields.length) {
804 setTemplateFieldValues({});
805 return;
806 }
807 setTemplateFieldValues(current => {
808 const next: Record<string, string> = {};
809 for (const field of selectedTemplateFields) {
810 next[field.key] = current[field.key] ?? field.default ?? '';
811 }
812 return next;
813 });
814 }, [templateMode, selectedTemplateFields]);
815
816 const enterTemplateMode = () => {
817 setTemplateMode(true);
818 setEnableSubtitles(true);
819 setStandardVideoMode('image_concat');
820 setText(current => current || TEMPLATE_TEXT_DEFAULTS.text);
821 };
822
823 const canSubmit = useMemo(() => {
824 if (pipeline === 'standard') {
825 return text.trim().length > 0
826 && (!templateMode || Boolean(selectedTemplate))
827 && (!templateVideoEnabled || Boolean(selectedTemplate?.supports_video));
828 }
829 if (pipeline === 'action_transfer') return promptText.trim() && imagePath.trim() && videoPath.trim();
830 return characterImage.trim() && goodsText.trim();
831 }, [pipeline, text, templateMode, selectedTemplate, templateVideoEnabled, promptText, imagePath, videoPath, characterImage, goodsText]);
832
833 useEffect(() => {
834 if (!task || !['pending', 'running'].includes(task.status)) return;
835
836 const refreshTask = async () => {
837 const fresh = await fetchPipelineTask(task.task_id);
838 setTask(fresh);
839 if (!['pending', 'running'].includes(fresh.status)) {
840 setRunning(false);
841 }
842 };
843
844 const handleEvent = (event: PipelineTaskEvent) => {
845 if (event.type === 'snapshot' || event.type === 'progress') {
846 setTask(prev => prev && prev.task_id === event.task_id
847 ? {
848 ...prev,
849 status: event.status || prev.status,
850 progress: event.progress ?? prev.progress,
851 }
852 : prev
853 );
854 return;
855 }
856
857 if (event.type === 'artifact' || event.type === 'completed' || event.type === 'failed') {
858 refreshTask().catch(() => setRunning(false));
859 }
860 };
861
862 return subscribePipelineTask(
863 task.task_id,
864 handleEvent,
865 () => {
866 if (task.status === 'pending' || task.status === 'running') {
867 setRunning(false);
868 }
869 },
870 );
871 }, [task?.task_id, task?.status]);
872
873 useEffect(() => {
874 const taskId = searchParams.get('task');
875 if (!taskId || task?.task_id === taskId) return;
876 fetchPipelineTask(taskId)
877 .then(fresh => {
878 if (fresh.pipeline === pipeline) {
879 setTask(fresh);
880 setRunning(['pending', 'running'].includes(fresh.status));
881 }
882 })
883 .catch(() => {});
884 }, [pipeline, searchParams, task?.task_id]);
885
886 const submit = async () => {
887 if (!canSubmit || running) return;
888 setRunning(true);
889 setError('');
890 try {
891 const submittedTitle = titleValue.trim();
892 const common = {
893 video_model: videoModel,
894 video_ratio: ratio,
895 video_resolution: videoResolution,
896 duration,
897 negative_prompt: negativePrompt || undefined,
898 };
899 const started = pipeline === 'standard'
900 ? await startStandardPipeline({
901 text,
902 mode: standardMode,
903 title: submittedTitle || undefined,
904 llm_model: llmModel,
905 image_model: imageModel,
906 video_ratio: ratio,
907 video_resolution: videoResolution,
908 enable_subtitles: templateMode ? true : enableSubtitles,
909 subtitle_render_mode: !templateMode && enableSubtitles ? subtitleRenderMode : undefined,
910 subtitle_template: templateMode ? selectedTemplate?.id : undefined,
911 subtitle_template_fields: templateMode ? templateFieldValues : undefined,
912 template_media_kind: templateMode ? templateMediaKind : undefined,
913 tts_voice: ttsVoice,
914 tts_speed: ttsSpeed,
915 style_control: negativePrompt || undefined,
916 segment_count: standardMode === 'inspiration' ? standardSegmentCount : undefined,
917 video_mode: templateMode ? 'image_concat' : standardVideoMode,
918 video_model: templateVideoEnabled || (!templateMode && standardVideoMode === 'dynamic_video') ? videoModel : undefined,
919 video_duration: templateVideoEnabled || (!templateMode && standardVideoMode === 'dynamic_video') ? duration : undefined,
920 })
921 : pipeline === 'action_transfer'
922 ? await startActionTransferPipeline({
923 prompt_text: promptText,
924 image_path: imagePath,
925 video_path: videoPath,
926 ...common,
927 })
928 : await startDigitalHumanPipeline({
929 mode: 'customize',
930 character_image_path: characterImage,
931 goods_image_path: goodsImage || undefined,
932 goods_title: goodsTitle || undefined,
933 goods_text: goodsText || undefined,
934 llm_model: llmModel,
935 image_model: imageModel,
936 video_model: videoModel,
937 video_ratio: ratio,
938 video_resolution: videoResolution,
939 tts_voice: ttsVoice,
940 tts_speed: ttsSpeed,
941 negative_prompt: negativePrompt || undefined,
942 });
943 const fresh = await fetchPipelineTask(started.task_id);
944 setTask(fresh);
945 } catch (e: any) {
946 setError(e.message || '启动失败');
947 setRunning(false);
948 }
949 };
950
951 return (
952 <div className="min-h-screen bg-gray-50/50 overflow-y-auto">
953 <BrandHeader />
954
955 <div className="w-full max-w-6xl mx-auto px-6 pt-10 pb-8">
956 <div className="text-center mb-10">
957 <div className="inline-flex items-center gap-2 mb-3">
958 <TitleIcon className="w-7 h-7 text-blue-500" />
959 <h1 className="text-2xl font-bold text-gray-800">{title}</h1>
960 </div>
961 <p className="text-sm text-gray-500">{subtitle}</p>
962 </div>
963
964 {pipeline === 'standard' && (
965 <section className="mb-5 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
966 <div className="flex flex-col gap-3 md:flex-row md:items-center">
967 <button
968 type="button"
969 onClick={() => {
970 if (templateMode) setTemplateMode(false);
971 else enterTemplateMode();
972 }}
973 className={clsx(
974 'h-10 flex-shrink-0 rounded-xl px-4 text-sm font-medium transition-colors',
975 templateMode
976 ? 'bg-gray-900 text-white hover:bg-gray-800'
977 : 'bg-blue-500 text-white hover:bg-blue-600'
978 )}
979 >
980 {templateMode ? '返回自定义生成模式' : '使用精品模版'}
981 </button>
982 <div className="min-w-0 flex-1 overflow-x-auto">
983 <div className="flex gap-3 pb-1">
984 {portraitTemplates.length ? (
985 portraitTemplates.map(item => (
986 <TemplatePreviewCard
987 key={item.id}
988 template={item}
989 selected={templateMode && selectedTemplateId === item.id}
990 onClick={() => {
991 enterTemplateMode();
992 setRatio(item.ratio);
993 setSelectedTemplateId(item.id);
994 }}
995 />
996 ))
997 ) : (
998 <div className="flex h-28 min-w-0 flex-1 items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50 text-xs text-gray-400">
999 暂无 1080x1920 模版
1000 </div>
1001 )}
1002 </div>
1003 </div>
1004 </div>
1005 </section>
1006 )}
1007
1008 <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] gap-5 items-start">
1009 <section className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5">
1010 {pipeline === 'standard' && (
1011 <div className="space-y-4">
1012 {templateMode ? (
1013 <div className="space-y-3">
1014 <div className="grid h-10 max-w-sm grid-cols-3 rounded-lg bg-gray-100 p-1 text-sm">
1015 {['9:16', '1:1', '16:9'].map(item => (
1016 <button
1017 key={item}
1018 onClick={() => setRatio(item)}
1019 className={clsx('rounded-md transition-colors', ratio === item ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500')}
1020 >
1021 {item}
1022 </button>
1023 ))}
1024 </div>
1025 <div className="rounded-xl border border-gray-200 bg-gray-50 p-3">
1026 <div className="mb-2 text-xs font-medium text-gray-500">选择模版</div>
1027 <div className="overflow-x-auto">
1028 <div className="flex gap-3 pb-1">
1029 {ratioTemplates.length ? (
1030 ratioTemplates.map(item => (
1031 <TemplatePreviewCard
1032 key={item.id}
1033 template={item}
1034 selected={selectedTemplateId === item.id}
1035 onClick={() => setSelectedTemplateId(item.id)}
1036 />
1037 ))
1038 ) : (
1039 <div className="flex h-28 min-w-0 flex-1 items-center justify-center rounded-xl border border-dashed border-gray-200 bg-white text-xs text-gray-400">
1040 当前比例暂无模版
1041 </div>
1042 )}
1043 </div>
1044 </div>
1045 </div>
1046 <div className="grid h-10 max-w-sm grid-cols-2 rounded-lg bg-gray-100 p-1 text-sm">
1047 {[
1048 { id: 'image', label: '图片拼接', disabled: false },
1049 { id: 'video', label: '动态视频', disabled: Boolean(selectedTemplate && !selectedTemplate.supports_video) },
1050 ].map(item => (
1051 <button
1052 key={item.id}
1053 type="button"
1054 disabled={item.disabled}
1055 title={item.disabled ? '当前模版不支持动态视频' : undefined}
1056 onClick={() => setTemplateMediaKind(item.id as 'image' | 'video')}
1057 className={clsx(
1058 'rounded-md transition-colors',
1059 templateMediaKind === item.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500',
1060 item.disabled && 'cursor-not-allowed opacity-40'
1061 )}
1062 >
1063 {item.label}
1064 </button>
1065 ))}
1066 </div>
1067 </div>
1068 ) : (
1069 <div className="grid grid-cols-2 h-10 rounded-lg bg-gray-100 p-1 text-sm max-w-sm">
1070 {[
1071 { id: 'image_concat', label: '图片拼接' },
1072 { id: 'dynamic_video', label: '动态视频' },
1073 ].map(item => (
1074 <button
1075 key={item.id}
1076 onClick={() => setStandardVideoMode(item.id as 'image_concat' | 'dynamic_video')}
1077 className={clsx('rounded-md transition-colors', standardVideoMode === item.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500')}
1078 >
1079 {item.label}
1080 </button>
1081 ))}
1082 </div>
1083 )}
1084 <div className="rounded-xl border border-yellow-300 bg-yellow-100 px-4 py-3">
1085 <div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-yellow-900">
1086 <Lightbulb className="w-3.5 h-3.5" />
1087 <span>说明</span>
1088 </div>
1089 <p className="text-sm leading-6 text-yellow-950">
1090 {templateMode
1091 ? templateMediaKind === 'video'
1092 ? '精品模版会先按模版媒体位生成图片,再调用视频模型生成动态媒体,最后逐帧渲染 HTML 模版并合成 TTS 音频。'
1093 : '精品模版会按模版媒体位生成图片,再用 HTML 模版精确排版标题、字幕和自定义字段。'
1094 : standardVideoMode === 'image_concat'
1095 ? '图片拼接会为每个旁白片段生成一张图片,并配合 TTS 音频合成为静态图文短视频。等待时间主要取决于图片生成速度,通常生成每张图片需 10-20 秒。'
1096 : '动态视频会先为每个旁白片段生成图片,再调用视频模型把图片扩展为动态片段,最后合成为完整短片。等待时间更长,通常生成每个视频片段需 1-2 分钟。'}
1097 </p>
1098 </div>
1099 <div className="grid grid-cols-2 h-10 rounded-lg bg-gray-100 p-1 text-sm max-w-sm">
1100 {[
1101 { id: 'inspiration', label: '创作灵感' },
1102 { id: 'copy', label: '完整文案' },
1103 ].map(item => (
1104 <button
1105 key={item.id}
1106 onClick={() => setStandardMode(item.id as 'inspiration' | 'copy')}
1107 className={clsx('rounded-md transition-colors', standardMode === item.id ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500')}
1108 >
1109 {item.label}
1110 </button>
1111 ))}
1112 </div>
1113 <textarea
1114 value={text}
1115 onChange={e => setText(e.target.value)}
1116 placeholder={standardMode === 'inspiration' ? '输入主题、观点或故事灵感,系统会先构思成完整旁白...' : '输入完整旁白文案,系统会按句号切分片段并直接进入 TTS...'}
1117 className="w-full min-h-[150px] resize-none rounded-xl border border-gray-200 bg-white px-3 py-3 text-sm text-gray-800 outline-none focus:border-blue-300"
1118 />
1119 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1120 <TextInput label="标题" value={titleValue} onChange={setTitleValue} placeholder="可选,留空时由llm生成" />
1121 {standardMode === 'inspiration' && (
1122 <NumberField label="片段数量" value={standardSegmentCount} onChange={setStandardSegmentCount} min={1} max={20} />
1123 )}
1124 </div>
1125 {templateMode && selectedTemplateFields.length > 0 && (
1126 <div className="rounded-xl border border-gray-200 bg-gray-50 p-3">
1127 <div className="mb-3 text-xs font-medium text-gray-500">自定义字段</div>
1128 <div className="space-y-2">
1129 {selectedTemplateFields.map(field => (
1130 <label key={field.key} className="grid grid-cols-[92px_minmax(0,1fr)] items-center gap-2">
1131 <span className="text-xs font-medium text-gray-500">{TEMPLATE_FIELD_LABELS[field.key] || field.key}</span>
1132 <input
1133 value={templateFieldValues[field.key] ?? field.default ?? ''}
1134 onChange={event => {
1135 const value = event.target.value;
1136 setTemplateFieldValues(current => ({ ...current, [field.key]: value }));
1137 }}
1138 className="h-9 min-w-0 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-blue-300"
1139 />
1140 </label>
1141 ))}
1142 </div>
1143 </div>
1144 )}
1145 </div>
1146 )}
1147
1148 {pipeline === 'action_transfer' && (
1149 <div className="space-y-4">
1150 <textarea
1151 value={promptText}
1152 onChange={e => setPromptText(e.target.value)}
1153 placeholder="描述希望迁移到人物或角色上的动作效果..."
1154 className="w-full min-h-[130px] resize-none rounded-xl border border-gray-200 bg-white px-3 py-3 text-sm text-gray-800 outline-none focus:border-blue-300"
1155 />
1156 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1157 <MediaUploadField label="参考图片" value={imagePath} onChange={setImagePath} accept="image/*" placeholder="/path/to/image.png" required />
1158 <MediaUploadField label="动作视频" value={videoPath} onChange={setVideoPath} accept="video/*" placeholder="/path/to/video.mp4" required />
1159 </div>
1160 </div>
1161 )}
1162
1163 {pipeline === 'digital_human' && (
1164 <div className="space-y-4">
1165 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1166 <MediaUploadField label="人物图片" value={characterImage} onChange={setCharacterImage} accept="image/*" placeholder="/path/to/person.png" required />
1167 <MediaUploadField label="商品图片" value={goodsImage} onChange={setGoodsImage} accept="image/*" placeholder="/path/to/product.png" />
1168 </div>
1169 <TextInput label="商品标题" value={goodsTitle} onChange={setGoodsTitle} placeholder="可选,留空时由llm生成" />
1170 <textarea
1171 value={goodsText}
1172 onChange={e => setGoodsText(e.target.value)}
1173 placeholder="输入口播文案..."
1174 className="w-full min-h-[130px] resize-none rounded-xl border border-gray-200 bg-white px-3 py-3 text-sm text-gray-800 outline-none focus:border-blue-300"
1175 />
1176 </div>
1177 )}
1178
1179 <div className="mt-4 pt-4 border-t border-gray-100 flex flex-wrap items-center gap-2">
1180 <button
1181 onClick={() => setShowSettings(!showSettings)}
1182 className={clsx(
1183 'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors',
1184 showSettings ? 'bg-blue-50 text-blue-600' : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
1185 )}
1186 >
1187 <Settings2 className="w-3.5 h-3.5" />
1188 生成配置
1189 </button>
1190 {pipeline === 'standard' && (
1191 <>
1192 <label className="flex items-center gap-2 h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs font-medium text-gray-600">
1193 <input
1194 type="checkbox"
1195 checked={templateMode || enableSubtitles}
1196 onChange={e => setEnableSubtitles(e.target.checked)}
1197 disabled={templateMode}
1198 className="w-4 h-4 rounded border-gray-300"
1199 />
1200 添加标题和字幕
1201 </label>
1202 {!templateMode && enableSubtitles && (
1203 <div className="grid h-9 grid-cols-2 rounded-lg bg-gray-100 p-1 text-xs">
1204 {[
1205 { id: 'postprocess', label: 'PIL 后期叠字' },
1206 { id: 'image_model', label: '模型直接生成字幕' },
1207 ].map(item => (
1208 <button
1209 key={item.id}
1210 type="button"
1211 onClick={() => setSubtitleRenderMode(item.id as 'postprocess' | 'image_model')}
1212 className={clsx(
1213 'rounded-md px-3 font-medium transition-colors',
1214 subtitleRenderMode === item.id
1215 ? 'bg-white text-blue-600 shadow-sm'
1216 : 'text-gray-500 hover:text-gray-700'
1217 )}
1218 >
1219 {item.label}
1220 </button>
1221 ))}
1222 </div>
1223 )}
1224 </>
1225 )}
1226 {error && <span className="text-xs text-red-500 truncate">{error}</span>}
1227 <button
1228 onClick={submit}
1229 disabled={!canSubmit || running}
1230 className={clsx(
1231 'ml-auto flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-medium transition-colors',
1232 canSubmit && !running ? 'bg-blue-500 text-white hover:bg-blue-600 shadow-sm' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
1233 )}
1234 >
1235 {running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
1236 启动任务
1237 </button>
1238 </div>
1239
1240 {showSettings && (
1241 <div className="mt-4 p-4 bg-gray-50 rounded-xl space-y-3">
1242 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1243 {(pipeline === 'digital_human' || pipeline === 'standard') && (
1244 <SelectField label="LLM 模型" value={llmModel} onChange={setLlmModel} groups={llmModelGroups} />
1245 )}
1246 {pipeline !== 'action_transfer' && (
1247 <SelectField label="图片模型" value={imageModel} onChange={setImageModel} groups={imageModelGroups} />
1248 )}
1249 {(pipeline !== 'standard' || standardVideoMode === 'dynamic_video' || templateVideoEnabled) && (
1250 <SelectField label="视频模型" value={videoModel} onChange={setVideoModel} groups={videoModelGroups} />
1251 )}
1252 <label className="flex flex-col gap-1.5">
1253 <span className="text-xs font-medium text-gray-500">视频比例</span>
1254 <select value={ratio} onChange={e => setRatio(e.target.value)} className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none">
1255 {VIDEO_RATIOS.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
1256 </select>
1257 </label>
1258 <label className="flex flex-col gap-1.5">
1259 <span className="text-xs font-medium text-gray-500">视频分辨率</span>
1260 <select value={videoResolution} onChange={e => setVideoResolution(e.target.value)} className="h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none">
1261 {VIDEO_RESOLUTIONS.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
1262 </select>
1263 </label>
1264 {(pipeline === 'action_transfer' || (pipeline === 'standard' && (standardVideoMode === 'dynamic_video' || templateVideoEnabled))) && (
1265 <NumberField label="视频时长" value={duration} onChange={setDuration} min={1} max={10} />
1266 )}
1267 {pipeline !== 'action_transfer' && (
1268 <>
1269 <SelectField label="TTS 声音" value={ttsVoice} onChange={setTtsVoice} groups={TTS_VOICE_GROUPS} />
1270 <NumberField label="TTS 速度" value={ttsSpeed} onChange={setTtsSpeed} min={0.5} max={2} />
1271 </>
1272 )}
1273 </div>
1274 <label className="flex flex-col gap-1.5">
1275 <span className="text-xs font-medium text-gray-500">{pipeline === 'standard' ? '风格控制' : '负向提示词'}</span>
1276 <textarea
1277 value={negativePrompt}
1278 onChange={e => setNegativePrompt(e.target.value)}
1279 placeholder={pipeline === 'standard' ? '会作为所有图像提示词的前缀...' : '负向提示词...'}
1280 className="w-full min-h-[70px] resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 outline-none focus:border-blue-300"
1281 />
1282 </label>
1283 {pipeline === 'standard' && (
1284 <div className="flex flex-wrap gap-2">
1285 {STANDARD_STYLE_PRESETS.map(preset => (
1286 <button
1287 key={preset.label}
1288 type="button"
1289 onClick={() => setNegativePrompt(preset.prompt)}
1290 className={clsx(
1291 'h-8 rounded-lg border px-3 text-xs font-medium transition-colors',
1292 negativePrompt === preset.prompt
1293 ? 'border-blue-300 bg-blue-50 text-blue-600'
1294 : 'border-gray-200 bg-white text-gray-500 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600'
1295 )}
1296 >
1297 {preset.label}
1298 </button>
1299 ))}
1300 </div>
1301 )}
1302 </div>
1303 )}
1304 </section>
1305
1306 <TaskResult task={task} />
1307 </div>
1308 </div>
1309
1310 <PipelineHistory
1311 pipeline={pipeline}
1312 activeTaskId={task?.task_id}
1313 onSelect={selected => setTask(selected)}
1314 onDeleted={taskId => {
1315 if (task?.task_id === taskId) setTask(null);
1316 }}
1317 />
1318 </div>
1319 );
1320 }
1321
1321 lines Plain Text