const express = require('express'); const { spawn, exec } = 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 runtimeConfig = require('./server/config/runtime-config'); const { registerHealthRoutes } = require('./server/routes/health'); const { registerFileRoutes } = require('./server/routes/files'); const { registerVideoProxyRoutes } = require('./server/routes/video-proxy'); const { registerLlmRoutes } = require('./server/routes/llm'); const { registerQuicklyRoutes } = require('./server/routes/quickly'); const { registerUploadRoutes } = require('./server/routes/upload'); const { registerAudioExtractionRoutes } = require('./server/routes/audio-extraction'); const { registerVideoDownloadRoutes } = require('./server/routes/video-download'); const { registerVideoCompositeRoutes } = require('./server/routes/video-composite'); const { registerWhisperRoutes } = require('./server/routes/whisper'); const { registerRemixAssetUploadRoutes } = require('./server/routes/remix-asset-upload'); const { printStartupLog } = require('./server/startup-log'); const app = express(); const PORT = runtimeConfig.port; 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 transcriptTasks = new Map(); const TRANSCRIPT_TEMP_DIR = path.join(DATA_DIR, 'douyin-transcripts'); const DOUYIN_API_BASE_URL = runtimeConfig.douyinApiBaseUrl; const TRANSCRIPTION_GATEWAY_BASE_URL = runtimeConfig.transcriptionGatewayBaseUrl; const DOUYIN_GATEWAY_MAX_ATTEMPTS = runtimeConfig.douyinGatewayMaxAttempts; const DOUYIN_API_ROUTES = { searchVideos: { method: 'POST', path: '/douyin/search/fetch_general_search_v2' }, challengeSearch: { method: 'POST', path: '/douyin/search/fetch_challenge_search_v2' }, videoDetail: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_v3' }, videoByShareUrl: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_by_share_url' }, userProfileWeb: { method: 'GET', path: '/douyin/web/handler_user_profile_v2' }, userProfileApp: { method: 'GET', path: '/douyin/app/v3/handler_user_profile' }, userPosts: { method: 'GET', path: '/douyin/app/v3/fetch_user_post_videos' }, comments: { method: 'GET', path: '/douyin/app/v3/fetch_video_comments' }, replies: { method: 'GET', path: '/douyin/app/v3/fetch_video_comment_replies' }, }; const QINIU_ACCESS_KEY = runtimeConfig.qiniu.accessKey; const QINIU_SECRET_KEY = runtimeConfig.qiniu.secretKey; const QINIU_BUCKET = runtimeConfig.qiniu.bucket; const QINIU_CDN_DOMAIN = runtimeConfig.qiniu.cdnDomain; const QINIU_CDN_PREFIX = runtimeConfig.qiniu.cdnPrefix; const QINIU_UPLOAD_URL = runtimeConfig.qiniu.uploadUrl; const VOLC_SPEECH_BASE_URL = runtimeConfig.volc.speechBaseUrl; const VOLC_TTS_PROXY_BASE_URL = runtimeConfig.volc.ttsProxyBaseUrl; const VOLC_SPEECH_API_KEY = runtimeConfig.volc.speechApiKey; const VOLC_TTS_RESOURCE_ID = runtimeConfig.volc.ttsResourceId; const VOLC_SPEECH_APP_KEY = runtimeConfig.volc.speechAppKey; const VOLC_SPEECH_ACCESS_KEY = runtimeConfig.volc.speechAccessKey; 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) || runtimeConfig.voice.defaultToken, 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) || runtimeConfig.voice.defaultToken, 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 || body?.volcengine_voice_type || body?.voiceType || body?.voice_type || '').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 数据文件读写 app.post('/api/volcengine/tts/unidirectional', async (req, res) => { try { const payload = normalizeVoiceSynthesisPayload(req.body || {}); const { response } = await executeVoiceSynthesisRequest(payload); res.json(response); } catch (error) { res.status(error.status || error.statusCode || 500).json({ code: error.status || error.statusCode || 500, success: false, error: error.message || 'TTS 请求失败', detail: error.detail || '', }); } }); app.post('/api/volcengine/tts/voice_clone', async (req, res) => { try { const payload = normalizeVoiceClonePayload(req.body || {}); const { response } = await executeVoiceCloneRequest(payload); res.json(response); } catch (error) { res.status(error.status || error.statusCode || 500).json({ code: error.status || error.statusCode || 500, success: false, error: error.message || '音色复刻请求失败', detail: error.detail || '', }); } }); const LOCAL_JIMENG_ALLOWED_ENDPOINTS = new Set([ 'getClothesV2', 'getImgByImg', 'getImgV4', 'getText2ImgV3', 'getText2ImgV31', 'getImg2ImgV3', 'getImgV4_pod', 'getImgV4_goods', 'getInpaint', 'getSuperResolution', 'getVideoV3_720p', 'getVideoV3_1080p', 'getVideoV3_Pro', 'getActor', 'getActorV2', 'getDataByTask02', 'getOhIdentifyMain', 'getOhDateByTask', 'getOhDetectMain', 'getOmniHuman', ]); const LOCAL_JIMENG_CHARGEABLE_ENDPOINTS = new Set([ 'getImgV4', 'getText2ImgV3', 'getText2ImgV31', 'getImg2ImgV3', 'getImgV4_pod', 'getImgV4_goods', 'getInpaint', 'getSuperResolution', 'getVideoV3_720p', 'getVideoV3_1080p', 'getVideoV3_Pro', 'getActor', 'getActorV2', 'getOmniHuman', ]); app.post('/api/volcengine/jimeng/:endpoint', async (req, res) => { const endpoint = String(req.params.endpoint || '').trim(); if (!LOCAL_JIMENG_ALLOWED_ENDPOINTS.has(endpoint)) { return res.status(400).json({ code: 400, success: false, error: '不支持的即梦接口' }); } if (LOCAL_JIMENG_CHARGEABLE_ENDPOINTS.has(endpoint)) { const sessionToken = String(req.headers['x-parse-session-token'] || req.body?.sessionToken || '').trim(); if (!sessionToken) { return res.status(401).json({ code: 401, success: false, error: 'Jimeng generation requires a logged-in Parse session for APIG billing.', }); } return res.status(409).json({ code: 409, success: false, error: 'Chargeable Jimeng generation must go through cloud-functions/11-jimengManager.js for APIG billing.', }); } try { const token = runtimeConfig.normalizeBearerToken( req.body?.token || process.env.JIMENG_TOKEN || process.env.VOLC_JIMENG_TOKEN || process.env.VOICE_TOKEN ); if (!token) { return res.status(400).json({ code: 400, success: false, error: '未配置 JIMENG_TOKEN、VOLC_JIMENG_TOKEN 或 VOICE_TOKEN' }); } const baseUrl = String(process.env.JIMENG_BASE_URL || 'https://server.fmode.cn/api/volcengine/jimeng').replace(/\/+$/, ''); const payload = stripEmpty({ ...(req.body || {}), token }); const data = await requestJimengGatewayWithRetry(`${baseUrl}/${endpoint}`, payload, endpoint); res.json(data); } catch (error) { res.status(error.status || 500).json({ code: error.status || 500, success: false, error: error.message || '即梦请求失败', detail: error.detail || '', }); } }); app.get('/api/volcengine/jimeng/work/:workId', async (req, res) => { try { const workId = String(req.params.workId || '').trim(); if (!workId) return res.status(400).json({ code: 400, success: false, error: '缺少作品 ID' }); const response = await fetch(`${runtimeConfig.parseApiHost}/parse/classes/ImagineWork/${encodeURIComponent(workId)}`, { headers: { 'X-Parse-Application-Id': runtimeConfig.parseAppId }, }); const { rawText, payload } = await readFetchResponse(response); if (!response.ok) { return res.status(response.status).json({ code: response.status, success: false, error: payload?.error || rawText || '查询作品结果失败', }); } res.json({ code: 200, success: true, data: payload }); } catch (error) { res.status(500).json({ code: 500, success: false, error: error.message || '查询作品结果失败' }); } }); async function requestJimengGatewayWithRetry(url, body, endpoint) { let lastError = null; const maxAttempts = Math.max(1, Number(process.env.JIMENG_LOCAL_MAX_ATTEMPTS || 3)); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const { rawText, payload } = await readFetchResponse(response); const status = Number(payload?.code || response.status || 0); const retryable = !response.ok || status === 408 || status === 429 || status >= 500; if (!retryable || attempt >= maxAttempts) { if (!response.ok) { const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || `HTTP ${response.status}`); error.status = response.status; error.detail = payload || rawText || ''; throw error; } return payload || { code: 500, success: false, error: '即梦返回异常' }; } await sleep(Math.min(12000, 1200 * attempt * attempt)); } catch (error) { lastError = error; if (attempt >= maxAttempts) break; await sleep(Math.min(12000, 1200 * attempt * attempt)); } } const message = formatNetworkError(lastError); const error = new Error(`即梦数据上游请求失败:${message};endpoint=${endpoint}`); error.status = lastError?.status || 500; error.detail = lastError?.detail || ''; throw error; } 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 转录 ==================== registerWhisperRoutes(app, { exec, fs, path, projectRoot: PROJECT_ROOT, whisperDir: WHISPER_DIR, readManifest, writeManifest, resolveVideoPath }); // ==================== Manifest 管理 ==================== // 已迁移至云函数 manifestManager(cloud-functions/01-manifestManager.js);历史本地参考见 server/routes/manifest-legacy-reference.js。 // ==================== 视频上传 ==================== registerUploadRoutes(app, { upload, readManifest, writeManifest }); // 上传错误处理 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(); }); registerAudioExtractionRoutes(app, { readManifest, resolveVideoPath, removeFileIfExists, dataRemixAssetDir: DATA_REMIX_ASSET_DIR, projectRoot: PROJECT_ROOT }); registerRemixAssetUploadRoutes(app, { path, assetUpload, qiniuUploadUrl: QINIU_UPLOAD_URL, qiniuCdnDomain: QINIU_CDN_DOMAIN, buildDigitalHumanAssetKey, buildQiniuUploadToken }); // ==================== 文件操作 ==================== registerFileRoutes(app, { fs, path, projectRoot: PROJECT_ROOT, whisperDir: WHISPER_DIR, readManifest }); registerVideoDownloadRoutes(app, { path, fs, crypto, Readable, Transform, pipeline, dataVideoDir: DATA_VIDEO_DIR, downloadTasks, normalizeRemoteUrls, ensureVideoFilename, ensureUniqueVideoFilename, fetchRemoteVideoResponse, createManagedVideoEntry, readManifest, writeManifest, removeFileIfExists }); // ==================== 视频流代理 ==================== registerVideoProxyRoutes(app, { isSafeRemoteUrl }); // ==================== AI 重塑记录 ==================== // ==================== AI 重塑记录 ==================== // 已迁移至云函数 remixManager(cloud-functions/05-remixManager.js);历史本地参考见 server/routes/cloud-managed-legacy-reference.js。 // ==================== 一键成片(Quickly 代理) ==================== registerQuicklyRoutes(app, runtimeConfig.quickly); // ==================== 视频合成(图片+音频 → 视频)==================== registerVideoCompositeRoutes(app, { fs, path, spawn, dataDir: DATA_DIR, removeFileIfExists }); // ==================== 语音管理 ==================== // 已迁移至云函数 voiceManager(cloud-functions/06-voiceManager.js);历史本地参考见 server/routes/cloud-managed-legacy-reference.js。 // ==================== 任务/历史/结果 ==================== // 已迁移至云函数 taskManager/historyManager/resultManager;历史本地参考见 server/routes/cloud-managed-legacy-reference.js。 // ==================== LLM 大模型代理 ==================== registerLlmRoutes(app, { llmBaseUrl: runtimeConfig.llm.baseUrl, llmApiKey: runtimeConfig.llm.apiKey }); // ==================== Douyin transcript worker ==================== function cleanText(value) { return String(value || '').trim(); } function getVocToken() { return cleanText( process.env.DOUYIN_API_TOKEN || process.env.VOC_TOKEN || process.env.TRANSCRIPTION_VOC_TOKEN || process.env.VOICE_TOKEN || process.env.OPENCLAW_VOC_TOKEN || process.env.VOC_SOCIAL_TOKEN ); } function bearerAuth(token) { const value = cleanText(token); return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`; } function stripEmpty(value) { if (!value || typeof value !== 'object') return value; const out = Array.isArray(value) ? [] : {}; for (const [key, val] of Object.entries(value)) { if (val === undefined || val === null || val === '') continue; if (val && typeof val === 'object' && !Array.isArray(val)) { const nested = stripEmpty(val); if (Object.keys(nested).length) out[key] = nested; } else { out[key] = val; } } return out; } function readGatewayError(data, fallback) { return data?.error?.message || data?.error || data?.mess || data?.message || data?.msg || data?.detail || fallback || ''; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function formatNetworkError(error) { const cause = error?.cause?.code || error?.cause?.message || ''; return `${error?.message || 'fetch failed'}${cause ? `;cause=${cause}` : ''}`; } async function fetchDouyinGatewayWithRetry(url, init, routePath) { let lastError = null; const attemptLimit = /fetch_video_comments|fetch_video_comment_replies/.test(routePath) ? Math.max(DOUYIN_GATEWAY_MAX_ATTEMPTS, 6) : DOUYIN_GATEWAY_MAX_ATTEMPTS; for (let attempt = 1; attempt <= attemptLimit; attempt += 1) { try { const response = await fetch(url, init); if (response.ok || response.status < 500 || attempt >= attemptLimit) { return response; } console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${attemptLimit} returned HTTP ${response.status}`); await response.body?.cancel().catch(() => {}); } catch (error) { lastError = error; const reason = formatNetworkError(error); console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${attemptLimit} failed: ${reason}`); } if (attempt < attemptLimit) { const delayMs = /fetch_video_comments|fetch_video_comment_replies/.test(routePath) ? Math.min(700 * attempt, 3000) : Math.min(300 * attempt, 1200); await sleep(delayMs); } } throw lastError || new Error('fetch failed'); } async function requestDouyinGateway(routeName, params = {}, payload = {}) { const route = DOUYIN_API_ROUTES[routeName]; if (!route) { throw Object.assign(new Error('不支持的抖音接口'), { statusCode: 400 }); } const token = getVocToken(); if (!token) { throw Object.assign(new Error('本地抖音数据网关未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN。'), { statusCode: 400 }); } const url = new URL(`${DOUYIN_API_BASE_URL}${route.path}`); const init = { method: route.method, headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: bearerAuth(token), }, }; if (route.method === 'GET') { const query = { ...(payload || {}), ...(params || {}) }; if (routeName === 'comments' || routeName === 'replies') { query.count = Math.min(Math.max(Number(query.count) || 20, 1), 20); } for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null && value !== '') { url.searchParams.set(key, String(value)); } } } else { init.body = JSON.stringify(stripEmpty(payload || {})); } let response; try { response = await fetchDouyinGatewayWithRetry(url.toString(), init, route.path); } catch (error) { throw Object.assign(new Error(`本地抖音数据网关网络请求失败:${formatNetworkError(error)};attempts=${DOUYIN_GATEWAY_MAX_ATTEMPTS};base=${DOUYIN_API_BASE_URL} route=${route.path}`), { statusCode: 502, }); } const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch {} if (!response.ok || data?.success === false) { const upstreamError = readGatewayError(data, text) || '上游未返回错误详情'; throw Object.assign(new Error(`抖音数据接口请求失败:${upstreamError};HTTP ${response.status};base=${DOUYIN_API_BASE_URL} route=${route.path}`), { statusCode: response.status || 500, }); } return data || { code: 500, success: false, error: '服务返回异常' }; } app.post('/api/douyin/call', async (req, res) => { try { const { route, params = {}, payload = {}, optional = false } = req.body || {}; const data = await requestDouyinGateway(route, params, payload); res.json({ success: true, data }); } catch (error) { if (req.body?.optional) { return res.json({ success: false, optional: true, error: error.message || '本地抖音数据网关调用失败', }); } res.status(error.statusCode || 500).json({ success: false, error: error.message || '本地抖音数据网关调用失败', }); } }); app.get('/api/douyin/diagnose', async (req, res) => { const routeName = cleanText(req.query.route || 'videoDetail'); const route = DOUYIN_API_ROUTES[routeName] || DOUYIN_API_ROUTES.videoDetail; const result = { success: true, data: { baseUrl: DOUYIN_API_BASE_URL, routeName, routePath: route.path, tokenConfigured: !!getVocToken(), probe: null, }, }; if (String(req.query.probe || '') === '1') { try { result.data.probe = await requestDouyinGateway(routeName, { aweme_id: cleanText(req.query.awemeId) || '7592116912205630761' }); } catch (error) { result.data.probe = { success: false, error: error.message || '探测失败' }; } } res.json(result); }); function createTranscriptJob(input) { const now = new Date().toISOString(); return { id: `transcript_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`, awemeId: cleanText(input.awemeId), analysisId: cleanText(input.analysisId), provider: cleanText(input.provider) || 'iflytek-gateway', status: 'queued', stageLabel: '已创建转写任务', progress: 0, warnings: [], createdAt: now, updatedAt: now }; } function updateTranscriptJob(job, patch) { Object.assign(job, patch, { updatedAt: new Date().toISOString() }); transcriptTasks.set(job.id, job); return job; } function findAwemeDetail(node, depth = 0) { if (!node || depth > 8) return null; if (Array.isArray(node)) { for (const item of node) { const found = findAwemeDetail(item, depth + 1); if (found) return found; } return null; } if (typeof node !== 'object') return null; if (node.aweme_id && node.video) return node; if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail; if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info; for (const value of Object.values(node)) { const found = findAwemeDetail(value, depth + 1); if (found) return found; } return null; } function extractAwemeId(value) { const text = cleanText(value); const patterns = [/aweme_id=(\d+)/, /modal_id=(\d+)/, /douyin\.com\/video\/(\d+)/, /douyin\.com\/share\/video\/(\d+)/, /\b(\d{15,25})\b/]; for (const pattern of patterns) { const match = text.match(pattern); if (match?.[1]) return match[1]; } return /^\d{15,25}$/.test(text) ? text : ''; } function decodeMaybeBase64Url(value) { const text = cleanText(value); if (/^https?:\/\//i.test(text)) return text; if (!/^[A-Za-z0-9+/=_-]{20,}$/.test(text)) return ''; try { const decoded = Buffer.from(text, 'base64').toString('utf8'); return /^https?:\/\//i.test(decoded) ? decoded : ''; } catch { return ''; } } function isLikelyMediaUrl(url) { return /^https?:\/\//i.test(url) && !/\.(?:jpg|jpeg|png|webp|gif)(?:\?|$)/i.test(url); } function inferMediaKind(pathParts, url) { const joined = pathParts.join('.').toLowerCase(); if (/audio|mp4a|music|sound/.test(joined) || /audio|mp4a/i.test(url)) return 'audio'; if (/play_addr|download_addr|bit_rate|video|media/.test(joined) || isLikelyMediaUrl(url)) return 'video'; return 'unknown'; } function collectMediaCandidates(node, pathParts = [], out = []) { if (!node) return out; if (Array.isArray(node)) { node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out)); return out; } if (typeof node !== 'object') return out; for (const [key, value] of Object.entries(node)) { const nextPath = [...pathParts, key]; if (key === 'url_list' && Array.isArray(value)) { value.forEach((item, index) => { const url = decodeMaybeBase64Url(item); const kind = inferMediaKind(nextPath, url); if (url && ['audio', 'video'].includes(kind)) { out.push({ url, kind, keyPath: nextPath.join('.'), index, dataSize: Number(node.data_size || node.size || 0), bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0) }); } }); } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') { const url = decodeMaybeBase64Url(value); const kind = inferMediaKind(nextPath, url); if (url && ['audio', 'video'].includes(kind)) { out.push({ url, kind, keyPath: nextPath.join('.'), dataSize: Number(node.data_size || node.size || 0), bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0) }); } } collectMediaCandidates(value, nextPath, out); } return out; } function selectMediaCandidate(detail) { const seen = new Set(); const candidates = collectMediaCandidates(detail) .filter(item => isLikelyMediaUrl(item.url)) .filter(item => { if (seen.has(item.url)) return false; seen.add(item.url); return true; }); candidates.sort((a, b) => { const aPreferred = a.kind === 'audio' ? 0 : 1; const bPreferred = b.kind === 'audio' ? 0 : 1; if (aPreferred !== bPreferred) return aPreferred - bPreferred; const rank = item => { const keyPath = String(item.keyPath || '').toLowerCase(); const url = String(item.url || '').toLowerCase(); if (/strong_beat|beats_tr|music\./.test(`${keyPath} ${url}`)) return 5; if (item.kind === 'audio' && keyPath.includes('video.dynamic_audio_list')) return 0; if (item.kind === 'audio' && keyPath.includes('video.bit_rate_audio')) return 1; if (item.kind === 'audio' && keyPath.includes('music.')) return 4; return 2; }; const aRank = rank(a); const bRank = rank(b); if (aRank !== bRank) return aRank - bRank; return Number(b.dataSize || 0) - Number(a.dataSize || 0); }); return candidates[0] || null; } function durationFromDetail(detail) { const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs; const numeric = Number(raw || 0); if (!Number.isFinite(numeric) || numeric <= 0) return 0; return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000); } function extensionFromUrl(url, fallback) { try { const parsed = new URL(url); const ext = path.extname(parsed.pathname).toLowerCase(); if (/^\.(mp4|m4a|mp3|wav|aac|mov|webm)$/i.test(ext)) return ext; } catch {} return fallback; } async function fetchTranscriptUpstream(url, init, timeoutMs, label) { try { return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } catch (error) { throw new Error(`${label}失败:${formatNetworkError(error)}`); } } async function fetchDouyinDetailForTranscript(input) { const supplied = findAwemeDetail(input.detail); if (supplied?.aweme_id && supplied.video) return supplied; const token = getVocToken(); if (!token) { throw Object.assign(new Error('未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN,无法获取抖音视频详情。'), { status: 'needs_provider_config' }); } const awemeId = cleanText(input.awemeId) || extractAwemeId(input.sourceUrl || input.url); if (!awemeId) { throw Object.assign(new Error('缺少 awemeId,无法获取视频详情。'), { status: 'needs_media' }); } const url = new URL(`${DOUYIN_API_BASE_URL}/douyin/app/v3/fetch_one_video_v3`); url.searchParams.set('aweme_id', awemeId); const response = await fetchTranscriptUpstream(url.toString(), { method: 'GET', headers: { Accept: 'application/json', Authorization: bearerAuth(token) } }, 45000, '获取抖音视频详情'); const data = await response.json().catch(() => ({})); if (!response.ok || data.success === false) { throw new Error(data.error?.message || data.error || data.message || data.mess || `抖音详情获取失败 HTTP ${response.status}`); } const detail = findAwemeDetail(data); if (!detail?.aweme_id) { throw Object.assign(new Error('抖音详情响应中未找到 aweme_detail。'), { status: 'needs_media' }); } return detail; } async function downloadTranscriptMedia(candidate, job) { fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true }); const ext = extensionFromUrl(candidate.url, candidate.kind === 'audio' ? '.m4a' : '.mp4'); const filePath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}-source${ext}`); const { response } = await fetchRemoteVideoResponse([candidate.url], { Accept: '*/*', 'User-Agent': 'Mozilla/5.0', Referer: 'https://www.douyin.com/', Origin: 'https://www.douyin.com' }); await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath)); return filePath; } function extractAudioForTranscript(inputPath, job) { return new Promise((resolve, reject) => { fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true }); const outputPath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}.m4a`); const args = ['-y', '-i', inputPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath]; const proc = spawn('ffmpeg', args, { cwd: PROJECT_ROOT }); let stderr = ''; proc.stderr.on('data', data => { stderr += data.toString(); }); proc.on('error', err => { reject(Object.assign(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并加入 PATH。' : err.message), { status: 'needs_media_processing' })); }); proc.on('close', code => { if (code !== 0 || !fs.existsSync(outputPath)) { const lastLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || ''; reject(Object.assign(new Error(`音频提取失败${lastLine ? `:${lastLine}` : ''}`), { status: 'needs_media_processing' })); return; } resolve(outputPath); }); }); } async function uploadTranscriptGateway(filePath, durationMs) { const token = getVocToken(); if (!token) { throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。'), { status: 'needs_provider_config' }); } if (!durationMs) { throw Object.assign(new Error('缺少音频时长 durationMs,无法提交转写网关。'), { status: 'needs_media' }); } const buffer = fs.readFileSync(filePath); const form = new FormData(); form.append('audio', new Blob([buffer], { type: 'audio/mp4' }), path.basename(filePath)); form.append('durationMs', String(durationMs)); form.append('roleType', '1'); form.append('roleNum', '0'); const response = await fetchTranscriptUpstream(`${TRANSCRIPTION_GATEWAY_BASE_URL}/upload`, { method: 'POST', headers: { Authorization: bearerAuth(token), Accept: 'application/json' }, body: form }, 120000, '提交转写任务'); const data = await response.json().catch(() => ({})); if (!response.ok || data.success === false) { throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${response.status}`); } const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId; if (!orderId) throw new Error('转写网关未返回 orderId。'); return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0) }; } async function queryTranscriptGateway(orderId) { const token = getVocToken(); if (!token) { throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'), { status: 'needs_provider_config' }); } const response = await fetchTranscriptUpstream(`${TRANSCRIPTION_GATEWAY_BASE_URL}/result`, { method: 'POST', headers: { Authorization: bearerAuth(token), Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId }) }, 45000, '查询转写结果'); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${response.status}`); } return data; } function gatewayValue(data, key) { return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key]; } function normalizeSegmentTime(value) { const number = Number(value); if (!Number.isFinite(number)) return null; return number > 1000 ? number / 1000 : number; } function normalizeTranscriptSegments(segments) { return (Array.isArray(segments) ? segments : []).map(segment => ({ start: normalizeSegmentTime(segment.start ?? segment.begin ?? segment.bg), end: normalizeSegmentTime(segment.end ?? segment.ed), text: cleanText(segment.text || segment.onebest || segment.content) })).filter(segment => segment.text); } async function pollTranscriptJob(job) { if (!job.orderId || job.status === 'completed' || job.status === 'failed') return job; const data = await queryTranscriptGateway(job.orderId); const status = cleanText(gatewayValue(data, 'status')).toLowerCase(); const text = cleanText(gatewayValue(data, 'text')); const segments = normalizeTranscriptSegments(gatewayValue(data, 'segments')); if (status === 'completed' || text || segments.length) { return updateTranscriptJob(job, { status: 'completed', stageLabel: '转写完成', progress: 100, text: text || segments.map(segment => segment.text).join('\n'), segments }); } if (status === 'failed' || status === 'error') { return updateTranscriptJob(job, { status: 'failed', stageLabel: '转写失败', errorMessage: gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写网关返回失败' }); } return updateTranscriptJob(job, { status: 'polling_provider', stageLabel: '转写处理中', progress: Math.max(Number(job.progress || 0), 85), warnings: [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])] }); } async function runTranscriptJob(job, input) { try { updateTranscriptJob(job, { status: 'resolving_detail', stageLabel: '正在获取视频详情', progress: 10 }); const detail = await fetchDouyinDetailForTranscript(input); const awemeId = cleanText(detail.aweme_id || job.awemeId); if (awemeId && awemeId !== job.awemeId) updateTranscriptJob(job, { awemeId }); updateTranscriptJob(job, { status: 'selecting_media', stageLabel: '正在选择可转写媒体', progress: 25 }); const suppliedMediaUrl = cleanText(input.mediaUrl); const candidate = suppliedMediaUrl ? { url: suppliedMediaUrl, kind: /audio|m4a|mp3|aac/i.test(suppliedMediaUrl) ? 'audio' : 'video', keyPath: 'input.mediaUrl' } : selectMediaCandidate(detail); if (!candidate?.url) { updateTranscriptJob(job, { status: 'needs_media', stageLabel: '未找到可转写媒体', progress: 25, warnings: [...(job.warnings || []), '视频详情中未找到音频或视频下载地址。'] }); return; } updateTranscriptJob(job, { status: 'downloading_media', stageLabel: '正在下载媒体', progress: 45, mediaUrl: candidate.url, sourceKind: candidate.kind === 'audio' ? 'douyin_audio' : 'douyin_video' }); const sourcePath = await downloadTranscriptMedia(candidate, job); updateTranscriptJob(job, { localVideoPath: sourcePath }); const durationMs = Number(input.durationMs || durationFromDetail(detail) || 0); let audioPath = sourcePath; if (candidate.kind !== 'audio') { updateTranscriptJob(job, { status: 'extracting_audio', stageLabel: '正在提取音频', progress: 65 }); audioPath = await extractAudioForTranscript(sourcePath, job); } updateTranscriptJob(job, { status: 'submitting_provider', stageLabel: '正在提交转写网关', progress: 80, localAudioPath: audioPath, durationMs }); const uploaded = await uploadTranscriptGateway(audioPath, durationMs); updateTranscriptJob(job, { status: 'polling_provider', stageLabel: '转写任务已提交', progress: 85, orderId: uploaded.orderId, estimateTime: uploaded.estimateTime, warnings: [...(job.warnings || []), `已提交转写任务:${uploaded.orderId}`] }); } catch (error) { updateTranscriptJob(job, { status: error.status || 'failed', stageLabel: '转写任务失败', errorMessage: error.message || '转写任务失败', warnings: [...(job.warnings || []), error.message || '转写任务失败'] }); } } app.post('/api/douyin/transcript/start', (req, res) => { const input = req.body || {}; if (!cleanText(input.awemeId) && !cleanText(input.sourceUrl) && !cleanText(input.mediaUrl)) { return res.status(400).json({ success: false, error: '缺少 awemeId、sourceUrl 或 mediaUrl,无法创建转写任务。' }); } const job = createTranscriptJob(input); updateTranscriptJob(job, { status: 'resolving_detail', stageLabel: '正在获取视频详情', progress: 5 }); res.json({ success: true, job: { ...job } }); setImmediate(() => { void runTranscriptJob(job, input); }); }); app.get('/api/douyin/transcript/:jobId', async (req, res) => { const job = transcriptTasks.get(req.params.jobId); if (!job) { return res.status(404).json({ success: false, error: '未找到逐字稿任务。' }); } try { if (job.status === 'polling_provider' && job.orderId) { await pollTranscriptJob(job); } res.json({ success: true, job: { ...job } }); } catch (error) { updateTranscriptJob(job, { status: error.status || 'failed', stageLabel: '查询转写结果失败', errorMessage: error.message || '查询转写结果失败', warnings: [...(job.warnings || []), error.message || '查询转写结果失败'] }); res.json({ success: true, job: { ...job } }); } }); app.post('/api/douyin/transcript/:jobId/retry', (req, res) => { const previous = transcriptTasks.get(req.params.jobId); if (!previous) { return res.status(404).json({ success: false, error: '未找到逐字稿任务。' }); } const body = req.body || {}; const input = { ...body, awemeId: body.awemeId || previous.awemeId, analysisId: body.analysisId || previous.analysisId, mediaUrl: body.mediaUrl || previous.mediaUrl }; const job = createTranscriptJob(input); updateTranscriptJob(job, { status: 'resolving_detail', stageLabel: '正在获取视频详情', progress: 5 }); res.json({ success: true, job: { ...job } }); setImmediate(() => { void runTranscriptJob(job, input); }); }); // ==================== 健康检查 ==================== registerHealthRoutes(app, { fs, projectRoot: PROJECT_ROOT, manifestPath: MANIFEST_PATH, whisperDir: WHISPER_DIR, dataVideoDir: DATA_VIDEO_DIR, legacyVideoDir: LEGACY_VIDEO_DIR, transcriptTempDir: TRANSCRIPT_TEMP_DIR, transcriptTasks, douyinApiBaseUrl: DOUYIN_API_BASE_URL, transcriptionGatewayBaseUrl: TRANSCRIPTION_GATEWAY_BASE_URL, douyinGatewayMaxAttempts: DOUYIN_GATEWAY_MAX_ATTEMPTS, getVocToken }); // ==================== 启动 ==================== app.listen(PORT, () => { printStartupLog({ port: PORT, projectRoot: PROJECT_ROOT }); });