Spaces:
Sleeping
Sleeping
| const express = require('express'); | |
| const cors = require('cors'); | |
| const multer = require('multer'); | |
| const path = require('path'); | |
| const fs = require('fs'); | |
| const https = require('https'); | |
| const http = require('http'); | |
| require('dotenv').config(); | |
| const app = express(); | |
| const PORT = process.env.PORT || 7860; | |
| // 中间件配置 | |
| app.use(cors()); | |
| app.use(express.json({ limit: '50mb' })); | |
| app.use(express.urlencoded({ extended: true, limit: '50mb' })); | |
| app.use(express.static(path.join(__dirname, 'public'))); | |
| // API key 存储文件路径 | |
| const API_KEY_FILE = path.join(__dirname, 'stored_api_key.json'); | |
| // 视频存储目录 | |
| const VIDEO_DIR = path.join(__dirname, 'video'); | |
| // 文件上传配置 | |
| const storage = multer.diskStorage({ | |
| destination: (req, file, cb) => { | |
| const uploadDir = path.join(__dirname, 'uploads'); | |
| if (!fs.existsSync(uploadDir)) { | |
| fs.mkdirSync(uploadDir, { recursive: true }); | |
| } | |
| cb(null, uploadDir); | |
| }, | |
| filename: (req, file, cb) => { | |
| cb(null, Date.now() + '-' + file.originalname); | |
| } | |
| }); | |
| const upload = multer({ | |
| storage: storage, | |
| limits: { fileSize: 50 * 1024 * 1024 } | |
| }); | |
| // 读取存储的 API key | |
| function getStoredApiKey() { | |
| try { | |
| if (fs.existsSync(API_KEY_FILE)) { | |
| const data = fs.readFileSync(API_KEY_FILE, 'utf8'); | |
| const parsed = JSON.parse(data); | |
| return parsed.apiKey || null; | |
| } | |
| } catch (error) { | |
| console.log('读取存储的 API key 失败:', error.message); | |
| } | |
| return null; | |
| } | |
| // 保存 API key | |
| function saveApiKey(apiKey) { | |
| try { | |
| const data = { apiKey, savedAt: new Date().toISOString() }; | |
| fs.writeFileSync(API_KEY_FILE, JSON.stringify(data, null, 2)); | |
| return true; | |
| } catch (error) { | |
| console.error('保存 API key 失败:', error); | |
| return false; | |
| } | |
| } | |
| // 获取有效的 API key | |
| function getValidApiKey(userApiKey = null) { | |
| // 优先级:用户提供的 > 环境变量 > 存储的 | |
| return userApiKey || process.env.FAL_KEY || getStoredApiKey(); | |
| } | |
| // 下载并保存视频文件 | |
| function downloadVideo(videoUrl, filename) { | |
| return new Promise((resolve, reject) => { | |
| // 确保视频目录存在 | |
| if (!fs.existsSync(VIDEO_DIR)) { | |
| fs.mkdirSync(VIDEO_DIR, { recursive: true }); | |
| } | |
| const filepath = path.join(VIDEO_DIR, filename); | |
| const file = fs.createWriteStream(filepath); | |
| const protocol = videoUrl.startsWith('https:') ? https : http; | |
| protocol.get(videoUrl, (response) => { | |
| if (response.statusCode !== 200) { | |
| reject(new Error(`下载失败,状态码: ${response.statusCode}`)); | |
| return; | |
| } | |
| response.pipe(file); | |
| file.on('finish', () => { | |
| file.close(); | |
| console.log(`视频已保存到: ${filepath}`); | |
| resolve(filepath); | |
| }); | |
| file.on('error', (err) => { | |
| fs.unlink(filepath, () => {}); // 删除不完整的文件 | |
| reject(err); | |
| }); | |
| }).on('error', (err) => { | |
| reject(err); | |
| }); | |
| }); | |
| } | |
| // 生成唯一的文件名 | |
| function generateVideoFilename(type = 'video') { | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); | |
| return `${type}-${timestamp}.mp4`; | |
| } | |
| // FAL AI 客户端初始化 | |
| let fal; | |
| try { | |
| fal = require('@fal-ai/client'); | |
| // 如果是对象形式的导入,尝试获取 fal 属性 | |
| if (fal && typeof fal === 'object' && fal.fal) { | |
| fal = fal.fal; | |
| } | |
| } catch (error) { | |
| console.error('FAL AI 客户端初始化失败:', error); | |
| } | |
| // 路由 | |
| app.get('/', (req, res) => { | |
| res.sendFile(path.join(__dirname, 'public', 'index.html')); | |
| }); | |
| // API key 管理接口 | |
| app.post('/api/save-key', (req, res) => { | |
| const { apiKey } = req.body; | |
| if (!apiKey || !apiKey.trim()) { | |
| return res.status(400).json({ error: 'API key 不能为空' }); | |
| } | |
| if (saveApiKey(apiKey.trim())) { | |
| res.json({ success: true, message: 'API key 保存成功' }); | |
| } else { | |
| res.status(500).json({ error: 'API key 保存失败' }); | |
| } | |
| }); | |
| app.get('/api/check-key', (req, res) => { | |
| const storedKey = getStoredApiKey(); | |
| const hasEnvKey = !!process.env.FAL_KEY; | |
| res.json({ | |
| hasStoredKey: !!storedKey, | |
| hasEnvKey: hasEnvKey, | |
| keySource: hasEnvKey ? 'environment' : (storedKey ? 'stored' : 'none') | |
| }); | |
| }); | |
| // 图片到视频生成 API(支持模型切换:WAN v2.2-a14b 与 Bytedance Seedance 1.0 Pro Fast) | |
| app.post('/api/image-to-video', upload.single('image'), async (req, res) => { | |
| try { | |
| const userApiKey = req.body.userApiKey; | |
| const apiKey = getValidApiKey(userApiKey); | |
| if (!apiKey) { | |
| return res.status(400).json({ error: '请提供 FAL AI API 密钥' }); | |
| } | |
| const prompt = req.body.prompt; | |
| if (!prompt) { | |
| return res.status(400).json({ error: '请提供文本提示' }); | |
| } | |
| // 解析图片输入(文件或 URL) | |
| let image_url; | |
| if (req.file) { | |
| const file = fs.readFileSync(req.file.path); | |
| const blob = new Blob([file], { type: req.file.mimetype }); | |
| image_url = await fal.storage.upload(blob); | |
| } else if (req.body.image_url) { | |
| image_url = req.body.image_url; | |
| } else { | |
| return res.status(400).json({ error: '请提供图片文件或图片URL' }); | |
| } | |
| // 模型选择:默认 WAN,支持 seedance-pro-fast | |
| const model = req.body.model || 'wan-v2.2-a14b'; | |
| // 设置临时环境变量 | |
| const originalFalKey = process.env.FAL_KEY; | |
| process.env.FAL_KEY = apiKey; | |
| let result; | |
| if (model === 'seedance-pro-fast') { | |
| // Bytedance Seedance 1.0 Pro Fast 入参映射 | |
| const { | |
| aspect_ratio = 'auto', | |
| resolution = '1080p', | |
| duration = '5', | |
| camera_fixed = false, | |
| seed = -1, | |
| enable_safety_checker = true, | |
| } = req.body; | |
| result = await fal.subscribe("fal-ai/bytedance/seedance/v1/pro/fast/image-to-video", { | |
| input: { | |
| prompt, | |
| image_url, | |
| aspect_ratio, | |
| resolution, | |
| duration, | |
| camera_fixed: typeof camera_fixed === 'string' ? camera_fixed === 'true' : !!camera_fixed, | |
| seed: parseInt(seed, 10), | |
| enable_safety_checker: typeof enable_safety_checker === 'string' ? enable_safety_checker === 'true' : !!enable_safety_checker, | |
| }, | |
| logs: true, | |
| onQueueUpdate: (update) => { | |
| if (update.status === "IN_PROGRESS") { | |
| console.log('生成进度(Seedance I2V):', update.logs?.map(log => log.message).join('\n')); | |
| } | |
| }, | |
| }); | |
| } else { | |
| // WAN v2.2-a14b 入参映射(保持原逻辑) | |
| const { | |
| negative_prompt = "", | |
| num_frames = 81, | |
| frames_per_second = 16, | |
| resolution = "720p", | |
| aspect_ratio = "auto", | |
| video_quality = "high", | |
| enable_safety_checker = "true" | |
| } = req.body; | |
| result = await fal.subscribe("fal-ai/wan/v2.2-a14b/image-to-video", { | |
| input: { | |
| image_url, | |
| prompt, | |
| negative_prompt, | |
| num_frames: parseInt(num_frames), | |
| frames_per_second: parseInt(frames_per_second), | |
| resolution, | |
| aspect_ratio, | |
| video_quality, | |
| num_inference_steps: 27, | |
| enable_safety_checker: enable_safety_checker === "true", | |
| acceleration: "regular", | |
| guidance_scale: 3.5, | |
| shift: 5 | |
| }, | |
| logs: true, | |
| onQueueUpdate: (update) => { | |
| if (update.status === "IN_PROGRESS") { | |
| console.log('生成进度(WAN I2V):', update.logs?.map(log => log.message).join('\n')); | |
| } | |
| }, | |
| }); | |
| } | |
| // 恢复原始环境变量 | |
| if (originalFalKey) { | |
| process.env.FAL_KEY = originalFalKey; | |
| } | |
| // 下载并保存视频到本地 | |
| let localVideoPath = null; | |
| if (result.data && result.data.video && result.data.video.url) { | |
| try { | |
| const filename = generateVideoFilename('image-to-video'); | |
| localVideoPath = await downloadVideo(result.data.video.url, filename); | |
| console.log(`图片转视频完成,已保存到: ${localVideoPath}`); | |
| } catch (downloadError) { | |
| console.error('视频下载失败:', downloadError); | |
| // 不影响主要功能,继续返回结果 | |
| } | |
| } | |
| if (req.file && fs.existsSync(req.file.path)) { | |
| fs.unlinkSync(req.file.path); | |
| } | |
| res.json({ | |
| success: true, | |
| data: { | |
| ...result.data, | |
| localPath: localVideoPath ? path.relative(__dirname, localVideoPath) : null | |
| }, | |
| requestId: result.requestId | |
| }); | |
| } catch (error) { | |
| console.error('视频生成错误:', error); | |
| if (req.file && fs.existsSync(req.file.path)) { | |
| fs.unlinkSync(req.file.path); | |
| } | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message || '视频生成失败' | |
| }); | |
| } | |
| }); | |
| // 文本到视频生成 API(支持模型切换:WAN v2.2-a14b 与 Bytedance Seedance 1.0 Pro Fast) | |
| app.post('/api/text-to-video', async (req, res) => { | |
| try { | |
| const userApiKey = req.body.userApiKey; | |
| const apiKey = getValidApiKey(userApiKey); | |
| if (!apiKey) { | |
| return res.status(400).json({ error: '请提供 FAL AI API 密钥' }); | |
| } | |
| const prompt = req.body.prompt; | |
| if (!prompt) { | |
| return res.status(400).json({ error: '请提供文本提示' }); | |
| } | |
| // 模型选择:默认 WAN,支持 seedance-pro-fast | |
| const model = req.body.model || 'wan-v2.2-a14b'; | |
| // 设置临时环境变量 | |
| const originalFalKey = process.env.FAL_KEY; | |
| process.env.FAL_KEY = apiKey; | |
| let result; | |
| if (model === 'seedance-pro-fast') { | |
| // Bytedance Seedance 1.0 Pro Fast 入参映射 | |
| const { | |
| aspect_ratio = '16:9', | |
| resolution = '1080p', | |
| duration = '5', | |
| camera_fixed = false, | |
| seed = -1, | |
| enable_safety_checker = true, | |
| } = req.body; | |
| result = await fal.subscribe("fal-ai/bytedance/seedance/v1/pro/fast/text-to-video", { | |
| input: { | |
| prompt, | |
| aspect_ratio, | |
| resolution, | |
| duration, | |
| camera_fixed: !!camera_fixed, | |
| seed: parseInt(seed, 10), | |
| enable_safety_checker: !!enable_safety_checker, | |
| }, | |
| logs: true, | |
| onQueueUpdate: (update) => { | |
| if (update.status === "IN_PROGRESS") { | |
| console.log('生成进度(Seedance):', update.logs?.map(log => log.message).join('\n')); | |
| } | |
| }, | |
| }); | |
| } else { | |
| // WAN v2.2-a14b 入参映射(保持原逻辑) | |
| const { | |
| negative_prompt = "", | |
| num_frames = 81, | |
| frames_per_second = 16, | |
| resolution = "720p", | |
| aspect_ratio = "16:9", | |
| video_quality = "high", | |
| enable_safety_checker = true | |
| } = req.body; | |
| result = await fal.subscribe("fal-ai/wan/v2.2-a14b/text-to-video", { | |
| input: { | |
| prompt, | |
| negative_prompt, | |
| num_frames: parseInt(num_frames), | |
| frames_per_second: parseInt(frames_per_second), | |
| resolution, | |
| aspect_ratio, | |
| video_quality, | |
| num_inference_steps: 27, | |
| enable_safety_checker: enable_safety_checker, | |
| acceleration: "regular", | |
| guidance_scale: 3.5, | |
| shift: 5 | |
| }, | |
| logs: true, | |
| onQueueUpdate: (update) => { | |
| if (update.status === "IN_PROGRESS") { | |
| console.log('生成进度(WAN):', update.logs?.map(log => log.message).join('\n')); | |
| } | |
| }, | |
| }); | |
| } | |
| // 恢复原始环境变量 | |
| if (originalFalKey) { | |
| process.env.FAL_KEY = originalFalKey; | |
| } | |
| // 下载并保存视频到本地 | |
| let localVideoPath = null; | |
| if (result.data && result.data.video && result.data.video.url) { | |
| try { | |
| const filename = generateVideoFilename('text-to-video'); | |
| localVideoPath = await downloadVideo(result.data.video.url, filename); | |
| console.log(`文本转视频完成,已保存到: ${localVideoPath}`); | |
| } catch (downloadError) { | |
| console.error('视频下载失败:', downloadError); | |
| // 不影响主要功能,继续返回结果 | |
| } | |
| } | |
| res.json({ | |
| success: true, | |
| data: { | |
| ...result.data, | |
| localPath: localVideoPath ? path.relative(__dirname, localVideoPath) : null | |
| }, | |
| requestId: result.requestId | |
| }); | |
| } catch (error) { | |
| console.error('视频生成错误:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message || '视频生成失败' | |
| }); | |
| } | |
| }); | |
| // 获取本地视频列表 | |
| app.get('/api/videos', (req, res) => { | |
| try { | |
| if (!fs.existsSync(VIDEO_DIR)) { | |
| return res.json({ videos: [] }); | |
| } | |
| const files = fs.readdirSync(VIDEO_DIR) | |
| .filter(file => file.endsWith('.mp4')) | |
| .map(file => { | |
| const filepath = path.join(VIDEO_DIR, file); | |
| const stats = fs.statSync(filepath); | |
| return { | |
| filename: file, | |
| path: path.join('video', file), | |
| size: stats.size, | |
| created: stats.birthtime, | |
| modified: stats.mtime | |
| }; | |
| }) | |
| .sort((a, b) => new Date(b.created) - new Date(a.created)); // 按创建时间倒序 | |
| res.json({ videos: files }); | |
| } catch (error) { | |
| console.error('获取视频列表失败:', error); | |
| res.status(500).json({ error: '获取视频列表失败' }); | |
| } | |
| }); | |
| // 提供视频文件访问 | |
| app.use('/video', express.static(path.join(__dirname, 'video'))); | |
| // 测试 API 密钥连接(使用免费的健康检查端点) | |
| app.post('/api/test-key', async (req, res) => { | |
| try { | |
| const { apiKey } = req.body; | |
| const testKey = apiKey || getValidApiKey(); | |
| if (!testKey) { | |
| return res.status(400).json({ | |
| error: 'API 密钥未提供', | |
| success: false | |
| }); | |
| } | |
| // 使用 FAL AI 的免费健康检查端点测试连接 | |
| const https = require('https'); | |
| const testPromise = new Promise((resolve, reject) => { | |
| // 使用 FAL AI 的状态检查端点(不消耗配额) | |
| const options = { | |
| hostname: 'fal.run', | |
| port: 443, | |
| path: '/status', | |
| method: 'GET', | |
| headers: { | |
| 'Authorization': `Key ${testKey}`, | |
| 'User-Agent': 'FAL-Video-Generator/1.0' | |
| }, | |
| timeout: 5000 | |
| }; | |
| const req = https.request(options, (res) => { | |
| let data = ''; | |
| res.on('data', (chunk) => data += chunk); | |
| res.on('end', () => { | |
| if (res.statusCode === 401) { | |
| reject(new Error('Unauthorized - API 密钥无效')); | |
| } else if (res.statusCode === 403) { | |
| reject(new Error('Forbidden - API 密钥被禁用')); | |
| } else if (res.statusCode === 429) { | |
| reject(new Error('Rate Limited - 请求过于频繁')); | |
| } else if (res.statusCode >= 200 && res.statusCode < 300) { | |
| resolve({ statusCode: res.statusCode, data }); | |
| } else { | |
| // 其他状态码,但至少证明密钥被识别了 | |
| resolve({ statusCode: res.statusCode, data }); | |
| } | |
| }); | |
| }); | |
| req.on('error', (err) => { | |
| if (err.code === 'ENOTFOUND') { | |
| reject(new Error('Network - 无法连接到 FAL AI 服务器')); | |
| } else if (err.code === 'ECONNREFUSED') { | |
| reject(new Error('Connection - 连接被拒绝')); | |
| } else { | |
| reject(new Error(`Network - ${err.message}`)); | |
| } | |
| }); | |
| req.on('timeout', () => { | |
| req.destroy(); | |
| reject(new Error('Timeout - 连接超时')); | |
| }); | |
| req.end(); | |
| }); | |
| try { | |
| const result = await testPromise; | |
| res.json({ | |
| success: true, | |
| message: '✅ 连接成功!API 密钥有效', | |
| keyFormat: testKey.substring(0, 8) + '...' + testKey.substring(testKey.length - 4), | |
| statusCode: result.statusCode, | |
| note: '🆓 使用免费健康检查端点,不消耗配额' | |
| }); | |
| } catch (testError) { | |
| let errorMessage = '❌ 连接测试失败'; | |
| let errorType = 'error'; | |
| if (testError.message.includes('Unauthorized')) { | |
| errorMessage = '❌ API 密钥无效或已过期'; | |
| errorType = 'auth'; | |
| } else if (testError.message.includes('Forbidden')) { | |
| errorMessage = '❌ API 密钥被禁用'; | |
| errorType = 'auth'; | |
| } else if (testError.message.includes('Rate Limited')) { | |
| errorMessage = '⚠️ 请求过于频繁,请稍后重试'; | |
| errorType = 'rate'; | |
| } else if (testError.message.includes('Network')) { | |
| errorMessage = '🌐 网络连接问题'; | |
| errorType = 'network'; | |
| } else if (testError.message.includes('Timeout')) { | |
| errorMessage = '⏱️ 连接超时,请重试'; | |
| errorType = 'timeout'; | |
| } | |
| res.status(errorType === 'auth' ? 401 : 500).json({ | |
| success: false, | |
| error: errorMessage, | |
| errorType: errorType, | |
| details: testError.message | |
| }); | |
| } | |
| } catch (error) { | |
| console.error('API 密钥连接测试失败:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: '测试过程中发生错误', | |
| details: error.message | |
| }); | |
| } | |
| }); | |
| app.get('/api/health', (req, res) => { | |
| const apiKey = getValidApiKey(); | |
| res.json({ | |
| status: 'healthy', | |
| timestamp: new Date().toISOString(), | |
| hasApiKey: !!apiKey, | |
| keyPreview: apiKey ? apiKey.substring(0, 8) + '...' : null | |
| }); | |
| }); | |
| app.listen(PORT, '0.0.0.0', () => { | |
| console.log(`服务器运行在端口 ${PORT}`); | |
| const apiKey = getValidApiKey(); | |
| console.log(`API Key 状态: ${apiKey ? '已配置' : '未配置'}`); | |
| }); | |
| module.exports = app; |