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