wapadil Claude commited on
Commit
3714bc5
·
1 Parent(s): bac962d

feat(video): add WAN 2.2/2.5 T2V/I2V with queue + status polling

Browse files

新增视频生成功能,接入 WAN 2.5 I2V 和 WAN 2.2 T2V/I2V 模型:

【后端改造】
- /api/generate 扩展视频参数透传(resolution/duration/fps/num_frames/negative_prompt等)
- WAN 2.2 暴露 enable_safety_checker 开关(默认 true)
- WAN 2.5 不提供安全开关(平台策略决定)
- 保持现有异步队列范式与轮询机制

【前端功能】
- 模型选择新增「🎬 视频生成」分组:
* WAN 2.5 - Image to Video (480p/720p/1080p, 5s/10s)
* WAN 2.2 - Image to Video (A14B) (480p/580p/720p, fps/frames)
* WAN 2.2 - Text to Video (A14B)
- 动态参数面板:根据模型显示对应参数
- 价格预估:
* WAN 2.5: 按分辨率 × 时长(0.05/0.10/0.15 $/s)
* WAN 2.2: 按 16FPS 计费秒数 × 费率(0.04/0.06/0.08 $/s)
- 视频结果展示:<video> 标签 + MP4 下载按钮
- I2V 模式:首帧图像上传并作为 image_url 传递
- T2V 模式:仅需 prompt

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +27 -1
  2. static/script.js +228 -20
  3. templates/index.html +88 -6
app.py CHANGED
@@ -282,13 +282,39 @@ def generate():
282
  if 'max_images' in data:
283
  fal_arguments['max_images'] = data['max_images']
284
 
285
- # Add common optional parameters
286
  if 'image_size' in data:
287
  fal_arguments['image_size'] = data['image_size']
288
  if 'num_images' in data:
289
  fal_arguments['num_images'] = data['num_images']
290
  if 'seed' in data:
291
  fal_arguments['seed'] = data['seed']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  if 'enable_safety_checker' in data:
293
  fal_arguments['enable_safety_checker'] = data['enable_safety_checker']
294
 
 
282
  if 'max_images' in data:
283
  fal_arguments['max_images'] = data['max_images']
284
 
285
+ # Add common optional parameters (images)
286
  if 'image_size' in data:
287
  fal_arguments['image_size'] = data['image_size']
288
  if 'num_images' in data:
289
  fal_arguments['num_images'] = data['num_images']
290
  if 'seed' in data:
291
  fal_arguments['seed'] = data['seed']
292
+
293
+ # Video-related passthrough (WAN 2.2 / 2.5)
294
+ for key in [
295
+ 'image_url', # I2V: single first frame
296
+ 'resolution', # e.g. "480p" | "580p" | "720p" | "1080p"
297
+ 'duration', # "5" | "10" (WAN 2.5)
298
+ 'frames_per_second', # (WAN 2.2)
299
+ 'num_frames', # (WAN 2.2)
300
+ 'negative_prompt',
301
+ 'video_quality', # (WAN 2.2)
302
+ 'video_write_mode', # (WAN 2.2)
303
+ 'acceleration', # (WAN 2.2)
304
+ 'guidance_scale', # (WAN 2.2)
305
+ 'guidance_scale_2', # (WAN 2.2)
306
+ 'interpolator_model', # (WAN 2.2)
307
+ 'num_interpolated_frames',# (WAN 2.2)
308
+ 'adjust_fps_for_interpolation', # (WAN 2.2)
309
+ 'aspect_ratio', # (WAN 2.2)
310
+ 'end_image_url', # (WAN 2.2 optional)
311
+ 'audio_url', # (WAN 2.5 optional: audio track)
312
+ 'enable_prompt_expansion' # both 2.5 and 2.2 (2.5 defaults to true)
313
+ ]:
314
+ if key in data:
315
+ fal_arguments[key] = data[key]
316
+
317
+ # WAN 2.2 exclusive safety switch: passthrough if provided (for uncensored use false)
318
  if 'enable_safety_checker' in data:
319
  fal_arguments['enable_safety_checker'] = data['enable_safety_checker']
320
 
static/script.js CHANGED
@@ -320,20 +320,77 @@ function handleImageSizeChange() {
320
 
321
  // Handle model dropdown change
322
  function handleModelChange() {
323
- const isTextToImage = modelSelect.value === 'fal-ai/bytedance/seedream/v4/text-to-image';
324
-
325
- if (isTextToImage) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  promptTitle.textContent = '生成提示词';
327
  promptLabel.textContent = '提示词';
328
  document.getElementById('prompt').placeholder = '例如:美丽的山水风景,湖泊和夕阳';
329
  imageInputCard.style.display = 'none';
330
  uploadedImages = [];
331
  renderImagePreviews();
 
332
  } else {
333
  promptTitle.textContent = '编辑指令';
334
  promptLabel.textContent = '编辑提示词';
335
  document.getElementById('prompt').placeholder = '例如:给模特穿上衣服和鞋子';
336
  imageInputCard.style.display = 'block';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  }
338
  }
339
 
@@ -1080,7 +1137,9 @@ async function generateEdit() {
1080
 
1081
  const selectedModel = modelSelect.value;
1082
  const isTextToImage = selectedModel === 'fal-ai/bytedance/seedream/v4/text-to-image';
1083
-
 
 
1084
  // Prepare upload progress UI early if there are base64 uploads
1085
  const base64Images = uploadedImages.filter(img => img.src.startsWith('data:'));
1086
  const totalUploads = base64Images.length;
@@ -1121,9 +1180,15 @@ async function generateEdit() {
1121
  showStatus(`上传错误: ${error.message || error}`, 'error');
1122
  return;
1123
  }
1124
- if (!isTextToImage && imageUrlsArray.length === 0) {
 
1125
  showStatus('请上传图像或提供图像URL进行图像编辑', 'error');
1126
- // Clean up any progress UI if present
 
 
 
 
 
1127
  const pc = document.getElementById('uploadProgressContainer');
1128
  if (pc && pc.parentNode) pc.parentNode.removeChild(pc);
1129
  return;
@@ -1162,18 +1227,28 @@ async function generateEdit() {
1162
  }
1163
 
1164
  const requestData = {
1165
- prompt: prompt,
1166
- image_size: getImageSize(),
1167
- num_images: parseInt(document.getElementById('numImages').value),
1168
- enable_safety_checker: false
1169
  };
1170
-
1171
- if (!isTextToImage) {
1172
- // Note: imageUrlsArray will now contain FAL URLs after upload
1173
- requestData.image_urls = imageUrlsArray;
1174
- requestData.max_images = parseInt(document.getElementById('maxImages').value);
 
 
 
 
 
 
 
 
 
 
 
 
 
1175
  }
1176
-
1177
  const seed = document.getElementById('seed').value;
1178
  if (seed) {
1179
  requestData.seed = parseInt(seed);
@@ -1378,13 +1453,46 @@ async function callFalAPI(apiKey, requestData, model, signal) {
1378
 
1379
  // Display current results
1380
  function displayCurrentResults(response) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1381
  if (!response || !response.images || response.images.length === 0) {
1382
  currentResults.innerHTML = '<div class="empty-state"><p>未生成图像</p></div>';
1383
  return;
1384
  }
1385
-
1386
  currentResults.innerHTML = '';
1387
-
1388
  response.images.forEach((image, index) => {
1389
  const imgSrc = image.url || image.file_data || '';
1390
  const imageId = `current-img-${Date.now()}-${index}`;
@@ -1408,13 +1516,13 @@ function displayCurrentResults(response) {
1408
  `;
1409
  currentResults.appendChild(item);
1410
  });
1411
-
1412
  // Display generation info
1413
  if (response.seed) {
1414
  currentInfo.innerHTML = `<strong>随机种子:</strong> ${response.seed}`;
1415
  addLog(`使用的随机种子: ${response.seed}`);
1416
  }
1417
-
1418
  addLog(`已生成 ${response.images.length} 张图像`);
1419
  }
1420
 
@@ -2675,3 +2783,103 @@ document.addEventListener('keydown', function(e) {
2675
  closeSettingsModal();
2676
  }
2677
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  // Handle model dropdown change
322
  function handleModelChange() {
323
+ const modelValue = modelSelect.value;
324
+ const isTextToImage = modelValue === 'fal-ai/bytedance/seedream/v4/text-to-image';
325
+ const isVideoModel = modelValue.includes('wan-25-preview') || modelValue.includes('wan/v2.2');
326
+ const isWan25 = modelValue.includes('wan-25-preview');
327
+ const isWan22 = modelValue.includes('wan/v2.2');
328
+ const isT2V = modelValue.includes('text-to-video');
329
+
330
+ // Toggle video params visibility
331
+ const videoParams = document.getElementById('videoParams');
332
+ const settingsGrid = document.querySelector('.settings-grid');
333
+ if (videoParams) {
334
+ videoParams.style.display = isVideoModel ? 'block' : 'none';
335
+ }
336
+ if (settingsGrid) {
337
+ settingsGrid.style.display = isVideoModel ? 'none' : 'grid';
338
+ }
339
+
340
+ // Show/hide WAN-specific params
341
+ const wan25Params = document.querySelectorAll('.video-param-wan25');
342
+ const wan22Params = document.querySelectorAll('.video-param-wan22');
343
+ wan25Params.forEach(el => el.style.display = isWan25 ? 'block' : 'none');
344
+ wan22Params.forEach(el => el.style.display = isWan22 ? 'block' : 'none');
345
+
346
+ // Update UI labels based on model type
347
+ if (isVideoModel) {
348
+ if (isT2V) {
349
+ promptTitle.textContent = '✏️ 视频提示词';
350
+ promptLabel.textContent = '提示词';
351
+ document.getElementById('prompt').placeholder = '例如:moody cyberpunk alley, steady cam forward, rain reflections';
352
+ imageInputCard.style.display = 'none';
353
+ uploadedImages = [];
354
+ renderImagePreviews();
355
+ } else {
356
+ promptTitle.textContent = '✏️ 视频提示词';
357
+ promptLabel.textContent = '提示词';
358
+ document.getElementById('prompt').placeholder = '例如:cinematic slow push-in on the subject, volumetric light beams';
359
+ imageInputCard.style.display = 'block';
360
+ }
361
+ document.getElementById('generateBtn').querySelector('.btn-text').textContent = '生成视频';
362
+ updateVideoPriceEstimate();
363
+ } else if (isTextToImage) {
364
  promptTitle.textContent = '生成提示词';
365
  promptLabel.textContent = '提示词';
366
  document.getElementById('prompt').placeholder = '例如:美丽的山水风景,湖泊和夕阳';
367
  imageInputCard.style.display = 'none';
368
  uploadedImages = [];
369
  renderImagePreviews();
370
+ document.getElementById('generateBtn').querySelector('.btn-text').textContent = '生成图像';
371
  } else {
372
  promptTitle.textContent = '编辑指令';
373
  promptLabel.textContent = '编辑提示词';
374
  document.getElementById('prompt').placeholder = '例如:给模特穿上衣服和鞋子';
375
  imageInputCard.style.display = 'block';
376
+ document.getElementById('generateBtn').querySelector('.btn-text').textContent = '生成图像';
377
+ }
378
+
379
+ // Add event listeners for video params to update price
380
+ if (isVideoModel) {
381
+ const videoResolution = document.getElementById('videoResolution');
382
+ const videoDuration = document.getElementById('videoDuration');
383
+ const videoFPS = document.getElementById('videoFPS');
384
+ const videoNumFrames = document.getElementById('videoNumFrames');
385
+
386
+ [videoResolution, videoDuration, videoFPS, videoNumFrames].forEach(el => {
387
+ if (el) {
388
+ el.removeEventListener('change', updateVideoPriceEstimate);
389
+ el.removeEventListener('input', updateVideoPriceEstimate);
390
+ el.addEventListener('change', updateVideoPriceEstimate);
391
+ el.addEventListener('input', updateVideoPriceEstimate);
392
+ }
393
+ });
394
  }
395
  }
396
 
 
1137
 
1138
  const selectedModel = modelSelect.value;
1139
  const isTextToImage = selectedModel === 'fal-ai/bytedance/seedream/v4/text-to-image';
1140
+ const isVideo = isVideoModel(selectedModel);
1141
+ const isVideoI2V = isVideo && !selectedModel.includes('text-to-video');
1142
+
1143
  // Prepare upload progress UI early if there are base64 uploads
1144
  const base64Images = uploadedImages.filter(img => img.src.startsWith('data:'));
1145
  const totalUploads = base64Images.length;
 
1180
  showStatus(`上传错误: ${error.message || error}`, 'error');
1181
  return;
1182
  }
1183
+ // Check image requirements based on model type
1184
+ if (!isTextToImage && !isVideo && imageUrlsArray.length === 0) {
1185
  showStatus('请上传图像或提供图像URL进行图像编辑', 'error');
1186
+ const pc = document.getElementById('uploadProgressContainer');
1187
+ if (pc && pc.parentNode) pc.parentNode.removeChild(pc);
1188
+ return;
1189
+ }
1190
+ if (isVideoI2V && imageUrlsArray.length === 0) {
1191
+ showStatus('视频 I2V 模式需要上传首帧图像', 'error');
1192
  const pc = document.getElementById('uploadProgressContainer');
1193
  if (pc && pc.parentNode) pc.parentNode.removeChild(pc);
1194
  return;
 
1227
  }
1228
 
1229
  const requestData = {
1230
+ prompt: prompt
 
 
 
1231
  };
1232
+
1233
+ if (isVideo) {
1234
+ // Video generation parameters
1235
+ if (isVideoI2V && imageUrlsArray.length > 0) {
1236
+ requestData.image_url = imageUrlsArray[0]; // Use first image as frame
1237
+ }
1238
+ // Add video-specific params from buildVideoParams()
1239
+ Object.assign(requestData, buildVideoParams());
1240
+ } else {
1241
+ // Image generation parameters
1242
+ requestData.image_size = getImageSize();
1243
+ requestData.num_images = parseInt(document.getElementById('numImages').value);
1244
+ requestData.enable_safety_checker = false;
1245
+
1246
+ if (!isTextToImage) {
1247
+ requestData.image_urls = imageUrlsArray;
1248
+ requestData.max_images = parseInt(document.getElementById('maxImages').value);
1249
+ }
1250
  }
1251
+
1252
  const seed = document.getElementById('seed').value;
1253
  if (seed) {
1254
  requestData.seed = parseInt(seed);
 
1453
 
1454
  // Display current results
1455
  function displayCurrentResults(response) {
1456
+ // Handle video results
1457
+ if (response && response.video) {
1458
+ currentResults.innerHTML = '';
1459
+ const videoUrl = response.video.url || '';
1460
+ const videoId = `current-video-${Date.now()}`;
1461
+
1462
+ const item = document.createElement('div');
1463
+ item.className = 'generation-item video-item';
1464
+ item.innerHTML = `
1465
+ <video id="${videoId}" controls style="width: 100%; border-radius: var(--radius-md);">
1466
+ <source src="${videoUrl}" type="video/mp4">
1467
+ 您的浏览器不支持视频播放。
1468
+ </video>
1469
+ <div class="generation-footer">
1470
+ <div class="generation-timestamp">刚刚生成</div>
1471
+ <div class="generation-actions-bar">
1472
+ <button class="action-icon" onclick="downloadVideo('${videoUrl}', 'video'); event.stopPropagation();" title="下载视频">
1473
+ ⬇️ MP4
1474
+ </button>
1475
+ </div>
1476
+ </div>
1477
+ `;
1478
+ currentResults.appendChild(item);
1479
+
1480
+ if (response.seed) {
1481
+ currentInfo.innerHTML = `<strong>随机种子:</strong> ${response.seed}`;
1482
+ addLog(`使用的随机种子: ${response.seed}`);
1483
+ }
1484
+ addLog('视频生成完成');
1485
+ return;
1486
+ }
1487
+
1488
+ // Handle image results
1489
  if (!response || !response.images || response.images.length === 0) {
1490
  currentResults.innerHTML = '<div class="empty-state"><p>未生成图像</p></div>';
1491
  return;
1492
  }
1493
+
1494
  currentResults.innerHTML = '';
1495
+
1496
  response.images.forEach((image, index) => {
1497
  const imgSrc = image.url || image.file_data || '';
1498
  const imageId = `current-img-${Date.now()}-${index}`;
 
1516
  `;
1517
  currentResults.appendChild(item);
1518
  });
1519
+
1520
  // Display generation info
1521
  if (response.seed) {
1522
  currentInfo.innerHTML = `<strong>随机种子:</strong> ${response.seed}`;
1523
  addLog(`使用的随机种子: ${response.seed}`);
1524
  }
1525
+
1526
  addLog(`已生成 ${response.images.length} 张图像`);
1527
  }
1528
 
 
2783
  closeSettingsModal();
2784
  }
2785
  });
2786
+
2787
+ // ============================= //
2788
+ // Video Generation Support (WAN 2.2 / 2.5) //
2789
+ // ============================= //
2790
+
2791
+ function updateVideoPriceEstimate() {
2792
+ const modelValue = modelSelect.value;
2793
+ const priceValueEl = document.getElementById('videoPriceValue');
2794
+ if (!priceValueEl) return;
2795
+
2796
+ const isWan25 = modelValue.includes('wan-25-preview');
2797
+ const isWan22 = modelValue.includes('wan/v2.2');
2798
+
2799
+ if (isWan25) {
2800
+ // WAN 2.5 pricing: based on resolution and duration
2801
+ const resolution = document.getElementById('videoResolution')?.value || '1080p';
2802
+ const duration = parseInt(document.getElementById('videoDuration')?.value || '5');
2803
+
2804
+ const resolutionPrices = {
2805
+ '480p': 0.05,
2806
+ '720p': 0.10,
2807
+ '1080p': 0.15
2808
+ };
2809
+
2810
+ const pricePerSecond = resolutionPrices[resolution] || 0.10;
2811
+ const totalPrice = (pricePerSecond * duration).toFixed(2);
2812
+ priceValueEl.textContent = `$${totalPrice} (${resolution} × ${duration}s)`;
2813
+
2814
+ } else if (isWan22) {
2815
+ // WAN 2.2 pricing: based on video seconds calculated from frames/fps
2816
+ // FAL uses 16 FPS for billing calculation
2817
+ const numFrames = parseInt(document.getElementById('videoNumFrames')?.value || 81);
2818
+ const fps = parseInt(document.getElementById('videoFPS')?.value || 16);
2819
+ const resolution = document.getElementById('videoResolution')?.value || '720p';
2820
+
2821
+ const videoSeconds = numFrames / fps;
2822
+ const billingSeconds = numFrames / 16; // FAL uses 16 FPS for billing
2823
+
2824
+ const resolutionRates = {
2825
+ '480p': 0.04,
2826
+ '580p': 0.06,
2827
+ '720p': 0.08
2828
+ };
2829
+
2830
+ const rate = resolutionRates[resolution] || 0.06;
2831
+ const totalPrice = (rate * billingSeconds).toFixed(2);
2832
+ priceValueEl.textContent = `$${totalPrice} (${resolution}, ~${videoSeconds.toFixed(1)}s 实际 / ${billingSeconds.toFixed(1)}s 计费@16FPS)`;
2833
+ }
2834
+ }
2835
+
2836
+ function isVideoModel(modelValue) {
2837
+ return modelValue.includes('wan-25-preview') || modelValue.includes('wan/v2.2');
2838
+ }
2839
+
2840
+ function buildVideoParams() {
2841
+ const modelValue = modelSelect.value;
2842
+ const isWan25 = modelValue.includes('wan-25-preview');
2843
+ const isWan22 = modelValue.includes('wan/v2.2');
2844
+ const params = {};
2845
+
2846
+ // Common video params
2847
+ const resolution = document.getElementById('videoResolution')?.value;
2848
+ if (resolution) params.resolution = resolution;
2849
+
2850
+ const negativePrompt = document.getElementById('videoNegativePrompt')?.value;
2851
+ if (negativePrompt) params.negative_prompt = negativePrompt;
2852
+
2853
+ if (isWan25) {
2854
+ // WAN 2.5 specific params
2855
+ const duration = document.getElementById('videoDuration')?.value;
2856
+ if (duration) params.duration = duration;
2857
+
2858
+ const audioUrl = document.getElementById('videoAudioUrl')?.value;
2859
+ if (audioUrl) params.audio_url = audioUrl;
2860
+
2861
+ params.enable_prompt_expansion = true; // WAN 2.5 defaults to true
2862
+ } else if (isWan22) {
2863
+ // WAN 2.2 specific params
2864
+ const fps = document.getElementById('videoFPS')?.value;
2865
+ if (fps) params.frames_per_second = parseInt(fps);
2866
+
2867
+ const numFrames = document.getElementById('videoNumFrames')?.value;
2868
+ if (numFrames) params.num_frames = parseInt(numFrames);
2869
+
2870
+ const safetyChecker = document.getElementById('videoSafetyChecker')?.checked;
2871
+ params.enable_safety_checker = safetyChecker !== undefined ? safetyChecker : true;
2872
+ }
2873
+
2874
+ return params;
2875
+ }
2876
+
2877
+ function downloadVideo(videoUrl, filename) {
2878
+ const link = document.createElement('a');
2879
+ link.href = videoUrl;
2880
+ link.download = `${filename || 'video'}-${Date.now()}.mp4`;
2881
+ document.body.appendChild(link);
2882
+ link.click();
2883
+ document.body.removeChild(link);
2884
+ showToast('视频下载已开始', 'success', 2000);
2885
+ }
templates/index.html CHANGED
@@ -54,9 +54,16 @@
54
  <div class="form-group">
55
  <label for="drawerModelSelect">模型选择</label>
56
  <select id="drawerModelSelect" onchange="syncModelSelection()">
57
- <option value="fal-ai/bytedance/seedream/v4/edit">图像编辑</option>
58
- <option value="fal-ai/qwen-image-edit-plus">图像编辑 (通义千问)</option>
59
- <option value="fal-ai/bytedance/seedream/v4/text-to-image">文本生成图像</option>
 
 
 
 
 
 
 
60
  </select>
61
  </div>
62
  <div class="form-group">
@@ -283,6 +290,74 @@
283
  <!-- Safety checker is disabled by default and hidden from UI -->
284
  <input type="hidden" id="safetyChecker" value="false" />
285
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  </div>
287
  </div>
288
 
@@ -396,9 +471,16 @@
396
  <div class="form-group">
397
  <label for="modelSelect">模型选择</label>
398
  <select id="modelSelect">
399
- <option value="fal-ai/bytedance/seedream/v4/edit">图像编辑</option>
400
- <option value="fal-ai/qwen-image-edit-plus">图像编辑 (通义千问)</option>
401
- <option value="fal-ai/bytedance/seedream/v4/text-to-image">文本生成图像</option>
 
 
 
 
 
 
 
402
  </select>
403
  <small class="help-text">选择用于生成的模型</small>
404
  </div>
 
54
  <div class="form-group">
55
  <label for="drawerModelSelect">模型选择</label>
56
  <select id="drawerModelSelect" onchange="syncModelSelection()">
57
+ <optgroup label="📷 图像生成">
58
+ <option value="fal-ai/bytedance/seedream/v4/edit">图像编辑</option>
59
+ <option value="fal-ai/qwen-image-edit-plus">图像编辑 (通义千问)</option>
60
+ <option value="fal-ai/bytedance/seedream/v4/text-to-image">文本生成图像</option>
61
+ </optgroup>
62
+ <optgroup label="🎬 视频生成">
63
+ <option value="fal-ai/wan-25-preview/image-to-video">WAN 2.5 - Image to Video</option>
64
+ <option value="fal-ai/wan/v2.2-a14b/image-to-video">WAN 2.2 - Image to Video (A14B)</option>
65
+ <option value="fal-ai/wan/v2.2-a14b/text-to-video">WAN 2.2 - Text to Video (A14B)</option>
66
+ </optgroup>
67
  </select>
68
  </div>
69
  <div class="form-group">
 
290
  <!-- Safety checker is disabled by default and hidden from UI -->
291
  <input type="hidden" id="safetyChecker" value="false" />
292
  </div>
293
+
294
+ <!-- Video-specific parameters (WAN 2.2 / 2.5) -->
295
+ <div class="video-params" id="videoParams" style="display: none;">
296
+ <h3 style="margin-top: var(--spacing-4); margin-bottom: var(--spacing-3); color: var(--text-secondary); font-size: 0.9rem;">视频参数</h3>
297
+
298
+ <div class="form-group">
299
+ <label for="videoResolution">分辨率</label>
300
+ <select id="videoResolution">
301
+ <option value="1080p">1080p (WAN 2.5)</option>
302
+ <option value="720p" selected>720p</option>
303
+ <option value="580p">580p (WAN 2.2)</option>
304
+ <option value="480p">480p</option>
305
+ </select>
306
+ </div>
307
+
308
+ <div class="form-group video-param-wan25" style="display: none;">
309
+ <label for="videoDuration">时长</label>
310
+ <select id="videoDuration">
311
+ <option value="5" selected>5秒</option>
312
+ <option value="10">10秒</option>
313
+ </select>
314
+ </div>
315
+
316
+ <div class="form-group video-param-wan22" style="display: none;">
317
+ <label for="videoFPS">帧率 (FPS)</label>
318
+ <input type="number" id="videoFPS" min="4" max="60" value="16" inputmode="numeric" step="1" />
319
+ <small class="help-text">4-60,默认16</small>
320
+ </div>
321
+
322
+ <div class="form-group video-param-wan22" style="display: none;">
323
+ <label for="videoNumFrames">帧数</label>
324
+ <input type="number" id="videoNumFrames" min="17" max="161" value="81" inputmode="numeric" step="1" />
325
+ <small class="help-text">17-161,默认81。视频秒数 = 帧数 / 帧率</small>
326
+ </div>
327
+
328
+ <div class="form-group">
329
+ <label for="videoNegativePrompt">负向提示词</label>
330
+ <textarea id="videoNegativePrompt" rows="2" placeholder="描述不想要的内容..."></textarea>
331
+ </div>
332
+
333
+ <div class="form-group video-param-wan25" style="display: none;">
334
+ <label for="videoAudioUrl">音频URL (可选)</label>
335
+ <input type="text" id="videoAudioUrl" placeholder="https://your.audio/file.mp3" />
336
+ <small class="help-text">MP3/WAV,≤15MB,3-30秒;过长会被截断</small>
337
+ </div>
338
+
339
+ <div class="form-group video-param-wan22" style="display: none;">
340
+ <div class="checkbox-group">
341
+ <input type="checkbox" id="videoSafetyChecker" checked />
342
+ <label for="videoSafetyChecker">启用安全检查</label>
343
+ </div>
344
+ <small class="help-text">取消勾选可能产生平台不允许的输出</small>
345
+ </div>
346
+
347
+ <div class="video-param-wan25" style="display: none; margin-top: var(--spacing-3); padding: var(--spacing-2); background: var(--surface-secondary); border-radius: var(--radius-sm);">
348
+ <small style="color: var(--text-tertiary);">
349
+ ℹ️ WAN 2.5 接口不提供安全检查开关,是否筛查由平台策略决定
350
+ </small>
351
+ </div>
352
+
353
+ <div class="form-group">
354
+ <div id="videoPriceEstimate" style="margin-top: var(--spacing-2); padding: var(--spacing-2); background: var(--surface-tertiary); border-radius: var(--radius-sm);">
355
+ <small style="color: var(--text-secondary);">
356
+ 💰 预估价格:<span id="videoPriceValue">--</span>
357
+ </small>
358
+ </div>
359
+ </div>
360
+ </div>
361
  </div>
362
  </div>
363
 
 
471
  <div class="form-group">
472
  <label for="modelSelect">模型选择</label>
473
  <select id="modelSelect">
474
+ <optgroup label="📷 图像生成">
475
+ <option value="fal-ai/bytedance/seedream/v4/edit">图像编辑</option>
476
+ <option value="fal-ai/qwen-image-edit-plus">图像编辑 (通义千问)</option>
477
+ <option value="fal-ai/bytedance/seedream/v4/text-to-image">文本生成图像</option>
478
+ </optgroup>
479
+ <optgroup label="🎬 视频生成">
480
+ <option value="fal-ai/wan-25-preview/image-to-video">WAN 2.5 - Image to Video</option>
481
+ <option value="fal-ai/wan/v2.2-a14b/image-to-video">WAN 2.2 - Image to Video (A14B)</option>
482
+ <option value="fal-ai/wan/v2.2-a14b/text-to-video">WAN 2.2 - Text to Video (A14B)</option>
483
+ </optgroup>
484
  </select>
485
  <small class="help-text">选择用于生成的模型</small>
486
  </div>