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>
  );
}