const express = require('express'); const { exec, spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const cors = require('cors'); const crypto = require('crypto'); const multer = require('multer'); const { Readable, Transform } = require('stream'); const { pipeline } = require('stream/promises'); const LOCAL_ENV_PATH = path.join(__dirname, '.env'); if (fs.existsSync(LOCAL_ENV_PATH)) { const envText = fs.readFileSync(LOCAL_ENV_PATH, 'utf-8'); for (const rawLine of envText.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const separatorIndex = line.indexOf('='); if (separatorIndex <= 0) { continue; } const key = line.slice(0, separatorIndex).trim(); let value = line.slice(separatorIndex + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (key && process.env[key] === undefined) { process.env[key] = value; } } } const app = express(); const PORT = 3000; const PROJECT_ROOT = __dirname; const DATA_DIR = path.join(PROJECT_ROOT, 'data'); const DATA_VIDEO_DIR = path.join(DATA_DIR, 'videos'); const DATA_REMIX_ASSET_DIR = path.join(DATA_DIR, 'remix-assets'); const LEGACY_VIDEO_DIR = path.join(PROJECT_ROOT, 'src', 'video'); const VOICE_SPEAKER_ID_DOC_PATH = path.join(PROJECT_ROOT, 'docs', '音色创建', 'speaker_id.md'); const MANIFEST_PATH = path.join(DATA_DIR, 'manifest.json'); const LEGACY_MANIFEST_PATH = path.join(LEGACY_VIDEO_DIR, 'manifest.json'); const WHISPER_DIR = path.join(PROJECT_ROOT, 'Whisper'); const downloadTasks = new Map(); const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE'; const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX'; const QINIU_BUCKET = 'nova-repos'; const QINIU_CDN_DOMAIN = 'https://repos.fmode.cn'; const QINIU_CDN_PREFIX = 'x/openclaw-skills'; const QINIU_UPLOAD_URL = 'https://up-z2.qiniup.com'; const VOLC_SPEECH_BASE_URL = 'https://openspeech.bytedance.com'; const VOLC_TTS_PROXY_BASE_URL = process.env.VOLC_TTS_PROXY_BASE_URL || 'https://server.fmode.cn/api/volcengine/tts'; const VOLC_SPEECH_API_KEY = process.env.VOLC_SPEECH_API_KEY || process.env.BYTEDANCE_SPEECH_API_KEY || process.env.VOICE_CREATION_API_KEY || 'f112a70a-4754-4d73-8a14-a05f9b57bf65'; const VOLC_TTS_RESOURCE_ID = process.env.VOLC_TTS_RESOURCE_ID || ''; const VOLC_SPEECH_APP_KEY = process.env.VOLC_SPEECH_APP_KEY || process.env.VOLC_SPEECH_APP_ID || ''; const VOLC_SPEECH_ACCESS_KEY = process.env.VOLC_SPEECH_ACCESS_KEY || ''; app.use(cors()); app.use(express.json({ limit: '50mb' })); // 静态文件:提供视频文件的访问 const staticVideoOptions = { setHeaders: (res, filePath) => { if (filePath.match(/\.(mp4|mov|webm|mkv|avi)$/i)) { res.setHeader('Content-Type', 'video/mp4'); res.setHeader('Accept-Ranges', 'bytes'); } } }; app.use('/api/video', express.static(DATA_VIDEO_DIR, staticVideoOptions)); app.use('/api/video', express.static(LEGACY_VIDEO_DIR, staticVideoOptions)); // ==================== 文件上传配置 ==================== const uploadStorage = multer.diskStorage({ destination: (req, file, cb) => { const uploadDir = DATA_VIDEO_DIR; if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); cb(null, uploadDir); }, filename: (req, file, cb) => { // 保留原始文件名,如有冲突则加时间戳 const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8'); const ext = path.extname(originalName); const baseName = path.basename(originalName, ext); const targetPath = path.join(DATA_VIDEO_DIR, originalName); if (fs.existsSync(targetPath)) { cb(null, `${baseName}-${Date.now()}${ext}`); } else { cb(null, originalName); } } }); const assetUpload = multer({ storage: multer.memoryStorage(), // 200MB 以支持动作迁移 / 素材拼接场景的参考视频上传 limits: { fileSize: 200 * 1024 * 1024 } }); const upload = multer({ storage: uploadStorage, limits: { fileSize: 500 * 1024 * 1024 }, // 500MB fileFilter: (req, file, cb) => { const allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm', 'video/x-matroska']; if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp4|mov|avi|webm|mkv)$/i)) { cb(null, true); } else { cb(new Error('仅支持视频文件(mp4, mov, avi, webm, mkv)')); } } }); // ==================== 工具函数 ==================== // 确保 data 目录存在 if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(DATA_VIDEO_DIR)) fs.mkdirSync(DATA_VIDEO_DIR, { recursive: true }); if (!fs.existsSync(DATA_REMIX_ASSET_DIR)) fs.mkdirSync(DATA_REMIX_ASSET_DIR, { recursive: true }); function ensureManifestFile() { if (fs.existsSync(MANIFEST_PATH)) return; if (fs.existsSync(LEGACY_MANIFEST_PATH)) { fs.copyFileSync(LEGACY_MANIFEST_PATH, MANIFEST_PATH); return; } fs.writeFileSync(MANIFEST_PATH, '[]', 'utf-8'); } function readManifest() { ensureManifestFile(); const raw = fs.readFileSync(MANIFEST_PATH, 'utf-8'); return JSON.parse(raw); } function writeManifest(data) { ensureManifestFile(); fs.writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2), 'utf-8'); } function resolveVideoPath(filename) { const runtimePath = path.join(DATA_VIDEO_DIR, filename); if (fs.existsSync(runtimePath)) return runtimePath; const legacyPath = path.join(LEGACY_VIDEO_DIR, filename); if (fs.existsSync(legacyPath)) return legacyPath; return runtimePath; } function removeFileIfExists(filePath) { try { if (filePath && fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch (err) { // 忽略竞态条件 / 权限问题导致的删除失败,避免 ENOENT 等异常中断主流程 if (err && err.code !== 'ENOENT') { console.warn(`⚠️ 删除文件失败 ${filePath}: ${err.message}`); } } } function removeDirectoryIfExists(dirPath) { if (fs.existsSync(dirPath)) { fs.rmSync(dirPath, { recursive: true, force: true }); } } function toBase64Url(input) { return Buffer.from(input) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); } function buildQiniuUploadToken(key) { const deadline = Math.floor(Date.now() / 1000) + 3600; const putPolicy = { scope: `${QINIU_BUCKET}:${key}`, deadline }; const encodedPutPolicy = toBase64Url(JSON.stringify(putPolicy)); const sign = crypto .createHmac('sha1', QINIU_SECRET_KEY) .update(encodedPutPolicy) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); return `${QINIU_ACCESS_KEY}:${sign}:${encodedPutPolicy}`; } function buildDigitalHumanAssetKey(fileName, kind) { const rawName = Buffer.from(fileName || `asset-${Date.now()}`, 'latin1').toString('utf8'); const ext = path.extname(rawName).toLowerCase(); const safeExt = ext && /^[.a-z0-9]+$/i.test(ext) ? ext : ''; const baseName = path.basename(rawName, ext).replace(/[^a-zA-Z0-9_-]/g, '_') || `asset-${Date.now()}`; const date = new Date(); const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); const timestamp = `${yyyy}${mm}${dd}-${Date.now()}`; return `${QINIU_CDN_PREFIX}/digital-human/${kind}/${yyyy}${mm}${dd}/${timestamp}-${baseName}${safeExt}`; } function createRequestId() { return typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `${Date.now()}-${crypto.randomBytes(8).toString('hex')}`; } function buildSpeechHeaders(contentType = 'application/json') { const headers = { 'Content-Type': contentType, 'X-Api-Request-Id': createRequestId() }; if (VOLC_SPEECH_API_KEY) { headers['X-Api-Key'] = VOLC_SPEECH_API_KEY; return headers; } if (VOLC_SPEECH_APP_KEY && VOLC_SPEECH_ACCESS_KEY) { headers['X-Api-App-Key'] = VOLC_SPEECH_APP_KEY; headers['X-Api-Access-Key'] = VOLC_SPEECH_ACCESS_KEY; return headers; } throw new Error('未配置火山语音鉴权,请设置 VOLC_SPEECH_API_KEY 或 VOLC_SPEECH_APP_KEY + VOLC_SPEECH_ACCESS_KEY'); } function normalizeOfficialSpeechResourceId(value) { const resourceId = String(value || '').trim(); return /^seed-(tts|icl)-/i.test(resourceId) ? resourceId : ''; } function inferOfficialSpeechResourceId(speaker, requestedResourceId = '') { const explicitResourceId = normalizeOfficialSpeechResourceId(requestedResourceId); if (explicitResourceId) { return explicitResourceId; } const configuredResourceId = normalizeOfficialSpeechResourceId(VOLC_TTS_RESOURCE_ID); if (configuredResourceId) { return configuredResourceId; } const normalizedSpeaker = String(speaker || '').trim(); if (/^(S_|icl_|saturn_|dit_)/i.test(normalizedSpeaker)) { return 'seed-icl-2.0'; } return 'seed-tts-2.0'; } function clampNumber(value, fallback, min, max) { const numericValue = Number(value); if (!Number.isFinite(numericValue)) { return fallback; } return Math.min(max, Math.max(min, numericValue)); } function clampOptionalNumber(value, min, max) { if (value === null || value === undefined || String(value).trim() === '') { return null; } const numericValue = Number(value); if (!Number.isFinite(numericValue)) { return null; } return Math.min(max, Math.max(min, numericValue)); } function parseOptionalBoolean(value, fallback = false) { if (value === null || value === undefined || value === '') { return fallback; } if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } const normalizedValue = String(value).trim().toLowerCase(); if (['true', '1', 'yes', 'on'].includes(normalizedValue)) { return true; } if (['false', '0', 'no', 'off'].includes(normalizedValue)) { return false; } return fallback; } function normalizeVoiceSynthesisErrorMessage(message) { const rawMessage = String(message || '').trim(); if (/resource ID is mismatched with speaker related resource/i.test(rawMessage)) { return '当前本地 X-Api-Key 与所选 speaker_id 不属于同一语音资源,无法直接使用 speaker_id 合成。请先通过音色复刻获取 timbreId 后再合成,或更换与该 speaker_id 匹配的 X-Api-Key。'; } return rawMessage || '语音合成失败'; } function mapVoiceDesignStatus(status) { switch (Number(status)) { case 0: return '未找到'; case 1: return '训练中'; case 2: return '可用'; case 3: return '失败'; case 4: return '已激活'; default: return '未知'; } } function mapVoiceCloneStatus(status) { switch (String(status ?? '')) { case '0': return '未占用'; case '1': return '可用'; case '2': return '训练中'; case '3': return '失败'; case '404': return '已删除'; default: return '未知'; } } function normalizeBearerToken(token) { const rawToken = String(token || '').trim(); if (!rawToken) { return ''; } return /^Bearer\s+/i.test(rawToken) ? rawToken : `Bearer ${rawToken}`; } function inferAudioFormat(file) { const mimeType = String(file?.mimetype || '').toLowerCase(); if (mimeType.includes('mpeg') || mimeType.includes('mp3')) { return 'mp3'; } if (mimeType.includes('wav')) { return 'wav'; } if (mimeType.includes('m4a') || mimeType.includes('mp4')) { return 'm4a'; } if (mimeType.includes('aac')) { return 'aac'; } if (mimeType.includes('flac')) { return 'flac'; } if (mimeType.includes('ogg') || mimeType.includes('opus')) { return 'ogg_opus'; } if (mimeType.includes('pcm')) { return 'pcm'; } const ext = path.extname(file?.originalname || '').toLowerCase(); switch (ext) { case '.wav': return 'wav'; case '.m4a': return 'm4a'; case '.aac': return 'aac'; case '.flac': return 'flac'; case '.ogg': case '.opus': return 'ogg_opus'; case '.pcm': return 'pcm'; case '.mp3': default: return 'mp3'; } } async function readFetchResponse(response) { const rawText = await response.text(); let payload = null; try { payload = rawText ? JSON.parse(rawText) : null; } catch { payload = null; } return { rawText, payload }; } function extractPrimaryVoiceModel(timbre) { if (!Array.isArray(timbre?.models) || timbre.models.length === 0) { return {}; } return timbre.models[0] || {}; } function readVoiceSpeakerIdOptions() { if (!fs.existsSync(VOICE_SPEAKER_ID_DOC_PATH)) { return []; } const rawText = fs.readFileSync(VOICE_SPEAKER_ID_DOC_PATH, 'utf-8'); const seen = new Set(); const options = []; for (const rawLine of rawText.split(/\r?\n/)) { const speakerId = rawLine.trim().match(/^S_[A-Za-z0-9]+$/)?.[0] || ''; if (!speakerId || seen.has(speakerId)) { continue; } seen.add(speakerId); options.push(speakerId); } return options; } function upsertVoiceProfile(profile) { const profiles = readDataFile('voice-profiles'); const existingIndex = profiles.findIndex((item) => ( (!!profile.timbre_id && item.timbre_id === profile.timbre_id) || (!!profile.id && item.id === profile.id) )); if (existingIndex >= 0) { const existingProfile = profiles[existingIndex]; const nextProfile = { ...existingProfile, ...profile, id: existingProfile.id || profile.id, created_at: existingProfile.created_at || profile.created_at, updated_at: profile.updated_at || new Date().toISOString() }; profiles[existingIndex] = nextProfile; writeDataFile('voice-profiles', profiles); return nextProfile; } profiles.unshift(profile); writeDataFile('voice-profiles', profiles); return profile; } async function requestVolcTtsJson(endpoint, requestBody) { const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const { rawText, payload } = await readFetchResponse(response); if (!response.ok) { const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败'); error.status = response.status; error.detail = payload || rawText || ''; throw error; } return payload; } async function proxyVolcTtsStream(endpoint, requestBody, res) { const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!response.ok) { const { rawText, payload } = await readFetchResponse(response); const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 流式请求失败'); error.status = response.status; error.detail = payload || rawText || ''; throw error; } res.status(response.status); res.setHeader('Content-Type', response.headers.get('content-type') || 'application/x-ndjson; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); if (!response.body) { res.end(); return; } await pipeline(Readable.fromWeb(response.body), res); } async function synthesizeVoiceAudio({ token, speaker, timbreId, text, ssml, xApiResourceId, model, isStream = false, audioParams = {}, additions = {} }) { const normalizedSpeaker = String(speaker || '').trim(); const fallbackSpeaker = String(timbreId || '').trim(); const resolvedSpeaker = normalizedSpeaker || fallbackSpeaker; const requestedResourceId = String(xApiResourceId || '').trim(); const headerResourceId = inferOfficialSpeechResourceId(resolvedSpeaker, requestedResourceId); const resolvedModel = String(model || '').trim(); const serializedAdditions = Object.keys(additions).length > 0 ? JSON.stringify(additions) : ''; const requestBody = { req_params: { ...(ssml ? { ssml } : { text }), speaker: resolvedSpeaker, audio_params: { format: audioParams.format || 'mp3', sample_rate: audioParams.sampleRate ?? 24000, ...(audioParams.speechRate === null || audioParams.speechRate === undefined ? {} : { speech_rate: audioParams.speechRate }), ...(audioParams.loudnessRate === null || audioParams.loudnessRate === undefined ? {} : { loudness_rate: audioParams.loudnessRate }), ...(audioParams.emotion ? { emotion: audioParams.emotion } : {}), ...(audioParams.emotionScale === null || audioParams.emotionScale === undefined ? {} : { emotion_scale: audioParams.emotionScale }), ...(audioParams.enableSubtitle ? { enable_subtitle: true } : {}) }, ...(serializedAdditions ? { additions: serializedAdditions } : {}), ...(resolvedModel ? { model: resolvedModel } : {}) } }; const headers = buildSpeechHeaders(); headers['X-Api-Resource-Id'] = headerResourceId; headers['Connection'] = 'keep-alive'; const response = await fetch(`${VOLC_SPEECH_BASE_URL}/api/v3/tts/unidirectional`, { method: 'POST', headers, body: JSON.stringify(requestBody) }); const { rawText, payload } = await readFetchResponse(response); if (!response.ok) { const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败')); error.status = response.status; error.detail = payload || rawText || ''; throw error; } if (Number(payload?.code) !== 200 || !payload?.data?.audioUrl) { const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || '语音合成失败')); error.status = 502; error.detail = payload || ''; throw error; } return { audioUrl: payload.data.audioUrl, workId: payload.data.workId || '', payload }; } function createHttpError(message, status = 500, detail = '') { const error = new Error(message || '请求失败'); error.status = status; error.detail = detail; return error; } function inferAudioFormatFromUrl(url) { try { const parsed = new URL(String(url || '').trim()); return inferAudioFormat({ originalname: parsed.pathname || '', mimetype: '' }); } catch { return inferAudioFormat({ originalname: String(url || '').trim(), mimetype: '' }); } } function normalizeVoiceClonePayload(body) { const audioData = body?.audioData && typeof body.audioData === 'object' ? body.audioData : {}; const extraParams = body?.extra_params && typeof body.extra_params === 'object' ? body.extra_params : {}; return { token: normalizeBearerToken(body?.token), name: String(body?.name || body?.displayName || '').trim(), timbreId: String(body?.timbreId || '').trim(), speakerId: String(body?.speaker_id || body?.speakerId || '').trim(), audioData: { url: String(audioData?.url || '').trim(), base64: String(audioData?.base64 || '').trim(), format: String(audioData?.format || '').trim().toLowerCase(), text: String(audioData?.text || body?.audioText || '').trim() }, language: Number(body?.language ?? 0) === 1 ? 1 : 0, demoText: String(extraParams?.demo_text || body?.sampleText || '').trim(), sourceAudioName: String(body?.sourceAudioName || body?.source_audio_name || '').trim(), sourceAudioFormat: String(body?.sourceAudioFormat || body?.source_audio_format || '').trim().toLowerCase() }; } function normalizeVoiceSynthesisPayload(body) { const rawAudioParams = body?.audio_params && typeof body.audio_params === 'object' ? body.audio_params : (body?.audioParams && typeof body.audioParams === 'object' ? body.audioParams : {}); const rawAdditions = body?.additions && typeof body.additions === 'object' ? body.additions : {}; const formatCandidate = String(rawAudioParams?.format || '').trim().toLowerCase(); const format = ['mp3', 'ogg_opus', 'pcm'].includes(formatCandidate) ? formatCandidate : 'mp3'; const sampleRateCandidate = Number(rawAudioParams?.sampleRate ?? rawAudioParams?.sample_rate); const sampleRate = [8000, 16000, 22050, 24000, 32000, 44100, 48000].includes(sampleRateCandidate) ? sampleRateCandidate : 24000; const speechRate = clampNumber(rawAudioParams?.speechRate ?? rawAudioParams?.speech_rate, 0, -50, 100); const loudnessRate = clampNumber(rawAudioParams?.loudnessRate ?? rawAudioParams?.loudness_rate, 0, -50, 100); const emotion = String(rawAudioParams?.emotion || '').trim(); const emotionScale = clampOptionalNumber(rawAudioParams?.emotionScale ?? rawAudioParams?.emotion_scale, 1, 5); const enableSubtitle = parseOptionalBoolean(rawAudioParams?.enableSubtitle ?? rawAudioParams?.enable_subtitle, false); const silenceDuration = clampOptionalNumber(rawAdditions?.silenceDuration ?? rawAdditions?.silence_duration, 0, 30000); const enableLanguageDetector = parseOptionalBoolean(rawAdditions?.enableLanguageDetector ?? rawAdditions?.enable_language_detector, false); const disableMarkdownFilter = parseOptionalBoolean(rawAdditions?.disableMarkdownFilter ?? rawAdditions?.disable_markdown_filter, false); const disableEmojiFilter = parseOptionalBoolean(rawAdditions?.disableEmojiFilter ?? rawAdditions?.disable_emoji_filter, false); const explicitLanguage = String((rawAdditions?.explicitLanguage ?? rawAdditions?.explicit_language) || '').trim(); return { token: normalizeBearerToken(body?.token), text: String(body?.text || '').trim(), ssml: String(body?.ssml || '').trim(), timbreId: String(body?.timbreId || body?.timbre_id || '').trim(), speakerId: String(body?.speaker_id || body?.speakerId || body?.speaker || '').trim(), isStream: parseOptionalBoolean(body?.isStream ?? body?.is_stream, true), xApiResourceId: String(body?.x_api_resource_id || body?.xApiResourceId || '').trim(), model: String(body?.model || '').trim(), audioParams: { format, sampleRate, speechRate, loudnessRate, emotion, emotionScale, enableSubtitle }, additions: { ...(silenceDuration === null ? {} : { silence_duration: silenceDuration }), ...(enableLanguageDetector ? { enable_language_detector: true } : {}), ...(disableMarkdownFilter ? { disable_markdown_filter: true } : {}), ...(disableEmojiFilter ? { disable_emoji_filter: true } : {}), ...(explicitLanguage ? { explicit_language: explicitLanguage } : {}) } }; } function buildProxySynthesisPayload(payload) { return { token: payload.token, ...(payload.text ? { text: payload.text } : {}), ...(payload.ssml ? { ssml: payload.ssml } : {}), ...(payload.timbreId ? { timbreId: payload.timbreId } : {}), ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}), isStream: !!payload.isStream, ...(payload.xApiResourceId ? { x_api_resource_id: payload.xApiResourceId } : {}), ...(payload.model ? { model: payload.model } : {}), audio_params: { format: payload.audioParams.format, sample_rate: payload.audioParams.sampleRate, speech_rate: payload.audioParams.speechRate, loudness_rate: payload.audioParams.loudnessRate, ...(payload.audioParams.emotion ? { emotion: payload.audioParams.emotion } : {}), ...(payload.audioParams.emotionScale === null || payload.audioParams.emotionScale === undefined ? {} : { emotion_scale: payload.audioParams.emotionScale }), ...(payload.audioParams.enableSubtitle ? { enable_subtitle: true } : {}) }, ...(Object.keys(payload.additions).length > 0 ? { additions: payload.additions } : {}) }; } async function executeVoiceCloneRequest(payload) { if (!payload.token) { throw createHttpError('缺少 token 参数', 400); } if (!payload.name) { throw createHttpError('缺少音色名称 name', 400); } if (!payload.timbreId && !payload.speakerId) { throw createHttpError('timbreId与speaker_id不能同时为空', 400); } if (!payload.audioData.url && !payload.audioData.base64) { throw createHttpError('音频url或base64至少提供一个', 400); } if (payload.demoText && (payload.demoText.length < 4 || payload.demoText.length > 80)) { throw createHttpError('试听文本长度需在 4-80 字之间', 400); } const clonePayload = await requestVolcTtsJson('voice_clone', { token: payload.token, name: payload.name, ...(payload.timbreId ? { timbreId: payload.timbreId } : {}), ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}), audioData: { ...(payload.audioData.url ? { url: payload.audioData.url } : {}), ...(payload.audioData.base64 ? { base64: payload.audioData.base64 } : {}), ...(payload.audioData.format ? { format: payload.audioData.format } : {}), ...(payload.audioData.text ? { text: payload.audioData.text } : {}) }, language: payload.language, ...(payload.demoText ? { extra_params: { demo_text: payload.demoText } } : {}) }); if (Number(clonePayload?.code) !== 200 || !clonePayload?.data?.timbre?.objectId) { throw createHttpError(clonePayload?.error?.message || clonePayload?.message || '音色复刻失败', 502, clonePayload || ''); } const timbre = clonePayload.data.timbre; const primaryModel = extractPrimaryVoiceModel(timbre); const now = new Date().toISOString(); const profile = upsertVoiceProfile({ id: `VOICE-${Date.now()}`, name: payload.name, creation_mode: 'clone', speaker_id: timbre?.speaker_id || payload.speakerId, timbre_id: timbre?.objectId || payload.timbreId || '', sample_text: payload.demoText, source_audio_text: payload.audioData.text, source_audio_name: payload.sourceAudioName, source_audio_format: payload.sourceAudioFormat || payload.audioData.format || inferAudioFormatFromUrl(payload.audioData.url), text_prompt: '', language: payload.language, status: Number.isFinite(Number(timbre?.status)) ? Number(timbre.status) : null, status_label: mapVoiceCloneStatus(timbre?.status), demo_audio: primaryModel?.demo_audio || '', available_training_times: null, image_prompt_name: '', x_api_resource_id: Array.isArray(primaryModel?.x_api_resource_id) ? String(primaryModel.x_api_resource_id[0] || '') : '', model_version: String(primaryModel?.version || ''), icl_speaker_id: String(primaryModel?.icl_speaker_id || ''), occupied: !!timbre?.occupied, synthesized_audio_url: '', synthesized_work_id: '', last_synthesis_text: '', latest_audio_url: primaryModel?.demo_audio || '', message: clonePayload?.data?.tip || '音色复刻成功', request_id: clonePayload?.request_id || '', created_at: now, updated_at: now }); return { clonePayload, profile }; } function findVoiceProfileForSynthesis(payload) { const profiles = readDataFile('voice-profiles'); if (payload.timbreId) { const byTimbreId = profiles.find((item) => item.timbre_id === payload.timbreId); if (byTimbreId) { return byTimbreId; } } if (payload.speakerId) { return profiles.find((item) => ( String(item.icl_speaker_id || '').trim() === payload.speakerId || String(item.speaker_id || '').trim() === payload.speakerId )) || null; } return null; } function persistSynthesisProfile(profile, payload, synthesis) { if (!profile) { return null; } return upsertVoiceProfile({ ...profile, synthesized_audio_url: synthesis.audioUrl, synthesized_work_id: synthesis.workId, last_synthesis_text: payload.text, last_synthesis_ssml: payload.ssml, last_synthesis_x_api_resource_id: payload.xApiResourceId || profile.x_api_resource_id || '', last_synthesis_model: payload.model, last_synthesis_format: payload.audioParams.format, last_synthesis_sample_rate: payload.audioParams.sampleRate, last_synthesis_speech_rate: payload.audioParams.speechRate, last_synthesis_loudness_rate: payload.audioParams.loudnessRate, last_synthesis_emotion: payload.audioParams.emotion, last_synthesis_emotion_scale: payload.audioParams.emotionScale, last_synthesis_enable_subtitle: !!payload.audioParams.enableSubtitle, last_synthesis_silence_duration: payload.additions?.silence_duration ?? null, last_synthesis_enable_language_detector: !!payload.additions?.enable_language_detector, last_synthesis_disable_markdown_filter: !!payload.additions?.disable_markdown_filter, last_synthesis_disable_emoji_filter: !!payload.additions?.disable_emoji_filter, last_synthesis_explicit_language: String(payload.additions?.explicit_language || '').trim(), latest_audio_url: synthesis.audioUrl || profile.latest_audio_url || profile.demo_audio || '', updated_at: new Date().toISOString() }); } async function executeVoiceSynthesisRequest(payload) { if (!payload.token) { throw createHttpError('缺少 token 参数', 400); } if (!payload.text && !payload.ssml) { throw createHttpError('文本内容不能为空,text与ssml不能同时为空', 400); } const matchedProfile = findVoiceProfileForSynthesis(payload); if (payload.timbreId) { const proxyPayload = await requestVolcTtsJson('unidirectional', buildProxySynthesisPayload({ ...payload, isStream: false })); if (Number(proxyPayload?.code) !== 200 || !proxyPayload?.data?.audioUrl) { throw createHttpError(proxyPayload?.error?.message || proxyPayload?.message || '语音合成失败', 502, proxyPayload || ''); } const profile = persistSynthesisProfile(matchedProfile, payload, { audioUrl: proxyPayload.data.audioUrl, workId: proxyPayload.data.workId || '' }); return { response: { ...proxyPayload, ...(profile ? { profile } : {}) }, profile }; } if (!payload.speakerId) { throw createHttpError('音色id不能为空', 400); } const synthesis = await synthesizeVoiceAudio({ token: payload.token, speaker: payload.speakerId, timbreId: matchedProfile?.timbre_id || '', text: payload.text, ssml: payload.ssml, xApiResourceId: payload.xApiResourceId || matchedProfile?.x_api_resource_id || '', model: payload.model, isStream: false, audioParams: payload.audioParams, additions: payload.additions }); const profile = persistSynthesisProfile(matchedProfile, payload, synthesis); return { response: { code: 200, data: { workId: synthesis.workId, audioUrl: synthesis.audioUrl }, ...(profile ? { profile } : {}) }, profile }; } // 通用 JSON 数据文件读写 function readDataFile(name) { const p = path.join(DATA_DIR, `${name}.json`); if (!fs.existsSync(p)) { fs.writeFileSync(p, '[]', 'utf-8'); return []; } return JSON.parse(fs.readFileSync(p, 'utf-8')); } function writeDataFile(name, data) { fs.writeFileSync(path.join(DATA_DIR, `${name}.json`), JSON.stringify(data, null, 2), 'utf-8'); } function isSafeRemoteUrl(url) { try { const parsed = new URL(url); return ['http:', 'https:'].includes(parsed.protocol); } catch { return false; } } function sanitizeFilename(filename) { const safeName = String(filename || '') .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') .trim(); return safeName || `video-${Date.now()}.mp4`; } function ensureVideoFilename(filename, sourceUrl = '') { const safeName = sanitizeFilename(filename); if (path.extname(safeName)) { return safeName; } try { const parsed = new URL(sourceUrl); const sourceExt = path.extname(parsed.pathname || '').toLowerCase(); if (sourceExt) { return `${safeName}${sourceExt}`; } } catch {} return `${safeName}.mp4`; } function ensureUniqueVideoFilename(filename) { const ext = path.extname(filename) || '.mp4'; const baseName = path.basename(filename, ext); let candidate = filename; let counter = 1; while (fs.existsSync(path.join(DATA_VIDEO_DIR, candidate)) || fs.existsSync(path.join(LEGACY_VIDEO_DIR, candidate))) { candidate = `${baseName}-${Date.now()}-${counter}${ext}`; counter += 1; } return candidate; } function createManagedVideoEntry({ filename, title, description, tags, thumbnail, duration, resolution, awemeId, authorName, size }) { const ext = path.extname(filename).replace('.', '').toLowerCase() || 'mp4'; const now = new Date().toISOString(); return { id: `VID-${Date.now()}`, title: title || path.basename(filename, path.extname(filename)), filename, size: size || 0, duration: Number(duration) || 0, created_at: now, modified_at: now, category: 'downloaded', tags: Array.isArray(tags) ? tags : [], description: description || '', thumbnail: thumbnail || '', source: 'downloaded', aweme_id: awemeId || '', metadata: { resolution: resolution || '未知', format: ext, authorName: authorName || '' } }; } function normalizeRemoteUrls(primaryUrl, urls = []) { return [primaryUrl, ...(Array.isArray(urls) ? urls : [])].filter((url, index, list) => ( typeof url === 'string' && isSafeRemoteUrl(url) && list.indexOf(url) === index )); } async function fetchRemoteVideoResponse(urls, requestHeaders = {}) { let lastError = null; for (const currentUrl of urls) { try { const response = await fetch(currentUrl, { method: 'GET', headers: requestHeaders, redirect: 'follow' }); if (!response.ok) { const detail = await response.text().catch(() => ''); lastError = new Error(`远程下载失败: ${response.status} ${response.statusText}${detail ? ` ${detail.slice(0, 200)}` : ''}`); continue; } if (!response.body) { lastError = new Error('远程响应缺少视频流'); continue; } return { url: currentUrl, response }; } catch (error) { lastError = error; } } throw lastError || new Error('没有可用的远程视频地址'); } // ==================== Whisper 转录 ==================== // POST /api/whisper/transcribe // body: { videoId, language?, model? } app.post('/api/whisper/transcribe', async (req, res) => { const { videoId, language = 'Chinese', model = 'base' } = req.body; if (!videoId) { return res.status(400).json({ error: '缺少 videoId 参数' }); } // 从 manifest 查找视频 const manifest = readManifest(); const video = manifest.find(v => v.id === videoId); if (!video) { return res.status(404).json({ error: `未找到视频: ${videoId}` }); } const videoPath = resolveVideoPath(video.filename); if (!fs.existsSync(videoPath)) { return res.status(404).json({ error: `视频文件不存在: ${video.filename}` }); } // 为该视频创建专属输出目录 const baseName = video.filename.replace(/\.[^.]+$/, ''); const outputDir = path.join(WHISPER_DIR, baseName); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } console.log(`🎙️ 开始 Whisper 转录: ${video.filename}`); console.log(` 模型: ${model}, 语言: ${language}`); console.log(` 输出目录: ${outputDir}`); // 执行 Whisper 命令 const cmd = `whisper "${videoPath}" --model ${model} --language ${language} --output_dir "${outputDir}"`; try { const result = await new Promise((resolve, reject) => { const process = exec(cmd, { cwd: PROJECT_ROOT, timeout: 10 * 60 * 1000, // 10分钟超时 maxBuffer: 10 * 1024 * 1024 }); let stdout = ''; let stderr = ''; process.stdout.on('data', (data) => { stdout += data; console.log(` [whisper] ${data.toString().trim()}`); }); process.stderr.on('data', (data) => { stderr += data; }); process.on('close', (code) => { if (code === 0) { resolve({ stdout, stderr }); } else { reject(new Error(`Whisper 退出码: ${code}\n${stderr}`)); } }); process.on('error', (err) => { reject(new Error(`无法启动 Whisper: ${err.message}`)); }); }); // 读取生成的文件 const txtFile = path.join(outputDir, `${baseName}.txt`); const srtFile = path.join(outputDir, `${baseName}.srt`); const transcript = fs.existsSync(txtFile) ? fs.readFileSync(txtFile, 'utf-8') : ''; const srt = fs.existsSync(srtFile) ? fs.readFileSync(srtFile, 'utf-8') : ''; if (!transcript) { return res.status(500).json({ error: 'Whisper 执行完成但未生成文字稿' }); } // 更新 manifest 中该视频的 whisper 字段 const whisperPaths = { transcript: `Whisper/${baseName}/${baseName}.txt`, srt: `Whisper/${baseName}/${baseName}.srt` }; // 检查是否存在其他输出文件 const jsonFile = path.join(outputDir, `${baseName}.json`); const vttFile = path.join(outputDir, `${baseName}.vtt`); const tsvFile = path.join(outputDir, `${baseName}.tsv`); if (fs.existsSync(jsonFile)) whisperPaths.segments = `Whisper/${baseName}/${baseName}.json`; video.whisper = whisperPaths; writeManifest(manifest); console.log(`✅ Whisper 转录完成: ${baseName}`); res.json({ success: true, videoId: video.id, transcript, srt, whisper: whisperPaths, outputDir: `Whisper/${baseName}` }); } catch (err) { console.error(`❌ Whisper 转录失败:`, err.message); res.status(500).json({ error: `Whisper 转录失败: ${err.message}`, hint: '请确保已安装 Whisper: pip install openai-whisper' }); } }); // GET /api/whisper/status — 检查 Whisper 是否可用 app.get('/api/whisper/status', (req, res) => { exec('whisper --help', { timeout: 5000 }, (err) => { if (err) { res.json({ available: false, message: '未检测到 Whisper,请执行: pip install openai-whisper' }); } else { res.json({ available: true, message: 'Whisper 已安装' }); } }); }); // ==================== Manifest 管理 ==================== // GET /api/manifest — 获取完整 manifest app.get('/api/manifest', (req, res) => { try { const manifest = readManifest(); res.json(manifest); } catch (err) { res.status(500).json({ error: `读取 manifest 失败: ${err.message}` }); } }); // PUT /api/manifest/:videoId — 更新指定视频条目 app.put('/api/manifest/:videoId', (req, res) => { try { const manifest = readManifest(); const idx = manifest.findIndex(v => v.id === req.params.videoId); if (idx === -1) { return res.status(404).json({ error: `未找到视频: ${req.params.videoId}` }); } // 合并更新字段 manifest[idx] = { ...manifest[idx], ...req.body }; writeManifest(manifest); res.json({ success: true, video: manifest[idx] }); } catch (err) { res.status(500).json({ error: `更新 manifest 失败: ${err.message}` }); } }); // POST /api/manifest — 添加新视频条目 app.post('/api/manifest', (req, res) => { try { const manifest = readManifest(); const newEntry = req.body; if (!newEntry.id || !newEntry.filename) { return res.status(400).json({ error: '缺少 id 或 filename' }); } // 检查重复 if (manifest.find(v => v.id === newEntry.id)) { return res.status(409).json({ error: `视频 ${newEntry.id} 已存在` }); } manifest.push(newEntry); writeManifest(manifest); res.json({ success: true, video: newEntry }); } catch (err) { res.status(500).json({ error: `添加视频失败: ${err.message}` }); } }); // DELETE /api/manifest/:videoId — 删除指定视频条目及关联文件 app.delete('/api/manifest/:videoId', (req, res) => { try { const manifest = readManifest(); const idx = manifest.findIndex(v => v.id === req.params.videoId); if (idx === -1) { return res.status(404).json({ error: `未找到视频: ${req.params.videoId}` }); } const [video] = manifest.splice(idx, 1); const filename = typeof video.filename === 'string' ? video.filename : ''; // 先清理关联文件,所有步骤都做容错;最后再持久化 manifest,避免中途异常导致状态不一致 if (filename) { try { const videoPath = resolveVideoPath(filename); removeFileIfExists(videoPath); } catch (cleanupErr) { console.warn(`⚠️ 删除视频文件失败 (${filename}):`, cleanupErr.message); } } else { console.warn(`⚠️ 视频条目 ${video.id} 缺少 filename 字段,跳过文件清理`); } try { const remixFilePath = path.join(REMIXES_DIR, `${video.id}.json`); removeFileIfExists(remixFilePath); } catch (cleanupErr) { console.warn(`⚠️ 删除 remix 文件失败 (${video.id}):`, cleanupErr.message); } if (filename) { try { const baseName = filename.replace(/\.[^.]+$/, ''); const whisperOutputDir = path.join(WHISPER_DIR, baseName); removeDirectoryIfExists(whisperOutputDir); } catch (cleanupErr) { console.warn(`⚠️ 删除 Whisper 输出目录失败 (${filename}):`, cleanupErr.message); } } writeManifest(manifest); console.log(`🗑️ 视频已删除: ${video.title || filename || video.id}`); res.json({ success: true, videoId: video.id, filename }); } catch (err) { console.error('❌ DELETE /api/manifest 失败:', err); res.status(500).json({ error: `删除视频失败: ${err.message}` }); } }); // ==================== 视频上传 ==================== // POST /api/upload/video — 上传视频文件 app.post('/api/upload/video', upload.single('video'), (req, res) => { try { if (!req.file) { return res.status(400).json({ error: '未收到视频文件' }); } const file = req.file; const filename = file.filename; const ext = path.extname(filename).replace('.', '').toLowerCase(); // 生成视频 ID const videoId = `VID-${Date.now()}`; // 获取文件大小 const fileStat = fs.statSync(file.path); // 创建 manifest 条目 const videoEntry = { id: videoId, title: req.body.title || path.basename(filename, path.extname(filename)), filename: filename, size: fileStat.size, duration: 0, // 前端可以通过 video 元素获取 category: 'uploaded', tags: req.body.tags ? JSON.parse(req.body.tags) : [], description: req.body.description || '用户上传的视频', source: 'uploaded', metadata: { resolution: '未知', format: ext || 'mp4' } }; // 添加到 manifest const manifest = readManifest(); manifest.push(videoEntry); writeManifest(manifest); console.log(`📤 视频上传成功: ${filename} (${(fileStat.size / 1024 / 1024).toFixed(1)}MB) → ${videoId}`); res.json({ success: true, video: videoEntry, filepath: `/backend/video/${filename}` }); } catch (err) { console.error('❌ 视频上传失败:', err.message); res.status(500).json({ error: `上传失败: ${err.message}` }); } }); // 上传错误处理 app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: '文件大小超过限制(最大 500MB)' }); } return res.status(400).json({ error: `上传错误: ${err.message}` }); } if (err) { return res.status(400).json({ error: err.message }); } next(); }); app.post('/api/remix/extract-audio', (req, res) => { const { videoId } = req.body || {}; if (!videoId) { return res.status(400).json({ error: '缺少 videoId 参数' }); } const manifest = readManifest(); const video = manifest.find(v => v.id === videoId); if (!video) { return res.status(404).json({ error: `未找到视频: ${videoId}` }); } const videoPath = resolveVideoPath(video.filename); if (!fs.existsSync(videoPath)) { return res.status(404).json({ error: `视频文件不存在: ${video.filename}` }); } const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`; const outputFilename = `${safeBaseName}-${Date.now()}.wav`; const outputPath = path.join(DATA_REMIX_ASSET_DIR, outputFilename); const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', outputPath]; const process = spawn('ffmpeg', ffmpegArgs, { cwd: PROJECT_ROOT }); let stderr = ''; process.stderr.on('data', (data) => { stderr += data.toString(); }); process.on('error', (err) => { removeFileIfExists(outputPath); const message = /ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量' : `无法启动 ffmpeg:${err.message}`; res.status(500).json({ error: message }); }); process.on('close', (code) => { if (code !== 0 || !fs.existsSync(outputPath)) { removeFileIfExists(outputPath); const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || ''; const normalizedMessage = /ffmpeg/i.test(stderr) && /not recognized|not found|no such file/i.test(stderr) ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量' : `音频提取失败${lastErrorLine ? `: ${lastErrorLine}` : ''}`; return res.status(500).json({ error: normalizedMessage }); } res.setHeader('Content-Type', 'audio/wav'); res.setHeader('Content-Disposition', `attachment; filename="${outputFilename}"`); const stream = fs.createReadStream(outputPath); stream.on('close', () => { removeFileIfExists(outputPath); }); stream.on('error', () => { removeFileIfExists(outputPath); if (!res.headersSent) { res.status(500).json({ error: '音频文件读取失败' }); } else { res.end(); } }); stream.pipe(res); }); }); // POST /api/extract-audio-mp3 — 提取视频音轨为 MP3(轻量,适合发给 Gemini 音频识别) app.post('/api/extract-audio-mp3', (req, res) => { const { videoId } = req.body || {}; if (!videoId) { return res.status(400).json({ error: '缺少 videoId 参数' }); } const manifest = readManifest(); const video = manifest.find(v => v.id === videoId); if (!video) { console.warn(`⚠️ extract-audio-mp3: 未找到 videoId=${videoId}`); return res.status(404).json({ error: `未找到视频: ${videoId}` }); } const videoPath = resolveVideoPath(video.filename); if (!fs.existsSync(videoPath)) { console.warn(`⚠️ extract-audio-mp3: 视频文件不存在: ${videoPath}`); return res.status(404).json({ error: `视频文件不存在: ${video.filename}` }); } const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`; // 使用 AAC(ffmpeg 内置,无需 libmp3lame),封装为 m4a;Gemini 支持 audio/mp4 const outputFilename = `${safeBaseName}-${Date.now()}.m4a`; const outputPath = path.join(DATA_REMIX_ASSET_DIR, outputFilename); const outputMimeType = 'audio/mp4'; console.log(`🎵 开始音频提取: ${videoPath} → ${outputPath}`); // 64kbps 单声道 16kHz:体积小且足够语音识别 const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath]; const proc = spawn('ffmpeg', ffmpegArgs, { cwd: PROJECT_ROOT }); let stderr = ''; let responded = false; const safeRespond = (status, body) => { if (responded) return; responded = true; res.status(status).json(body); }; proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('error', (err) => { removeFileIfExists(outputPath); const message = /ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量' : `无法启动 ffmpeg:${err.message}`; console.error(`❌ extract-audio-mp3 spawn error: ${err.message}`); safeRespond(500, { error: message }); }); proc.on('close', (code) => { if (code !== 0 || !fs.existsSync(outputPath)) { const tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | '); console.error(`❌ ffmpeg exit code=${code} 输出文件存在=${fs.existsSync(outputPath)}`); console.error(` stderr 末尾: ${tail}`); removeFileIfExists(outputPath); const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || ''; return safeRespond(500, { error: `音频提取失败 (exit=${code})${lastErrorLine ? `: ${lastErrorLine}` : ''}` }); } const stats = fs.statSync(outputPath); const sizeMB = (stats.size / 1024 / 1024).toFixed(2); console.log(`🎵 音频提取完成: ${outputFilename} (${sizeMB}MB)`); // 返回 base64 编码的 mp3(方便前端直接发给 Gemini) const audioBuffer = fs.readFileSync(outputPath); const audioBase64 = audioBuffer.toString('base64'); removeFileIfExists(outputPath); safeRespond(200, { success: true, audio: { base64: audioBase64, mimeType: outputMimeType, sizeMB: parseFloat(sizeMB), filename: outputFilename } }); }); }); app.post('/api/remix/upload-asset', assetUpload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: '未收到素材文件' }); } const mimeType = req.file.mimetype || 'application/octet-stream'; const kind = mimeType.startsWith('audio/') ? 'audio' : mimeType.startsWith('video/') ? 'video' : 'image'; const key = buildDigitalHumanAssetKey(req.file.originalname, kind); const token = buildQiniuUploadToken(key); const formData = new FormData(); formData.append('token', token); formData.append('key', key); formData.append('file', new Blob([req.file.buffer], { type: mimeType }), path.basename(key)); const response = await fetch(QINIU_UPLOAD_URL, { method: 'POST', body: formData }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = null; } if (!response.ok) { return res.status(response.status).json({ error: payload?.error || payload?.message || text || '七牛素材上传失败', detail: payload || text || '' }); } const uploadedKey = payload?.key || key; const url = `${QINIU_CDN_DOMAIN.replace(/\/$/, '')}/${uploadedKey}`; if (!uploadedKey || !url) { return res.status(500).json({ error: '七牛未返回素材 Key', detail: payload || text || '' }); } res.json({ success: true, url, key: uploadedKey, mimeType, kind }); } catch (error) { console.error('❌ 上传重塑素材失败:', error); res.status(500).json({ error: `上传重塑素材失败: ${error.message}` }); } }); // ==================== 文件操作 ==================== // GET /api/files/whisper/:videoId — 获取指定视频的 Whisper 输出文件列表 app.get('/api/files/whisper/:videoId', (req, res) => { try { const manifest = readManifest(); const video = manifest.find(v => v.id === req.params.videoId); if (!video) { return res.status(404).json({ error: `未找到视频: ${req.params.videoId}` }); } const baseName = video.filename.replace(/\.[^.]+$/, ''); const outputDir = path.join(WHISPER_DIR, baseName); if (!fs.existsSync(outputDir)) { return res.json({ files: [], exists: false }); } const files = fs.readdirSync(outputDir).map(f => ({ name: f, path: `Whisper/${baseName}/${f}`, size: fs.statSync(path.join(outputDir, f)).size })); res.json({ files, exists: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/files/read — 读取项目内文件内容 app.get('/api/files/read', (req, res) => { const filePath = req.query.path; if (!filePath) { return res.status(400).json({ error: '缺少 path 参数' }); } const fullPath = path.join(PROJECT_ROOT, filePath); // 安全检查:不允许读取项目目录外的文件 if (!fullPath.startsWith(PROJECT_ROOT)) { return res.status(403).json({ error: '路径不在项目目录内' }); } if (!fs.existsSync(fullPath)) { return res.status(404).json({ error: '文件不存在' }); } const ext = path.extname(fullPath).toLowerCase(); if (['.json'].includes(ext)) { res.json(JSON.parse(fs.readFileSync(fullPath, 'utf-8'))); } else { res.type('text/plain').send(fs.readFileSync(fullPath, 'utf-8')); } }); app.post('/api/download/video', (req, res) => { const { url, urls, filename, title, description, tags, thumbnail, duration, resolution, awemeId, authorName } = req.body || {}; if (!url || typeof url !== 'string') { return res.status(400).json({ error: '缺少 url 参数' }); } const candidateUrls = normalizeRemoteUrls(url, urls); if (candidateUrls.length === 0) { return res.status(400).json({ error: '无效的视频地址' }); } const safeFilename = ensureUniqueVideoFilename(ensureVideoFilename(filename || title || awemeId || 'douyin-video.mp4', url)); const filePath = path.join(DATA_VIDEO_DIR, safeFilename); const taskId = `DL-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; const task = { id: taskId, status: 'pending', progress: 0, downloadedBytes: 0, totalBytes: 0, filename: safeFilename, created_at: new Date().toISOString() }; downloadTasks.set(taskId, task); res.json({ success: true, taskId, filename: safeFilename }); (async () => { try { task.status = 'downloading'; console.log(`📥 开始下载远程视频: ${safeFilename} ← ${candidateUrls[0]}`); const { url: resolvedUrl, response } = await fetchRemoteVideoResponse(candidateUrls, { 'Accept': '*/*', 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.douyin.com/', 'Origin': 'https://www.douyin.com' }); task.sourceUrl = resolvedUrl; const totalBytes = Number.parseInt(response.headers.get('content-length') || '0', 10) || 0; task.totalBytes = totalBytes; let downloadedBytes = 0; let chunkCount = 0; const progressStream = new Transform({ transform(chunk, encoding, callback) { downloadedBytes += chunk.length; chunkCount += 1; task.downloadedBytes = downloadedBytes; task.progress = totalBytes > 0 ? Math.min(99, Math.round((downloadedBytes / totalBytes) * 100)) : Math.min(95, Math.max(task.progress || 0, Math.min(95, chunkCount))); callback(null, chunk); } }); await pipeline( Readable.fromWeb(response.body), progressStream, fs.createWriteStream(filePath) ); const stat = fs.statSync(filePath); const manifest = readManifest(); const videoEntry = createManagedVideoEntry({ filename: safeFilename, title, description, tags, thumbnail, duration, resolution, awemeId, authorName, size: stat.size }); manifest.push(videoEntry); writeManifest(manifest); task.status = 'completed'; task.progress = 100; task.completed_at = new Date().toISOString(); task.video = videoEntry; console.log(`✅ 远程视频下载完成: ${safeFilename} (${(stat.size / 1024 / 1024).toFixed(1)}MB)`); } catch (error) { removeFileIfExists(filePath); task.status = 'failed'; task.error = error.message; task.failed_at = new Date().toISOString(); console.error('❌ 远程视频下载失败:', error); } })(); }); app.get('/api/download/video/:taskId', (req, res) => { const task = downloadTasks.get(req.params.taskId); if (!task) { return res.status(404).json({ error: '未找到下载任务' }); } res.json(task); }); // ==================== 视频流代理 ==================== app.get('/api/video-proxy', async (req, res) => { const { url, filename = 'douyin-video.mp4', download } = req.query; if (!url || typeof url !== 'string') { return res.status(400).json({ error: '缺少 url 参数' }); } if (!isSafeRemoteUrl(url)) { return res.status(400).json({ error: '无效的视频地址' }); } try { const upstreamHeaders = { 'Accept': req.headers.accept || '*/*', 'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0', 'Referer': 'https://www.douyin.com/', 'Origin': 'https://www.douyin.com' }; if (req.headers.range) { upstreamHeaders.Range = req.headers.range; } const response = await fetch(url, { method: 'GET', headers: upstreamHeaders, redirect: 'follow' }); if (!response.ok && response.status !== 206) { const errorText = await response.text().catch(() => ''); return res.status(response.status).json({ error: '远程视频请求失败', status: response.status, detail: errorText }); } res.status(response.status); const passthroughHeaders = [ 'content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified', 'cache-control' ]; passthroughHeaders.forEach((headerName) => { const value = response.headers.get(headerName); if (value) { res.setHeader(headerName, value); } }); if (!response.headers.get('content-type')) { res.setHeader('Content-Type', 'video/mp4'); } res.setHeader( 'Content-Disposition', download === '1' ? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}` : `inline; filename*=UTF-8''${encodeURIComponent(filename)}` ); if (!response.body) { return res.end(); } Readable.fromWeb(response.body).pipe(res); } catch (error) { console.error('视频代理失败:', error); res.status(500).json({ error: `视频代理失败: ${error.message}` }); } }); // ==================== AI 重塑记录 ==================== const REMIXES_DIR = path.join(DATA_DIR, 'remixes'); if (!fs.existsSync(REMIXES_DIR)) fs.mkdirSync(REMIXES_DIR, { recursive: true }); function readRemixes(videoId) { const p = path.join(REMIXES_DIR, `${videoId}.json`); if (!fs.existsSync(p)) return []; return JSON.parse(fs.readFileSync(p, 'utf-8')); } function writeRemixes(videoId, data) { fs.writeFileSync(path.join(REMIXES_DIR, `${videoId}.json`), JSON.stringify(data, null, 2), 'utf-8'); } // GET /api/remixes/:videoId — 获取某视频的所有重塑记录 app.get('/api/remixes/:videoId', (req, res) => { res.json(readRemixes(req.params.videoId)); }); // POST /api/remixes/:videoId — 创建或更新一条重塑记录 // body: { remixId, ...remixData } app.post('/api/remixes/:videoId', (req, res) => { const remixes = readRemixes(req.params.videoId); const { remixId } = req.body; const idx = remixes.findIndex(r => r.remixId === remixId); if (idx >= 0) { // 更新已有记录(合并 segments) remixes[idx] = { ...remixes[idx], ...req.body, updated_at: new Date().toISOString() }; } else { // 新增记录 remixes.unshift({ ...req.body, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); } writeRemixes(req.params.videoId, remixes); res.json({ success: true }); }); // DELETE /api/remixes/:videoId/:remixId — 删除某条重塑记录 app.delete('/api/remixes/:videoId/:remixId', (req, res) => { let remixes = readRemixes(req.params.videoId); remixes = remixes.filter(r => r.remixId !== req.params.remixId); writeRemixes(req.params.videoId, remixes); res.json({ success: true }); }); // GET /api/remixes — 获取所有视频的重塑记录汇总 app.get('/api/remixes', (req, res) => { const files = fs.readdirSync(REMIXES_DIR).filter(f => f.endsWith('.json')); const all = {}; files.forEach(f => { const videoId = f.replace('.json', ''); all[videoId] = JSON.parse(fs.readFileSync(path.join(REMIXES_DIR, f), 'utf-8')); }); res.json(all); }); // ==================== 一键成片(Quickly 代理) ==================== const QUICKLY_APP_KEY = 'ZmNmOGRhNjYzZTAx'; const QUICKLY_APP_SECRET = 'eaa12154c248cad9159a9d6ea8bedf46'; const QUICKLY_ACCOUNT_ID = '12859_117409'; const QUICKLY_CALLBACK_URL = 'https://server.fmode.cn/api/functions/cut/onemerge'; const QUICKLY_RELAY_URL = 'https://server.fmode.cn/api/functions'; // POST /api/quickly/create — 代理一键成片请求(服务端签名) app.post('/api/quickly/create', async (req, res) => { try { const { videoUrls, options = {} } = req.body; if (!videoUrls || !Array.isArray(videoUrls) || videoUrls.length === 0) { return res.status(400).json({ error: '缺少 videoUrls 参数' }); } const timestamp = Date.now().toString(); const signStr = timestamp + '#' + QUICKLY_APP_SECRET; const sign = crypto.createHash('md5').update(signStr).digest('hex'); const materialList = videoUrls.map(url => ({ type: 'video', value: url })); const preId = timestamp; const apiBody = { account_id: QUICKLY_ACCOUNT_ID, callback_url: QUICKLY_CALLBACK_URL, material_list: materialList, tags: options.tags || '视频,AI生成', proportion: options.proportion || '9:16', video_duration: options.videoDuration || { min: 10, max: 30 }, pre_id: preId, compose_number: 1, ai_voice: options.aiVoice ?? 1, ai_bgm: options.aiBgm ?? 1, ai_flower: 1, ai_subtitle: options.aiSubtitle ?? 0, original_voice: 0 }; const relayData = JSON.stringify({ apiPath: '/v2/video/vlog/create', apiBody: apiBody, appKey: QUICKLY_APP_KEY, timestamp: timestamp, sign: sign }); const body = JSON.stringify({ action: 'relay', relayData: relayData }); console.log(`📦 一键成片 - timestamp=${timestamp}, sign=${sign}`); console.log(` 材料: ${videoUrls.length} 个视频`); // 使用 Node 原生 fetch 发送请求 const response = await fetch(QUICKLY_RELAY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body }); const result = await response.json(); console.log('📦 一键成片 - 响应:', JSON.stringify(result).substring(0, 300)); res.json(result); } catch (err) { console.error('❌ 一键成片代理失败:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/quickly/query — 代理查询一键成片结果(通过 Parse 云函数) app.post('/api/quickly/query', async (req, res) => { try { const { taskId } = req.body; if (!taskId) return res.status(400).json({ error: '缺少 taskId 参数' }); const body = JSON.stringify({ id: 'sWvRr8RvPT', _ApplicationId: 'ncloudmaster', action: 'query', taskId: taskId }); const QUICKLY_QUERY_URL = 'https://server.fmode.cn/api/functions'; const response = await fetch(QUICKLY_QUERY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body }); const result = await response.json(); res.json(result); } catch (err) { console.error('❌ 一键成片查询失败:', err.message); res.status(500).json({ error: err.message }); } }); // ==================== 视频合成(图片+音频 → 视频)==================== const COMPOSITE_DIR = path.join(DATA_DIR, 'composite'); if (!fs.existsSync(COMPOSITE_DIR)) fs.mkdirSync(COMPOSITE_DIR, { recursive: true }); // 下载远程文件到本地 async function downloadFile(url, destPath) { const response = await fetch(url, { redirect: 'follow' }); if (!response.ok) throw new Error(`下载失败 (${response.status}): ${url}`); const buffer = Buffer.from(await response.arrayBuffer()); fs.writeFileSync(destPath, buffer); return destPath; } // 获取媒体时长(秒)。ffprobe format=duration 同时适用于音频与视频。 function getMediaDuration(mediaPath) { return new Promise((resolve, reject) => { const proc = spawn('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', mediaPath ]); let stdout = ''; proc.stdout.on('data', d => stdout += d.toString()); proc.on('error', reject); proc.on('close', code => { const dur = parseFloat(stdout.trim()); if (code !== 0 || isNaN(dur)) reject(new Error('无法获取媒体时长')); else resolve(dur); }); }); } // 向后兼容别名 const getAudioDuration = getMediaDuration; // 将图片转为 jpg(确保 ffmpeg 兼容性) function convertImageToJpg(inputPath, outputPath) { return new Promise((resolve, reject) => { const proc = spawn('ffmpeg', ['-y', '-i', inputPath, '-frames:v', '1', outputPath]); let stderr = ''; proc.stderr.on('data', d => stderr += d.toString()); proc.on('error', reject); proc.on('close', code => { if (code !== 0) reject(new Error(`图片转换失败: ${stderr.split('\n').filter(Boolean).slice(-1)[0]}`)); else resolve(outputPath); }); }); } // ==================================================================== // 拼接管线统一规格(所有段必须一致,否则 concat -c copy 会失败): // 视频:1920x1080 yuv420p h264_mf // 音频:AAC 192k 44100Hz stereo // ==================================================================== const SEG_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black'; const SEG_AUDIO_ARGS = ['-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '2']; // 单个片段:图片 + 音频 → 视频(case A,原有逻辑) function createSegmentVideo(imagePath, audioPath, outputPath, duration) { return new Promise(async (resolve, reject) => { try { // 先将图片统一转为 jpg 确保兼容性 const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg'; await convertImageToJpg(imagePath, jpgPath); const args = [ '-y', '-loop', '1', '-i', jpgPath, '-i', audioPath, '-c:v', 'h264_mf', ...SEG_AUDIO_ARGS, '-vf', SEG_VF, '-pix_fmt', 'yuv420p', '-t', String(duration), '-shortest', outputPath ]; const proc = spawn('ffmpeg', args); let stderr = ''; proc.stderr.on('data', d => stderr += d.toString()); proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message))); proc.on('close', code => { removeFileIfExists(jpgPath); if (code !== 0) reject(new Error(`片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`)); else resolve(outputPath); }); } catch (err) { reject(err); } }); } /** * Case B: 视频 + 音频 → 替换音轨,**按音频时长对齐**(不是 -shortest)。 * * 对齐策略(音频是叙事主干,宁可冻结画面也不能丢解说): * - videoDuration >= audioDuration + 0.05 → `-t audioDuration` 截断视频 * - videoDuration < audioDuration - 0.05 → `tpad=stop_mode=clone` 用末帧补齐到 audioDuration * - 长度接近 → 直接 `-t audioDuration` * * 输出统一规格(1920x1080 yuv420p)以便后续 concat -c copy。 * * @param {number} audioDuration 音频时长(秒),必填 * @param {number=} videoDuration 视频原时长(秒),可选;缺省时退化为旧的 -shortest 行为 */ function createVideoAudioSegment(videoPath, audioPath, outputPath, audioDuration, videoDuration) { return new Promise((resolve, reject) => { const target = Number(audioDuration); if (!target || target <= 0) { reject(new Error('createVideoAudioSegment 需要正数 audioDuration')); return; } // 构建 -vf 滤镜链。若视频短于音频,append tpad 冻结末帧补齐。 let vfChain = SEG_VF; let needPad = false; if (typeof videoDuration === 'number' && videoDuration > 0 && videoDuration < target - 0.05) { const padSec = (target - videoDuration).toFixed(3); vfChain += `,tpad=stop_mode=clone:stop_duration=${padSec}`; needPad = true; } const args = [ '-y', '-i', videoPath, '-i', audioPath, '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'h264_mf', '-vf', vfChain, '-pix_fmt', 'yuv420p', ...SEG_AUDIO_ARGS, '-t', String(target), outputPath, ]; console.log(` ↳ case B align: audio=${target.toFixed(2)}s, video=${videoDuration ? videoDuration.toFixed(2) + 's' : '?'}, ${needPad ? 'PAD freeze last frame' : 'TRIM/EXACT'}`); const proc = spawn('ffmpeg', args); let stderr = ''; proc.stderr.on('data', d => stderr += d.toString()); proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message))); proc.on('close', code => { if (code !== 0) reject(new Error(`视频+音频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`)); else resolve(outputPath); }); }); } /** * Case C: 纯视频段 → 加静音轨并重编码到统一规格。 * 用于「无解说配音」场景。视频原音轨被丢弃。 */ function createVideoOnlySegment(videoPath, outputPath) { return new Promise((resolve, reject) => { const args = [ '-y', '-i', videoPath, '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'h264_mf', '-vf', SEG_VF, '-pix_fmt', 'yuv420p', ...SEG_AUDIO_ARGS, '-shortest', outputPath, ]; const proc = spawn('ffmpeg', args); let stderr = ''; proc.stderr.on('data', d => stderr += d.toString()); proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message))); proc.on('close', code => { if (code !== 0) reject(new Error(`纯视频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`)); else resolve(outputPath); }); }); } /** * Case D: 图片 + 静音 → 静态画面段(指定时长)。 * 用于「图片 + 无配音」组合,duration 由调用方决定(默认 3s)。 */ function createImageOnlySegment(imagePath, outputPath, duration) { return new Promise(async (resolve, reject) => { try { const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg'; await convertImageToJpg(imagePath, jpgPath); const args = [ '-y', '-loop', '1', '-i', jpgPath, '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'h264_mf', '-vf', SEG_VF, '-pix_fmt', 'yuv420p', ...SEG_AUDIO_ARGS, '-t', String(duration), '-shortest', outputPath, ]; const proc = spawn('ffmpeg', args); let stderr = ''; proc.stderr.on('data', d => stderr += d.toString()); proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message))); proc.on('close', code => { removeFileIfExists(jpgPath); if (code !== 0) reject(new Error(`静音图片片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`)); else resolve(outputPath); }); } catch (err) { reject(err); } }); } /** * 段调度:根据 segment 字段路由到 4 种 case 之一。 * * 输入字段: * - imageUrl?: 图片 URL * - videoUrl?: 视频 URL(如即梦图生视频结果;优先级高于 imageUrl) * - audioUrl?: 音频 URL(可选,缺省视为静音段) * - duration?: 静态图模式下的画面停留秒数(默认 3) * * 路由: * case A: imageUrl + audioUrl → createSegmentVideo(按音频时长) * case B: videoUrl + audioUrl → createVideoAudioSegment(按短的) * case C: videoUrl 无 audioUrl → createVideoOnlySegment(按视频原长) * case D: imageUrl 无 audioUrl → createImageOnlySegment(按 duration) * * @returns {Promise<{ path: string, duration: number|null, mode: 'A'|'B'|'C'|'D' }>} */ async function composeOneSegment({ seg, jobDir, i }) { const segVideoPath = path.join(jobDir, `seg-${i}.mp4`); const hasImage = !!seg.imageUrl; const hasVideo = !!seg.videoUrl; const hasAudio = !!seg.audioUrl; if (!hasImage && !hasVideo) { throw new Error(`片段 ${seg.id != null ? seg.id : i} 缺少 imageUrl/videoUrl`); } // 下载音频(若有) let audioPath = null; let audioDuration = null; if (hasAudio) { const ext = (seg.audioUrl.match(/\.(mp3|wav|aac|ogg|m4a)/i) || ['.mp3'])[0] || '.mp3'; audioPath = path.join(jobDir, `audio-${i}${ext}`); await downloadFile(seg.audioUrl, audioPath); audioDuration = await getAudioDuration(audioPath); } if (hasVideo) { const videoPath = path.join(jobDir, `video-${i}.mp4`); await downloadFile(seg.videoUrl, videoPath); // 探测原视频时长用于音频对齐(trim 或 freeze-last-frame pad); // 探测失败 → 退化为旧的 -shortest 语义,仍能产出可用片段。 let videoDuration; try { videoDuration = await getMediaDuration(videoPath); } catch (e) { console.warn(` ⚠️ 片段 ${i + 1} 探测视频时长失败,退化为 -shortest 对齐:`, e?.message || e); } if (hasAudio) { await createVideoAudioSegment(videoPath, audioPath, segVideoPath, audioDuration, videoDuration); return { path: segVideoPath, duration: audioDuration, mode: 'B' }; } else { await createVideoOnlySegment(videoPath, segVideoPath); return { path: segVideoPath, duration: null, mode: 'C' }; } } // hasImage const ext = (seg.imageUrl.match(/\.(jpg|jpeg|png|webp|gif)/i) || ['.jpg'])[0] || '.jpg'; const imgPath = path.join(jobDir, `img-${i}${ext}`); await downloadFile(seg.imageUrl, imgPath); if (hasAudio) { await createSegmentVideo(imgPath, audioPath, segVideoPath, audioDuration); return { path: segVideoPath, duration: audioDuration, mode: 'A' }; } else { const dur = Math.max(0.5, Number(seg.duration) || 3); await createImageOnlySegment(imgPath, segVideoPath, dur); return { path: segVideoPath, duration: dur, mode: 'D' }; } } // 拼接多个视频片段 function concatVideos(segmentPaths, outputPath) { return new Promise((resolve, reject) => { // 创建 concat 文件列表 const listPath = outputPath + '.txt'; const listContent = segmentPaths.map(p => `file '${p.replace(/\\/g, '/')}'`).join('\n'); fs.writeFileSync(listPath, listContent, 'utf-8'); const args = [ '-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath ]; const proc = spawn('ffmpeg', args); let stderr = ''; proc.stderr.on('data', d => stderr += d.toString()); proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message))); proc.on('close', code => { removeFileIfExists(listPath); if (code !== 0) reject(new Error(`视频拼接失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`)); else resolve(outputPath); }); }); } // POST /api/video/composite — 合成视频(图片+音频 → 最终视频) app.post('/api/video/composite', async (req, res) => { const { segments, title } = req.body; // segments: [{ imageUrl, audioUrl, id }] if (!Array.isArray(segments) || segments.length === 0) { return res.status(400).json({ error: '缺少 segments 参数' }); } const jobId = `VG-${Date.now()}`; const jobDir = path.join(COMPOSITE_DIR, jobId); fs.mkdirSync(jobDir, { recursive: true }); try { console.log(`🎬 开始合成视频: ${jobId}, ${segments.length} 个片段`); // 1. 下载并合成每段(四态分发:image+audio / video+audio / video-only / image-only) const segmentPaths = []; for (let i = 0; i < segments.length; i++) { const seg = segments[i]; if (!seg.imageUrl && !seg.videoUrl) { console.warn(`⚠️ 片段 ${seg.id || i} 缺少 imageUrl/videoUrl,跳过`); continue; } console.log(` 📥 处理片段 ${i + 1}/${segments.length}...`); const { path: segPath, duration, mode } = await composeOneSegment({ seg, jobDir, i }); console.log(` 🎞️ 已完成片段 ${i + 1}/${segments.length} [mode=${mode}${duration ? `, ${duration.toFixed(1)}s` : ''}]`); segmentPaths.push(segPath); } if (segmentPaths.length === 0) { return res.status(400).json({ error: '没有有效的素材片段' }); } // 2. 拼接所有片段 const safeTitle = String(title || 'video').replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').substring(0, 50); const finalFilename = `${safeTitle}-${jobId}.mp4`; const finalPath = path.join(COMPOSITE_DIR, finalFilename); console.log(` 🔗 拼接 ${segmentPaths.length} 个片段...`); if (segmentPaths.length === 1) { // 只有一个片段,直接复制 fs.copyFileSync(segmentPaths[0], finalPath); } else { await concatVideos(segmentPaths, finalPath); } // 3. 清理临时文件 try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {} const fileSize = fs.statSync(finalPath).size; console.log(`✅ 视频合成完成: ${finalFilename} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`); res.json({ success: true, videoUrl: `/api/video/composite/${finalFilename}`, filename: finalFilename, size: fileSize, segments: segmentPaths.length }); } catch (err) { console.error(`❌ 视频合成失败:`, err.message); // 清理 try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {} res.status(500).json({ error: `视频合成失败: ${err.message}` }); } }); /** * POST /api/video/composite/stream — SSE 流式合成端点 * * 与 /api/video/composite 功能一致,但通过 Server-Sent Events 推送实时进度, * 用于 P3/P4 等长耗时合成任务,替换前端的"假定时器进度"。 * * 事件类型: * - event: stage { stage: 'download'|'compose'|'concat'|'cleanup', message } * - event: progress { stage, current, total, percent } * - event: done { videoUrl, filename, size, segments } * - event: error { error } */ app.post('/api/video/composite/stream', async (req, res) => { const { segments, title } = req.body || {}; if (!Array.isArray(segments) || segments.length === 0) { return res.status(400).json({ error: '缺少 segments 参数' }); } // SSE 头 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); const emit = (event, data) => { res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`); }; const jobId = `VG-${Date.now()}`; const jobDir = path.join(COMPOSITE_DIR, jobId); fs.mkdirSync(jobDir, { recursive: true }); // —— 心跳:长时间静默会被代理 / 浏览器空闲 TCP 关掉。 // 每 10 秒写一个 SSE 注释行(`: ...\n\n`),客户端 reader 会忽略它。 let finished = false; const heartbeat = setInterval(() => { if (finished) return; try { res.write(`: hb ${Date.now()}\n\n`); } catch {} }, 10000); // —— 客户端真正断开的检测: // // ❌ 不能用 `req.on('close')`。Node 16+ 中 IncomingMessage 的 close // 事件会在请求体被 express.json() 完整读取后立即触发(这是 Node 行为变更 // 的常见陷阱),导致刚进 loop 就被误判为「客户端断开」。 // // ✅ 正确做法:监听 `res.on('close')`。该事件只在响应连接被对端 // 在 res.end() 之前提前关闭时触发;服务端主动 res.end() 不会触发。 let aborted = false; res.on('close', () => { if (!finished) { aborted = true; console.warn(`⚠️ [SSE] 客户端断开 ${jobId}`); } }); const cleanup = () => { finished = true; clearInterval(heartbeat); }; try { console.log(`🎬 [SSE] 开始合成视频: ${jobId}, ${segments.length} 个片段`); emit('stage', { stage: 'download', message: `开始处理 ${segments.length} 段素材...` }); const segmentPaths = []; const total = segments.length; for (let i = 0; i < total; i++) { if (aborted) throw new Error('客户端已断开'); const seg = segments[i]; if (!seg.imageUrl && !seg.videoUrl) { console.warn(`⚠️ 片段 ${seg.id || i} 缺少 imageUrl/videoUrl,跳过`); continue; } emit('progress', { stage: 'download', current: i + 1, total, percent: Math.round((i / total) * 100), message: `处理素材 ${i + 1}/${total}` }); const { path: segPath, duration, mode } = await composeOneSegment({ seg, jobDir, i }); segmentPaths.push(segPath); const durLabel = duration ? `${duration.toFixed(1)}s` : '原长'; emit('progress', { stage: 'compose', current: i + 1, total, percent: Math.round(((i + 1) / total) * 100), message: `已完成 ${i + 1}/${total} 段(${durLabel}, mode=${mode})` }); } if (segmentPaths.length === 0) { throw new Error('没有有效的素材片段'); } const safeTitle = String(title || 'video').replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').substring(0, 50); const finalFilename = `${safeTitle}-${jobId}.mp4`; const finalPath = path.join(COMPOSITE_DIR, finalFilename); emit('stage', { stage: 'concat', message: `拼接 ${segmentPaths.length} 个片段...` }); if (segmentPaths.length === 1) { fs.copyFileSync(segmentPaths[0], finalPath); } else { await concatVideos(segmentPaths, finalPath); } emit('stage', { stage: 'cleanup', message: '清理临时文件...' }); try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {} const fileSize = fs.statSync(finalPath).size; console.log(`✅ [SSE] 视频合成完成: ${finalFilename} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`); emit('done', { success: true, videoUrl: `/api/video/composite/${finalFilename}`, filename: finalFilename, size: fileSize, segments: segmentPaths.length, }); cleanup(); res.end(); } catch (err) { console.error(`❌ [SSE] 视频合成失败:`, err.message); try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {} emit('error', { error: `视频合成失败: ${err.message}` }); cleanup(); res.end(); } }); // GET /api/video/composite/:filename — 下载合成视频 app.get('/api/video/composite/:filename', (req, res) => { const filePath = path.join(COMPOSITE_DIR, req.params.filename); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: '视频文件不存在' }); } res.setHeader('Content-Type', 'video/mp4'); fs.createReadStream(filePath).pipe(res); }); // ==================== 声音训练辅助:自动选择未训练的 speaker_id ==================== // 已被认为"已训练 / 已占用"的状态枚举(trainStatus 文档中的 State 值) const VOICE_TRAINED_STATES = new Set(['training', 'success', 'active', 'expired', 'reclaimed']); const DEFAULT_VOICE_TOKEN = process.env.VOLC_TTS_TOKEN || 'Bearer r:f0333969e312a40e4703e8fe4ed1c600'; // 从 trainStatus 响应中尽力解析出已被训练 / 占用的 speaker_id 集合 function collectTrainedSpeakerIdsFromResponse(payload) { const found = new Set(); const seen = new WeakSet(); const visit = (node) => { if (!node || typeof node !== 'object') return; if (seen.has(node)) return; seen.add(node); if (Array.isArray(node)) { node.forEach(visit); return; } const speakerId = node.speaker_id || node.SpeakerID || node.speakerId || node.Speaker_id || node.speakerID; const state = node.State || node.state || node.status || node.Status; if (typeof speakerId === 'string' && /^S_[A-Za-z0-9]+$/.test(speakerId.trim())) { const stateLower = String(state || '').trim().toLowerCase(); if (stateLower && VOICE_TRAINED_STATES.has(stateLower)) { found.add(speakerId.trim()); } } Object.values(node).forEach(visit); }; visit(payload); return found; } // POST /api/voice/auto-speaker-id — 自动挑选一个未训练 / 未占用的 speaker_id app.post('/api/voice/auto-speaker-id', async (req, res) => { try { const pool = readVoiceSpeakerIdOptions(); if (pool.length === 0) { return res.status(500).json({ error: '本地未配置 speaker_id 池(docs/音色创建/speaker_id.md 为空)' }); } const profiles = readDataFile('voice-profiles'); const usedLocally = new Set( profiles .map((p) => String(p?.speaker_id || '').trim()) .filter((id) => /^S_[A-Za-z0-9]+$/.test(id)) ); const token = normalizeBearerToken(req.body?.token) || DEFAULT_VOICE_TOKEN; let usedRemotely = new Set(); let remoteOk = false; let remoteError = ''; try { const trainStatusPayload = await requestVolcTtsJson('trainStatus', { token, speakerIdList: pool }); usedRemotely = collectTrainedSpeakerIdsFromResponse(trainStatusPayload); remoteOk = true; } catch (e) { const detail = e?.detail; const detailText = typeof detail === 'string' ? detail : (detail ? JSON.stringify(detail) : ''); remoteError = e?.message || detailText || (typeof e === 'string' ? e : (e ? JSON.stringify(e) : 'trainStatus 调用失败')); } const used = new Set([...usedLocally, ...usedRemotely]); const available = pool.find((id) => !used.has(id)); if (!available) { return res.status(409).json({ error: '当前所有候选 speaker_id 均已被训练或占用,请补充新的 speaker_id', pool, used: [...used], used_local: [...usedLocally], used_remote: [...usedRemotely], remote_check: remoteOk, remote_error: remoteError || undefined }); } res.json({ speaker_id: available, pool, used_local: [...usedLocally], used_remote: [...usedRemotely], remote_check: remoteOk, remote_error: remoteError || undefined }); } catch (err) { res.status(500).json({ error: err?.message || '检测可用 speaker_id 失败' }); } }); // POST /api/voice-profiles/sync — 前端复刻成功后回写本地占用记录 // 临时方案:远程 trainStatus 不稳定,使用 data/voice-profiles.json 作为 speaker_id 占用源 app.post('/api/voice-profiles/sync', (req, res) => { try { const body = req.body || {}; const speakerId = String(body.speaker_id || '').trim(); const timbreId = String(body.timbre_id || body.id || '').trim(); if (!/^S_[A-Za-z0-9]+$/.test(speakerId)) { return res.status(400).json({ error: '缺少有效的 speaker_id' }); } if (!timbreId) { return res.status(400).json({ error: '缺少 timbre_id / id' }); } const nowIso = new Date().toISOString(); const profile = { id: timbreId, name: String(body.name || '').trim() || '未命名音色', creation_mode: 'clone', speaker_id: speakerId, timbre_id: timbreId, demo_audio: String(body.demo_audio || '').trim(), latest_audio_url: String(body.latest_audio_url || body.demo_audio || '').trim(), x_api_resource_id: String(body.x_api_resource_id || '').trim(), icl_speaker_id: String(body.icl_speaker_id || '').trim(), model_version: String(body.model_version || '').trim(), status: body.status === undefined || body.status === null ? null : body.status, status_label: String(body.status_label || '').trim(), message: String(body.message || '').trim(), created_at: body.created_at || nowIso, updated_at: nowIso }; const saved = upsertVoiceProfile(profile); res.json({ success: true, profile: saved }); } catch (err) { res.status(500).json({ error: err?.message || '保存音色记录失败' }); } }); // ==================== 任务管理 ==================== // GET /api/tasks — 获取所有任务 app.get('/api/tasks', (req, res) => { res.json(readDataFile('tasks')); }); // POST /api/tasks — 创建新任务 app.post('/api/tasks', (req, res) => { const tasks = readDataFile('tasks'); const task = { ...req.body, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; // 确保始终有有效 ID(即使前端传了空 ID) if (!task.id) task.id = `TASK-${Date.now()}`; tasks.unshift(task); writeDataFile('tasks', tasks); res.json({ success: true, task }); }); // PUT /api/tasks/:id — 更新任务 app.put('/api/tasks/:id', (req, res) => { const tasks = readDataFile('tasks'); const idx = tasks.findIndex(t => t.id === req.params.id); if (idx === -1) return res.status(404).json({ error: `任务不存在: ${req.params.id}` }); tasks[idx] = { ...tasks[idx], ...req.body, updated_at: new Date().toISOString() }; writeDataFile('tasks', tasks); res.json({ success: true, task: tasks[idx] }); }); // DELETE /api/tasks/:id — 删除任务 app.delete('/api/tasks/:id', (req, res) => { let tasks = readDataFile('tasks'); const len = tasks.length; tasks = tasks.filter(t => t.id !== req.params.id); if (tasks.length === len) return res.status(404).json({ error: `任务不存在: ${req.params.id}` }); writeDataFile('tasks', tasks); res.json({ success: true }); }); // ==================== 生成历史 ==================== // GET /api/history — 获取历史记录 app.get('/api/history', (req, res) => { res.json(readDataFile('history')); }); // POST /api/history — 添加历史记录 app.post('/api/history', (req, res) => { const history = readDataFile('history'); const record = { id: `HIS-${Date.now()}`, created_at: new Date().toISOString(), ...req.body }; history.unshift(record); writeDataFile('history', history); res.json({ success: true, record }); }); // DELETE /api/history/:id — 删除历史记录 app.delete('/api/history/:id', (req, res) => { let history = readDataFile('history'); const len = history.length; history = history.filter(h => h.id !== req.params.id); if (history.length === len) return res.status(404).json({ error: `记录不存在: ${req.params.id}` }); writeDataFile('history', history); res.json({ success: true }); }); // DELETE /api/history — 清空所有历史 app.delete('/api/history', (req, res) => { writeDataFile('history', []); res.json({ success: true }); }); // ==================== 生成结果 ==================== // GET /api/results — 获取所有结果 app.get('/api/results', (req, res) => { res.json(readDataFile('results')); }); // POST /api/results — 添加结果 app.post('/api/results', (req, res) => { const results = readDataFile('results'); const result = { id: `RES-${Date.now()}`, created_at: new Date().toISOString(), ...req.body }; results.unshift(result); writeDataFile('results', results); res.json({ success: true, result }); }); // PUT /api/results/:id — 更新结果 app.put('/api/results/:id', (req, res) => { const results = readDataFile('results'); const idx = results.findIndex(r => r.id === req.params.id); if (idx === -1) return res.status(404).json({ error: `结果不存在: ${req.params.id}` }); results[idx] = { ...results[idx], ...req.body }; writeDataFile('results', results); res.json({ success: true, result: results[idx] }); }); // DELETE /api/results/:id — 删除结果 app.delete('/api/results/:id', (req, res) => { let results = readDataFile('results'); const len = results.length; results = results.filter(r => r.id !== req.params.id); if (results.length === len) return res.status(404).json({ error: `结果不存在: ${req.params.id}` }); writeDataFile('results', results); res.json({ success: true }); }); // ==================== LLM 大模型代理 ==================== const LLM_BASE_URL = 'http://server.fmode.cn:9999'; const LLM_API_KEY = 'sk-MFBOnsAtZiqlwwMgMLKCFmPy55pMohQEGMqsIw3aJrIgvoEO'; // POST /api/llm/chat — OpenAI ChatCompletions 代理 app.post('/api/llm/chat', async (req, res) => { try { const { model, messages, temperature, max_tokens, stream, ...rest } = req.body; if (!messages || !Array.isArray(messages)) { return res.status(400).json({ error: '缺少 messages 参数' }); } const payload = { model: model || 'gpt-4o-mini', messages, temperature: temperature ?? 0.7, max_tokens: max_tokens || 4096, stream: stream || false, ...rest }; const url = `${LLM_BASE_URL}/v1/chat/completions`; console.log(`🤖 LLM Chat 请求: model=${payload.model}, messages=${messages.length}条`); if (payload.stream) { // 流式响应 const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${LLM_API_KEY}` }, body: JSON.stringify(payload) }); if (!response.ok) { const errText = await response.text(); console.error('🤖 LLM Stream 错误:', response.status, errText); return res.status(response.status).json({ error: errText }); } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const reader = response.body; reader.on('data', (chunk) => res.write(chunk)); reader.on('end', () => res.end()); reader.on('error', (err) => { console.error('🤖 LLM Stream 读取错误:', err.message); res.end(); }); } else { // 非流式响应 const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${LLM_API_KEY}` }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok) { console.error('🤖 LLM Chat 错误:', response.status, data); return res.status(response.status).json(data); } console.log(`🤖 LLM Chat 完成: tokens=${data?.usage?.total_tokens || '?'}`); res.json(data); } } catch (err) { console.error('🤖 LLM Chat 异常:', err.message); res.status(500).json({ error: `LLM 请求失败: ${err.message}` }); } }); // POST /api/llm/gemini — Gemini 原生格式代理(支持媒体识别) app.post('/api/llm/gemini', async (req, res) => { try { const { model, contents, generationConfig, safetySettings, systemInstruction } = req.body; if (!contents) { return res.status(400).json({ error: '缺少 contents 参数' }); } const geminiModel = model || 'gemini-2.5-flash'; const url = `${LLM_BASE_URL}/v1beta/models/${geminiModel}:generateContent`; const payload = { contents }; if (generationConfig) payload.generationConfig = generationConfig; if (safetySettings) payload.safetySettings = safetySettings; if (systemInstruction) payload.systemInstruction = systemInstruction; console.log(`🤖 Gemini 请求: model=${geminiModel}, parts=${contents?.[0]?.parts?.length || 0}`); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${LLM_API_KEY}` }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok) { console.error('🤖 Gemini 错误:', response.status, data); return res.status(response.status).json(data); } console.log(`🤖 Gemini 完成: tokens=${data?.usageMetadata?.totalTokenCount || '?'}`); res.json(data); } catch (err) { console.error('🤖 Gemini 异常:', err.message); res.status(500).json({ error: `Gemini 请求失败: ${err.message}` }); } }); // ==================== 健康检查 ==================== app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), project: PROJECT_ROOT, services: { manifest: fs.existsSync(MANIFEST_PATH), whisperDir: fs.existsSync(WHISPER_DIR), videoDir: fs.existsSync(DATA_VIDEO_DIR) || fs.existsSync(LEGACY_VIDEO_DIR) } }); }); // ==================== 启动 ==================== app.listen(PORT, () => { console.log(''); console.log('========================================'); console.log(` 🚀 后端服务已启动: http://localhost:${PORT}`); console.log(` 📁 项目根目录: ${PROJECT_ROOT}`); console.log(` 📋 接口列表:`); console.log(` GET /api/health — 健康检查`); console.log(` GET /api/whisper/status — Whisper 可用性`); console.log(` POST /api/whisper/transcribe — 语音转文字`); console.log(` GET /api/manifest — 获取视频清单`); console.log(` PUT /api/manifest/:videoId — 更新视频信息`); console.log(` POST /api/manifest — 添加视频条目`); console.log(` GET /api/files/whisper/:id — Whisper 输出文件`); console.log(` GET /api/files/read?path= — 读取项目文件`); console.log(` POST /api/upload/video — 上传视频文件`); console.log(` POST /api/remix/extract-audio — 提取视频音频`); console.log(` POST /api/remix/upload-asset — 上传重塑素材到 Parse`); console.log(` POST /api/download/video — 下载远程视频到本地库`); console.log(` GET /api/download/video/:id — 查询下载任务进度`); console.log(` ---`); console.log(` GET /api/tasks — 获取任务列表`); console.log(` POST /api/tasks — 创建任务`); console.log(` PUT /api/tasks/:id — 更新任务`); console.log(` DEL /api/tasks/:id — 删除任务`); console.log(` ---`); console.log(` GET /api/history — 获取历史记录`); console.log(` POST /api/history — 添加历史`); console.log(` DEL /api/history/:id — 删除历史`); console.log(` DEL /api/history — 清空历史`); console.log(` ---`); console.log(` GET /api/results — 获取结果`); console.log(` POST /api/results — 添加结果`); console.log(` PUT /api/results/:id — 更新结果`); console.log(` DEL /api/results/:id — 删除结果`); console.log(` ---`); console.log(` POST /api/llm/chat — LLM 对话(OpenAI格式)`); console.log(` POST /api/llm/gemini — Gemini 媒体识别`); console.log('========================================'); console.log(''); });