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>
- app.py +27 -1
- static/script.js +228 -20
- 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
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 1125 |
showStatus('请上传图像或提供图像URL进行图像编辑', 'error');
|
| 1126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 1172 |
-
//
|
| 1173 |
-
|
| 1174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 400 |
-
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|