FALMOVIE / server.js
Logankunfall's picture
Upload 8 files
337ee27 verified
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;