CreateForm.jsx
19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
import React, { useState, useEffect } from 'react';
import { Sparkles, Sliders, RefreshCw, Send, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import MediaUploader from './MediaUploader';
import { RESOLUTIONS, DURATIONS, STEPS_BY_TURBO, SEED_OPTIONS, DEFAULT_RES, DEFAULT_DUR } from '../constants';
import { createJob } from '../api';
import { useToast } from './Toast';
export default function CreateForm({ initialValues, onCreated }) {
const { addToast } = useToast();
const [tab, setTab] = useState('text'); // 'text' | 'fl2va' | 'ref2va'
const [prompt, setPrompt] = useState('');
const [resValue, setResValue] = useState(DEFAULT_RES);
const [durValue, setDurValue] = useState(DEFAULT_DUR);
const [turbo, setTurbo] = useState(false);
const [steps, setSteps] = useState('20');
const [seed, setSeed] = useState('0');
const [cfgScale, setCfgScale] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState('');
// 媒体状态
const [firstFrame, setFirstFrame] = useState(null);
const [lastFrame, setLastFrame] = useState(null);
const [refImages, setRefImages] = useState([]);
const [refVideos, setRefVideos] = useState([]);
const [refAudios, setRefAudios] = useState([]);
// 如果从详情页点了“重跑”,注入初始参数
useEffect(() => {
if (!initialValues) return;
const req = initialValues;
if (req.first_frame_url || req.last_frame_url) {
setTab('fl2va');
setFirstFrame(req.first_frame_url || null);
setLastFrame(req.last_frame_url || null);
} else if ((req.reference_image_urls || []).length || (req.reference_video_urls || []).length || (req.reference_audio_urls || []).length) {
setTab('ref2va');
setRefImages(req.reference_image_urls || []);
setRefVideos(req.reference_video_urls || []);
setRefAudios(req.reference_audio_urls || []);
} else {
setTab('text');
}
setPrompt(req.prompt || '');
const matchedRes = RESOLUTIONS.find((r) => r.width === req.width && r.height === req.height);
if (matchedRes) setResValue(matchedRes.value);
const matchedDur = DURATIONS.find((d) => d.frames === req.frames);
if (matchedDur) setDurValue(matchedDur.value);
const isTurbo = req.turbo === false ? false : true;
setTurbo(isTurbo);
if (req.steps != null) setSteps(String(req.steps));
if (req.seed != null) setSeed(String(req.seed));
if (req.cfg_scale != null) setCfgScale(String(req.cfg_scale));
if (req.negative_prompt) setNegativePrompt(req.negative_prompt);
}, [initialValues]);
// 分辨率对应的时长合法性校验
const currentRes = RESOLUTIONS.find((r) => r.value === resValue) || RESOLUTIONS[0];
const allowedDurations = DURATIONS.filter((d) => d.frames <= currentRes.maxFrames);
const droppedDurations = DURATIONS.filter((d) => d.frames > currentRes.maxFrames);
useEffect(() => {
if (!allowedDurations.some((d) => d.value === durValue)) {
setDurValue(allowedDurations[0]?.value || DEFAULT_DUR);
}
}, [resValue]);
// Turbo 切换自动联动步数
const stepOptions = STEPS_BY_TURBO[turbo];
useEffect(() => {
if (!stepOptions.some((s) => s.value === steps)) {
setSteps(stepOptions[0].value);
}
}, [turbo]);
const handleClear = () => {
if (!confirm('确定清空当前表单所有内容?')) return;
setPrompt('');
setResValue(DEFAULT_RES);
setDurValue(DEFAULT_DUR);
setTurbo(false);
setSteps('20');
setSeed('0');
setCfgScale('');
setNegativePrompt('');
setFirstFrame(null);
setLastFrame(null);
setRefImages([]);
setRefVideos([]);
setRefAudios([]);
setErr('');
addToast('已恢复默认配置', 'info');
};
const handleSubmit = async (e) => {
e.preventDefault();
setErr('');
if (!prompt.trim()) {
setErr('请填写 Prompt 提示词');
return;
}
const durObj = DURATIONS.find((d) => d.value === durValue);
const body = {
prompt: prompt.trim(),
width: currentRes.width,
height: currentRes.height,
frames: durObj ? durObj.frames : 243,
turbo,
steps: parseInt(steps, 10),
seed: seed === 'random' ? Math.floor(Math.random() * 2 ** 31) : parseInt(seed, 10),
};
if (cfgScale.trim() !== '') body.cfg_scale = parseFloat(cfgScale);
if (negativePrompt.trim() !== '') body.negative_prompt = negativePrompt.trim();
if (tab === 'fl2va') {
if (!firstFrame) {
setErr('首尾帧模式下必须上传【首帧图】');
return;
}
body.first_frame_url = firstFrame;
if (lastFrame) body.last_frame_url = lastFrame;
} else if (tab === 'ref2va') {
if (refImages.length === 0 && refVideos.length === 0 && refAudios.length === 0) {
setErr('参考模式下至少需要上传 1 张参考图、1 段参考视频或 1 个音频');
return;
}
body.reference_image_urls = refImages;
body.reference_video_urls = refVideos;
body.reference_audio_urls = refAudios;
}
setSubmitting(true);
try {
const job = await createJob(body);
addToast(`任务提交成功 (#${job.id.substring(0, 8)})`, 'success');
onCreated(job.id);
} catch (e) {
setErr('提交失败: ' + e.message);
addToast('提交失败: ' + e.message, 'error');
} finally {
setSubmitting(false);
}
};
return (
<div className="max-w-4xl mx-auto py-6 px-4 sm:px-8">
{/* 顶部标签切换 */}
<div className="flex items-center gap-1.5 p-1.5 bg-slate-100 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl w-fit shadow-xs mb-6">
{[
{ key: 'text', label: '文生视频', desc: 'Text to Video' },
{ key: 'fl2va', label: '首尾帧生视频', desc: 'FL2VA' },
{ key: 'ref2va', label: '多参考图生视频', desc: 'Ref2VA' },
].map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex flex-col items-start px-4 py-2 rounded-xl text-xs transition-all cursor-pointer ${
tab === t.key
? 'bg-emerald-600 text-white font-semibold shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/80 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800/60'
}`}
>
<span>{t.label}</span>
<span className={`text-[10px] ${tab === t.key ? 'text-emerald-100' : 'text-slate-400 dark:text-zinc-500'}`}>{t.desc}</span>
</button>
))}
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Prompt 输入 */}
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl p-5 shadow-xs">
<div className="flex items-center justify-between mb-2.5">
<label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-zinc-300 flex items-center gap-2">
<Sparkles className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
<span>Prompt 提示词</span>
</label>
<span className="text-xs text-slate-400 dark:text-zinc-500">{prompt.length} 字</span>
</div>
<textarea
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={
tab === 'fl2va'
? '描述首帧到尾帧之间的动态演变过程,例如:女子缓缓端起茶杯,轻轻抿了一口,微风拂动发丝...'
: '描述您想要生成的电影级视频画面、光影运镜与角色动作...'
}
className="w-full bg-slate-50 dark:bg-zinc-950/70 border border-slate-200 dark:border-zinc-800 focus:bg-white focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/20 rounded-xl p-3.5 text-sm text-slate-900 dark:text-zinc-100 placeholder-slate-400 dark:placeholder-zinc-500 outline-none transition-all resize-y min-h-[120px] leading-relaxed"
/>
</div>
{/* 媒体上传区 (非纯文本模式) */}
{tab === 'fl2va' && (
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl p-5 shadow-xs flex flex-col gap-5">
<MediaUploader
label="首帧图片 (必填)"
accept="image/*"
type="image"
value={firstFrame}
onChange={setFirstFrame}
hint="视频起始画面"
/>
<div className="border-t border-slate-100 dark:border-zinc-800/80 pt-4">
<MediaUploader
label="尾帧图片 (可选)"
accept="image/*"
type="image"
value={lastFrame}
onChange={setLastFrame}
hint="视频结束画面,不填则自由发散"
/>
</div>
</div>
)}
{tab === 'ref2va' && (
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl p-5 shadow-xs flex flex-col gap-5">
<MediaUploader
label="参考图片 (可多选)"
accept="image/*"
type="image"
multiple={true}
value={refImages}
onChange={setRefImages}
hint="角色/场景设定图,无数量限制"
/>
<div className="border-t border-slate-100 dark:border-zinc-800/80 pt-4">
<MediaUploader
label="参考视频 (可选 · 最多3段)"
accept="video/*"
type="video"
multiple={true}
maxCount={3}
value={refVideos}
onChange={setRefVideos}
hint="2-15秒动态参考,自动保留音轨"
/>
</div>
<div className="border-t border-slate-100 dark:border-zinc-800/80 pt-4">
<MediaUploader
label="独立音频参考 (可选 · 最多3首)"
accept="audio/*"
type="audio"
multiple={true}
maxCount={3}
value={refAudios}
onChange={setRefAudios}
hint="背景音乐或台词声音参考"
/>
</div>
</div>
)}
{/* 核心生成参数配置 */}
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl p-5 shadow-xs flex flex-col gap-5">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-zinc-300">
<Sliders className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
<span>生成参数与画质规格</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 分辨率 */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-slate-600 dark:text-zinc-400 font-medium">分辨率与画幅</label>
<select
value={resValue}
onChange={(e) => setResValue(e.target.value)}
className="bg-slate-50 dark:bg-zinc-950/80 border border-slate-200 dark:border-zinc-800 rounded-xl px-3 py-2.5 text-xs text-slate-800 dark:text-zinc-200 outline-none focus:border-emerald-500 cursor-pointer"
>
{RESOLUTIONS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
{/* 时长 */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-slate-600 dark:text-zinc-400 font-medium">生成时长 (帧数)</label>
<select
value={durValue}
onChange={(e) => setDurValue(e.target.value)}
className="bg-slate-50 dark:bg-zinc-950/80 border border-slate-200 dark:border-zinc-800 rounded-xl px-3 py-2.5 text-xs text-slate-800 dark:text-zinc-200 outline-none focus:border-emerald-500 cursor-pointer"
>
{allowedDurations.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
</div>
</div>
{/* 显存与时长安全提示 */}
{droppedDurations.length > 0 && (
<div className="flex items-center gap-2 p-2.5 bg-amber-50 border border-amber-200 dark:bg-amber-950/30 dark:border-amber-600/30 rounded-xl text-xs text-amber-800 dark:text-amber-300">
<AlertCircle className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span>
{currentRes.label} 实测上限 {currentRes.maxFrames} 帧,已自动过滤超出显存承受的超长档位。
</span>
</div>
)}
{/* Turbo 模式与步数联动 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 border-t border-slate-100 dark:border-zinc-800/80 items-center">
{/* Turbo Toggle */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-slate-600 dark:text-zinc-400 font-medium">Turbo 加速 LoRA</label>
<div className="flex p-1 bg-slate-100 dark:bg-zinc-950/80 border border-slate-200 dark:border-zinc-800 rounded-xl">
<button
type="button"
onClick={() => setTurbo(false)}
className={`flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors cursor-pointer ${
!turbo ? 'bg-white dark:bg-zinc-800 text-slate-800 dark:text-white shadow-xs' : 'text-slate-500 dark:text-zinc-400 hover:text-slate-800'
}`}
>
关 (稳健多步)
</button>
<button
type="button"
onClick={() => setTurbo(true)}
className={`flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors cursor-pointer ${
turbo ? 'bg-emerald-600 text-white shadow-xs' : 'text-slate-500 dark:text-zinc-400 hover:text-slate-800'
}`}
>
开 (4步极速)
</button>
</div>
</div>
{/* 采样步数 */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-slate-600 dark:text-zinc-400 font-medium">采样步数 (Steps)</label>
<select
value={steps}
onChange={(e) => setSteps(e.target.value)}
className="bg-slate-50 dark:bg-zinc-950/80 border border-slate-200 dark:border-zinc-800 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-zinc-200 outline-none focus:border-emerald-500 cursor-pointer"
>
{stepOptions.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
{/* 随机种子 */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-slate-600 dark:text-zinc-400 font-medium">随机种子 (Seed)</label>
<select
value={seed}
onChange={(e) => setSeed(e.target.value)}
className="bg-slate-50 dark:bg-zinc-950/80 border border-slate-200 dark:border-zinc-800 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-zinc-200 outline-none focus:border-emerald-500 cursor-pointer"
>
{SEED_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
</div>
{/* 高级参数折叠面板 */}
<div className="pt-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-slate-600 dark:text-zinc-400 hover:text-slate-900 dark:hover:text-zinc-200 transition-colors cursor-pointer"
>
<span>高级调参 (CFG / 负面提示词)</span>
{showAdvanced ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
{showAdvanced && (
<div className="mt-3 p-4 bg-slate-50 dark:bg-zinc-950/50 border border-slate-200 dark:border-zinc-800/80 rounded-xl flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-slate-600 dark:text-zinc-400">CFG Scale (留空默认 1.0)</label>
<input
type="number"
step="0.1"
min="0"
placeholder="留空 = 1.0 (蒸馏 LoRA 调太高易出伪影)"
value={cfgScale}
onChange={(e) => setCfgScale(e.target.value)}
className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-zinc-200 outline-none focus:border-emerald-500"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-slate-600 dark:text-zinc-400">负面提示词 Negative Prompt</label>
<textarea
rows={2}
placeholder="例如:模糊,低画质,肢体变形,拖影,五官扭曲"
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-xl p-2.5 text-xs text-slate-800 dark:text-zinc-200 outline-none focus:border-emerald-500 resize-none"
/>
</div>
</div>
)}
</div>
</div>
{/* 错误提示 */}
{err && (
<div className="p-3 bg-rose-50 border border-rose-200 dark:bg-rose-950/60 dark:border-rose-500/40 rounded-xl text-xs text-rose-700 dark:text-rose-300 flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-500" />
<span>{err}</span>
</div>
)}
{/* 操作按钮栏 */}
<div className="flex items-center justify-end gap-3 pb-8">
<button
type="button"
onClick={handleClear}
className="px-5 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-slate-700 dark:text-zinc-300 text-xs font-medium rounded-xl transition-colors cursor-pointer border border-slate-200 dark:border-zinc-700"
>
清空表单
</button>
<button
type="submit"
disabled={submitting}
className="px-7 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold rounded-xl transition-all shadow-md shadow-emerald-600/30 flex items-center gap-2 cursor-pointer disabled:opacity-50"
>
{submitting ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
<span>{submitting ? '正在提交…' : '立即生成视频'}</span>
</button>
</div>
</form>
</div>
);
}