server.js 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009
  1. const express = require('express');
  2. const { spawn, exec } = require('child_process');
  3. const path = require('path');
  4. const fs = require('fs');
  5. const cors = require('cors');
  6. const crypto = require('crypto');
  7. const multer = require('multer');
  8. const { Readable, Transform } = require('stream');
  9. const { pipeline } = require('stream/promises');
  10. const LOCAL_ENV_PATH = path.join(__dirname, '.env');
  11. if (fs.existsSync(LOCAL_ENV_PATH)) {
  12. const envText = fs.readFileSync(LOCAL_ENV_PATH, 'utf-8');
  13. for (const rawLine of envText.split(/\r?\n/)) {
  14. const line = rawLine.trim();
  15. if (!line || line.startsWith('#')) {
  16. continue;
  17. }
  18. const separatorIndex = line.indexOf('=');
  19. if (separatorIndex <= 0) {
  20. continue;
  21. }
  22. const key = line.slice(0, separatorIndex).trim();
  23. let value = line.slice(separatorIndex + 1).trim();
  24. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  25. value = value.slice(1, -1);
  26. }
  27. if (key && process.env[key] === undefined) {
  28. process.env[key] = value;
  29. }
  30. }
  31. }
  32. const runtimeConfig = require('./server/config/runtime-config');
  33. const { registerHealthRoutes } = require('./server/routes/health');
  34. const { registerFileRoutes } = require('./server/routes/files');
  35. const { registerVideoProxyRoutes } = require('./server/routes/video-proxy');
  36. const { registerLlmRoutes } = require('./server/routes/llm');
  37. const { registerQuicklyRoutes } = require('./server/routes/quickly');
  38. const { registerUploadRoutes } = require('./server/routes/upload');
  39. const { registerAudioExtractionRoutes } = require('./server/routes/audio-extraction');
  40. const { registerVideoDownloadRoutes } = require('./server/routes/video-download');
  41. const { registerVideoCompositeRoutes } = require('./server/routes/video-composite');
  42. const { registerWhisperRoutes } = require('./server/routes/whisper');
  43. const { registerRemixAssetUploadRoutes } = require('./server/routes/remix-asset-upload');
  44. const { printStartupLog } = require('./server/startup-log');
  45. const app = express();
  46. const PORT = runtimeConfig.port;
  47. const PROJECT_ROOT = __dirname;
  48. const DATA_DIR = path.join(PROJECT_ROOT, 'data');
  49. const DATA_VIDEO_DIR = path.join(DATA_DIR, 'videos');
  50. const DATA_REMIX_ASSET_DIR = path.join(DATA_DIR, 'remix-assets');
  51. const LEGACY_VIDEO_DIR = path.join(PROJECT_ROOT, 'src', 'video');
  52. const VOICE_SPEAKER_ID_DOC_PATH = path.join(PROJECT_ROOT, 'docs', '音色创建', 'speaker_id.md');
  53. const MANIFEST_PATH = path.join(DATA_DIR, 'manifest.json');
  54. const LEGACY_MANIFEST_PATH = path.join(LEGACY_VIDEO_DIR, 'manifest.json');
  55. const WHISPER_DIR = path.join(PROJECT_ROOT, 'Whisper');
  56. const downloadTasks = new Map();
  57. const transcriptTasks = new Map();
  58. const TRANSCRIPT_TEMP_DIR = path.join(DATA_DIR, 'douyin-transcripts');
  59. const DOUYIN_API_BASE_URL = runtimeConfig.douyinApiBaseUrl;
  60. const TRANSCRIPTION_GATEWAY_BASE_URL = runtimeConfig.transcriptionGatewayBaseUrl;
  61. const DOUYIN_GATEWAY_MAX_ATTEMPTS = runtimeConfig.douyinGatewayMaxAttempts;
  62. const DOUYIN_API_ROUTES = {
  63. searchVideos: { method: 'POST', path: '/douyin/search/fetch_general_search_v2' },
  64. challengeSearch: { method: 'POST', path: '/douyin/search/fetch_challenge_search_v2' },
  65. videoDetail: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_v3' },
  66. videoByShareUrl: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_by_share_url' },
  67. userProfileWeb: { method: 'GET', path: '/douyin/web/handler_user_profile_v2' },
  68. userProfileApp: { method: 'GET', path: '/douyin/app/v3/handler_user_profile' },
  69. userPosts: { method: 'GET', path: '/douyin/app/v3/fetch_user_post_videos' },
  70. comments: { method: 'GET', path: '/douyin/app/v3/fetch_video_comments' },
  71. replies: { method: 'GET', path: '/douyin/app/v3/fetch_video_comment_replies' },
  72. };
  73. const QINIU_ACCESS_KEY = runtimeConfig.qiniu.accessKey;
  74. const QINIU_SECRET_KEY = runtimeConfig.qiniu.secretKey;
  75. const QINIU_BUCKET = runtimeConfig.qiniu.bucket;
  76. const QINIU_CDN_DOMAIN = runtimeConfig.qiniu.cdnDomain;
  77. const QINIU_CDN_PREFIX = runtimeConfig.qiniu.cdnPrefix;
  78. const QINIU_UPLOAD_URL = runtimeConfig.qiniu.uploadUrl;
  79. const VOLC_SPEECH_BASE_URL = runtimeConfig.volc.speechBaseUrl;
  80. const VOLC_TTS_PROXY_BASE_URL = runtimeConfig.volc.ttsProxyBaseUrl;
  81. const VOLC_SPEECH_API_KEY = runtimeConfig.volc.speechApiKey;
  82. const VOLC_TTS_RESOURCE_ID = runtimeConfig.volc.ttsResourceId;
  83. const VOLC_SPEECH_APP_KEY = runtimeConfig.volc.speechAppKey;
  84. const VOLC_SPEECH_ACCESS_KEY = runtimeConfig.volc.speechAccessKey;
  85. app.use(cors());
  86. app.use(express.json({ limit: '50mb' }));
  87. // 静态文件:提供视频文件的访问
  88. const staticVideoOptions = {
  89. setHeaders: (res, filePath) => {
  90. if (filePath.match(/\.(mp4|mov|webm|mkv|avi)$/i)) {
  91. res.setHeader('Content-Type', 'video/mp4');
  92. res.setHeader('Accept-Ranges', 'bytes');
  93. }
  94. }
  95. };
  96. app.use('/api/video', express.static(DATA_VIDEO_DIR, staticVideoOptions));
  97. app.use('/api/video', express.static(LEGACY_VIDEO_DIR, staticVideoOptions));
  98. // ==================== 文件上传配置 ====================
  99. const uploadStorage = multer.diskStorage({
  100. destination: (req, file, cb) => {
  101. const uploadDir = DATA_VIDEO_DIR;
  102. if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
  103. cb(null, uploadDir);
  104. },
  105. filename: (req, file, cb) => {
  106. // 保留原始文件名,如有冲突则加时间戳
  107. const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
  108. const ext = path.extname(originalName);
  109. const baseName = path.basename(originalName, ext);
  110. const targetPath = path.join(DATA_VIDEO_DIR, originalName);
  111. if (fs.existsSync(targetPath)) {
  112. cb(null, `${baseName}-${Date.now()}${ext}`);
  113. } else {
  114. cb(null, originalName);
  115. }
  116. }
  117. });
  118. const assetUpload = multer({
  119. storage: multer.memoryStorage(),
  120. // 200MB 以支持动作迁移 / 素材拼接场景的参考视频上传
  121. limits: { fileSize: 200 * 1024 * 1024 }
  122. });
  123. const upload = multer({
  124. storage: uploadStorage,
  125. limits: { fileSize: 500 * 1024 * 1024 }, // 500MB
  126. fileFilter: (req, file, cb) => {
  127. const allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm', 'video/x-matroska'];
  128. if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp4|mov|avi|webm|mkv)$/i)) {
  129. cb(null, true);
  130. } else {
  131. cb(new Error('仅支持视频文件(mp4, mov, avi, webm, mkv)'));
  132. }
  133. }
  134. });
  135. // ==================== 工具函数 ====================
  136. // 确保 data 目录存在
  137. if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
  138. if (!fs.existsSync(DATA_VIDEO_DIR)) fs.mkdirSync(DATA_VIDEO_DIR, { recursive: true });
  139. if (!fs.existsSync(DATA_REMIX_ASSET_DIR)) fs.mkdirSync(DATA_REMIX_ASSET_DIR, { recursive: true });
  140. function ensureManifestFile() {
  141. if (fs.existsSync(MANIFEST_PATH)) return;
  142. if (fs.existsSync(LEGACY_MANIFEST_PATH)) {
  143. fs.copyFileSync(LEGACY_MANIFEST_PATH, MANIFEST_PATH);
  144. return;
  145. }
  146. fs.writeFileSync(MANIFEST_PATH, '[]', 'utf-8');
  147. }
  148. function readManifest() {
  149. ensureManifestFile();
  150. const raw = fs.readFileSync(MANIFEST_PATH, 'utf-8');
  151. return JSON.parse(raw);
  152. }
  153. function writeManifest(data) {
  154. ensureManifestFile();
  155. fs.writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2), 'utf-8');
  156. }
  157. function resolveVideoPath(filename) {
  158. const runtimePath = path.join(DATA_VIDEO_DIR, filename);
  159. if (fs.existsSync(runtimePath)) return runtimePath;
  160. const legacyPath = path.join(LEGACY_VIDEO_DIR, filename);
  161. if (fs.existsSync(legacyPath)) return legacyPath;
  162. return runtimePath;
  163. }
  164. function removeFileIfExists(filePath) {
  165. try {
  166. if (filePath && fs.existsSync(filePath)) {
  167. fs.unlinkSync(filePath);
  168. }
  169. } catch (err) {
  170. // 忽略竞态条件 / 权限问题导致的删除失败,避免 ENOENT 等异常中断主流程
  171. if (err && err.code !== 'ENOENT') {
  172. console.warn(`⚠️ 删除文件失败 ${filePath}: ${err.message}`);
  173. }
  174. }
  175. }
  176. function removeDirectoryIfExists(dirPath) {
  177. if (fs.existsSync(dirPath)) {
  178. fs.rmSync(dirPath, { recursive: true, force: true });
  179. }
  180. }
  181. function toBase64Url(input) {
  182. return Buffer.from(input)
  183. .toString('base64')
  184. .replace(/\+/g, '-')
  185. .replace(/\//g, '_');
  186. }
  187. function buildQiniuUploadToken(key) {
  188. const deadline = Math.floor(Date.now() / 1000) + 3600;
  189. const putPolicy = {
  190. scope: `${QINIU_BUCKET}:${key}`,
  191. deadline
  192. };
  193. const encodedPutPolicy = toBase64Url(JSON.stringify(putPolicy));
  194. const sign = crypto
  195. .createHmac('sha1', QINIU_SECRET_KEY)
  196. .update(encodedPutPolicy)
  197. .digest('base64')
  198. .replace(/\+/g, '-')
  199. .replace(/\//g, '_');
  200. return `${QINIU_ACCESS_KEY}:${sign}:${encodedPutPolicy}`;
  201. }
  202. function buildDigitalHumanAssetKey(fileName, kind) {
  203. const rawName = Buffer.from(fileName || `asset-${Date.now()}`, 'latin1').toString('utf8');
  204. const ext = path.extname(rawName).toLowerCase();
  205. const safeExt = ext && /^[.a-z0-9]+$/i.test(ext) ? ext : '';
  206. const baseName = path.basename(rawName, ext).replace(/[^a-zA-Z0-9_-]/g, '_') || `asset-${Date.now()}`;
  207. const date = new Date();
  208. const yyyy = date.getFullYear();
  209. const mm = String(date.getMonth() + 1).padStart(2, '0');
  210. const dd = String(date.getDate()).padStart(2, '0');
  211. const timestamp = `${yyyy}${mm}${dd}-${Date.now()}`;
  212. return `${QINIU_CDN_PREFIX}/digital-human/${kind}/${yyyy}${mm}${dd}/${timestamp}-${baseName}${safeExt}`;
  213. }
  214. function createRequestId() {
  215. return typeof crypto.randomUUID === 'function'
  216. ? crypto.randomUUID()
  217. : `${Date.now()}-${crypto.randomBytes(8).toString('hex')}`;
  218. }
  219. function buildSpeechHeaders(contentType = 'application/json') {
  220. const headers = {
  221. 'Content-Type': contentType,
  222. 'X-Api-Request-Id': createRequestId()
  223. };
  224. if (VOLC_SPEECH_API_KEY) {
  225. headers['X-Api-Key'] = VOLC_SPEECH_API_KEY;
  226. return headers;
  227. }
  228. if (VOLC_SPEECH_APP_KEY && VOLC_SPEECH_ACCESS_KEY) {
  229. headers['X-Api-App-Key'] = VOLC_SPEECH_APP_KEY;
  230. headers['X-Api-Access-Key'] = VOLC_SPEECH_ACCESS_KEY;
  231. return headers;
  232. }
  233. throw new Error('未配置火山语音鉴权,请设置 VOLC_SPEECH_API_KEY 或 VOLC_SPEECH_APP_KEY + VOLC_SPEECH_ACCESS_KEY');
  234. }
  235. function normalizeOfficialSpeechResourceId(value) {
  236. const resourceId = String(value || '').trim();
  237. return /^seed-(tts|icl)-/i.test(resourceId) ? resourceId : '';
  238. }
  239. function inferOfficialSpeechResourceId(speaker, requestedResourceId = '') {
  240. const explicitResourceId = normalizeOfficialSpeechResourceId(requestedResourceId);
  241. if (explicitResourceId) {
  242. return explicitResourceId;
  243. }
  244. const configuredResourceId = normalizeOfficialSpeechResourceId(VOLC_TTS_RESOURCE_ID);
  245. if (configuredResourceId) {
  246. return configuredResourceId;
  247. }
  248. const normalizedSpeaker = String(speaker || '').trim();
  249. if (/^(S_|icl_|saturn_|dit_)/i.test(normalizedSpeaker)) {
  250. return 'seed-icl-2.0';
  251. }
  252. return 'seed-tts-2.0';
  253. }
  254. function clampNumber(value, fallback, min, max) {
  255. const numericValue = Number(value);
  256. if (!Number.isFinite(numericValue)) {
  257. return fallback;
  258. }
  259. return Math.min(max, Math.max(min, numericValue));
  260. }
  261. function clampOptionalNumber(value, min, max) {
  262. if (value === null || value === undefined || String(value).trim() === '') {
  263. return null;
  264. }
  265. const numericValue = Number(value);
  266. if (!Number.isFinite(numericValue)) {
  267. return null;
  268. }
  269. return Math.min(max, Math.max(min, numericValue));
  270. }
  271. function parseOptionalBoolean(value, fallback = false) {
  272. if (value === null || value === undefined || value === '') {
  273. return fallback;
  274. }
  275. if (typeof value === 'boolean') {
  276. return value;
  277. }
  278. if (typeof value === 'number') {
  279. return value !== 0;
  280. }
  281. const normalizedValue = String(value).trim().toLowerCase();
  282. if (['true', '1', 'yes', 'on'].includes(normalizedValue)) {
  283. return true;
  284. }
  285. if (['false', '0', 'no', 'off'].includes(normalizedValue)) {
  286. return false;
  287. }
  288. return fallback;
  289. }
  290. function normalizeVoiceSynthesisErrorMessage(message) {
  291. const rawMessage = String(message || '').trim();
  292. if (/resource ID is mismatched with speaker related resource/i.test(rawMessage)) {
  293. return '当前本地 X-Api-Key 与所选 speaker_id 不属于同一语音资源,无法直接使用 speaker_id 合成。请先通过音色复刻获取 timbreId 后再合成,或更换与该 speaker_id 匹配的 X-Api-Key。';
  294. }
  295. return rawMessage || '语音合成失败';
  296. }
  297. function mapVoiceDesignStatus(status) {
  298. switch (Number(status)) {
  299. case 0:
  300. return '未找到';
  301. case 1:
  302. return '训练中';
  303. case 2:
  304. return '可用';
  305. case 3:
  306. return '失败';
  307. case 4:
  308. return '已激活';
  309. default:
  310. return '未知';
  311. }
  312. }
  313. function mapVoiceCloneStatus(status) {
  314. switch (String(status ?? '')) {
  315. case '0':
  316. return '未占用';
  317. case '1':
  318. return '可用';
  319. case '2':
  320. return '训练中';
  321. case '3':
  322. return '失败';
  323. case '404':
  324. return '已删除';
  325. default:
  326. return '未知';
  327. }
  328. }
  329. function normalizeBearerToken(token) {
  330. const rawToken = String(token || '').trim();
  331. if (!rawToken) {
  332. return '';
  333. }
  334. return /^Bearer\s+/i.test(rawToken) ? rawToken : `Bearer ${rawToken}`;
  335. }
  336. function inferAudioFormat(file) {
  337. const mimeType = String(file?.mimetype || '').toLowerCase();
  338. if (mimeType.includes('mpeg') || mimeType.includes('mp3')) {
  339. return 'mp3';
  340. }
  341. if (mimeType.includes('wav')) {
  342. return 'wav';
  343. }
  344. if (mimeType.includes('m4a') || mimeType.includes('mp4')) {
  345. return 'm4a';
  346. }
  347. if (mimeType.includes('aac')) {
  348. return 'aac';
  349. }
  350. if (mimeType.includes('flac')) {
  351. return 'flac';
  352. }
  353. if (mimeType.includes('ogg') || mimeType.includes('opus')) {
  354. return 'ogg_opus';
  355. }
  356. if (mimeType.includes('pcm')) {
  357. return 'pcm';
  358. }
  359. const ext = path.extname(file?.originalname || '').toLowerCase();
  360. switch (ext) {
  361. case '.wav':
  362. return 'wav';
  363. case '.m4a':
  364. return 'm4a';
  365. case '.aac':
  366. return 'aac';
  367. case '.flac':
  368. return 'flac';
  369. case '.ogg':
  370. case '.opus':
  371. return 'ogg_opus';
  372. case '.pcm':
  373. return 'pcm';
  374. case '.mp3':
  375. default:
  376. return 'mp3';
  377. }
  378. }
  379. async function readFetchResponse(response) {
  380. const rawText = await response.text();
  381. let payload = null;
  382. try {
  383. payload = rawText ? JSON.parse(rawText) : null;
  384. } catch {
  385. payload = null;
  386. }
  387. return {
  388. rawText,
  389. payload
  390. };
  391. }
  392. function extractPrimaryVoiceModel(timbre) {
  393. if (!Array.isArray(timbre?.models) || timbre.models.length === 0) {
  394. return {};
  395. }
  396. return timbre.models[0] || {};
  397. }
  398. function readVoiceSpeakerIdOptions() {
  399. if (!fs.existsSync(VOICE_SPEAKER_ID_DOC_PATH)) {
  400. return [];
  401. }
  402. const rawText = fs.readFileSync(VOICE_SPEAKER_ID_DOC_PATH, 'utf-8');
  403. const seen = new Set();
  404. const options = [];
  405. for (const rawLine of rawText.split(/\r?\n/)) {
  406. const speakerId = rawLine.trim().match(/^S_[A-Za-z0-9]+$/)?.[0] || '';
  407. if (!speakerId || seen.has(speakerId)) {
  408. continue;
  409. }
  410. seen.add(speakerId);
  411. options.push(speakerId);
  412. }
  413. return options;
  414. }
  415. function upsertVoiceProfile(profile) {
  416. const profiles = readDataFile('voice-profiles');
  417. const existingIndex = profiles.findIndex((item) => (
  418. (!!profile.timbre_id && item.timbre_id === profile.timbre_id)
  419. || (!!profile.id && item.id === profile.id)
  420. ));
  421. if (existingIndex >= 0) {
  422. const existingProfile = profiles[existingIndex];
  423. const nextProfile = {
  424. ...existingProfile,
  425. ...profile,
  426. id: existingProfile.id || profile.id,
  427. created_at: existingProfile.created_at || profile.created_at,
  428. updated_at: profile.updated_at || new Date().toISOString()
  429. };
  430. profiles[existingIndex] = nextProfile;
  431. writeDataFile('voice-profiles', profiles);
  432. return nextProfile;
  433. }
  434. profiles.unshift(profile);
  435. writeDataFile('voice-profiles', profiles);
  436. return profile;
  437. }
  438. async function requestVolcTtsJson(endpoint, requestBody) {
  439. const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, {
  440. method: 'POST',
  441. headers: {
  442. 'Content-Type': 'application/json'
  443. },
  444. body: JSON.stringify(requestBody)
  445. });
  446. const { rawText, payload } = await readFetchResponse(response);
  447. if (!response.ok) {
  448. const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败');
  449. error.status = response.status;
  450. error.detail = payload || rawText || '';
  451. throw error;
  452. }
  453. return payload;
  454. }
  455. async function proxyVolcTtsStream(endpoint, requestBody, res) {
  456. const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, {
  457. method: 'POST',
  458. headers: {
  459. 'Content-Type': 'application/json'
  460. },
  461. body: JSON.stringify(requestBody)
  462. });
  463. if (!response.ok) {
  464. const { rawText, payload } = await readFetchResponse(response);
  465. const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 流式请求失败');
  466. error.status = response.status;
  467. error.detail = payload || rawText || '';
  468. throw error;
  469. }
  470. res.status(response.status);
  471. res.setHeader('Content-Type', response.headers.get('content-type') || 'application/x-ndjson; charset=utf-8');
  472. res.setHeader('Cache-Control', 'no-cache, no-transform');
  473. if (!response.body) {
  474. res.end();
  475. return;
  476. }
  477. await pipeline(Readable.fromWeb(response.body), res);
  478. }
  479. async function synthesizeVoiceAudio({ token, speaker, timbreId, text, ssml, xApiResourceId, model, isStream = false, audioParams = {}, additions = {} }) {
  480. const normalizedSpeaker = String(speaker || '').trim();
  481. const fallbackSpeaker = String(timbreId || '').trim();
  482. const resolvedSpeaker = normalizedSpeaker || fallbackSpeaker;
  483. const requestedResourceId = String(xApiResourceId || '').trim();
  484. const headerResourceId = inferOfficialSpeechResourceId(resolvedSpeaker, requestedResourceId);
  485. const resolvedModel = String(model || '').trim();
  486. const serializedAdditions = Object.keys(additions).length > 0 ? JSON.stringify(additions) : '';
  487. const requestBody = {
  488. req_params: {
  489. ...(ssml ? { ssml } : { text }),
  490. speaker: resolvedSpeaker,
  491. audio_params: {
  492. format: audioParams.format || 'mp3',
  493. sample_rate: audioParams.sampleRate ?? 24000,
  494. ...(audioParams.speechRate === null || audioParams.speechRate === undefined ? {} : { speech_rate: audioParams.speechRate }),
  495. ...(audioParams.loudnessRate === null || audioParams.loudnessRate === undefined ? {} : { loudness_rate: audioParams.loudnessRate }),
  496. ...(audioParams.emotion ? { emotion: audioParams.emotion } : {}),
  497. ...(audioParams.emotionScale === null || audioParams.emotionScale === undefined ? {} : { emotion_scale: audioParams.emotionScale }),
  498. ...(audioParams.enableSubtitle ? { enable_subtitle: true } : {})
  499. },
  500. ...(serializedAdditions ? { additions: serializedAdditions } : {}),
  501. ...(resolvedModel ? { model: resolvedModel } : {})
  502. }
  503. };
  504. const headers = buildSpeechHeaders();
  505. headers['X-Api-Resource-Id'] = headerResourceId;
  506. headers['Connection'] = 'keep-alive';
  507. const response = await fetch(`${VOLC_SPEECH_BASE_URL}/api/v3/tts/unidirectional`, {
  508. method: 'POST',
  509. headers,
  510. body: JSON.stringify(requestBody)
  511. });
  512. const { rawText, payload } = await readFetchResponse(response);
  513. if (!response.ok) {
  514. const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败'));
  515. error.status = response.status;
  516. error.detail = payload || rawText || '';
  517. throw error;
  518. }
  519. if (Number(payload?.code) !== 200 || !payload?.data?.audioUrl) {
  520. const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || '语音合成失败'));
  521. error.status = 502;
  522. error.detail = payload || '';
  523. throw error;
  524. }
  525. return {
  526. audioUrl: payload.data.audioUrl,
  527. workId: payload.data.workId || '',
  528. payload
  529. };
  530. }
  531. function createHttpError(message, status = 500, detail = '') {
  532. const error = new Error(message || '请求失败');
  533. error.status = status;
  534. error.detail = detail;
  535. return error;
  536. }
  537. function inferAudioFormatFromUrl(url) {
  538. try {
  539. const parsed = new URL(String(url || '').trim());
  540. return inferAudioFormat({ originalname: parsed.pathname || '', mimetype: '' });
  541. } catch {
  542. return inferAudioFormat({ originalname: String(url || '').trim(), mimetype: '' });
  543. }
  544. }
  545. function normalizeVoiceClonePayload(body) {
  546. const audioData = body?.audioData && typeof body.audioData === 'object' ? body.audioData : {};
  547. const extraParams = body?.extra_params && typeof body.extra_params === 'object' ? body.extra_params : {};
  548. return {
  549. token: normalizeBearerToken(body?.token) || runtimeConfig.voice.defaultToken,
  550. name: String(body?.name || body?.displayName || '').trim(),
  551. timbreId: String(body?.timbreId || '').trim(),
  552. speakerId: String(body?.speaker_id || body?.speakerId || '').trim(),
  553. audioData: {
  554. url: String(audioData?.url || '').trim(),
  555. base64: String(audioData?.base64 || '').trim(),
  556. format: String(audioData?.format || '').trim().toLowerCase(),
  557. text: String(audioData?.text || body?.audioText || '').trim()
  558. },
  559. language: Number(body?.language ?? 0) === 1 ? 1 : 0,
  560. demoText: String(extraParams?.demo_text || body?.sampleText || '').trim(),
  561. sourceAudioName: String(body?.sourceAudioName || body?.source_audio_name || '').trim(),
  562. sourceAudioFormat: String(body?.sourceAudioFormat || body?.source_audio_format || '').trim().toLowerCase()
  563. };
  564. }
  565. function normalizeVoiceSynthesisPayload(body) {
  566. const rawAudioParams = body?.audio_params && typeof body.audio_params === 'object'
  567. ? body.audio_params
  568. : (body?.audioParams && typeof body.audioParams === 'object' ? body.audioParams : {});
  569. const rawAdditions = body?.additions && typeof body.additions === 'object' ? body.additions : {};
  570. const formatCandidate = String(rawAudioParams?.format || '').trim().toLowerCase();
  571. const format = ['mp3', 'ogg_opus', 'pcm'].includes(formatCandidate) ? formatCandidate : 'mp3';
  572. const sampleRateCandidate = Number(rawAudioParams?.sampleRate ?? rawAudioParams?.sample_rate);
  573. const sampleRate = [8000, 16000, 22050, 24000, 32000, 44100, 48000].includes(sampleRateCandidate) ? sampleRateCandidate : 24000;
  574. const speechRate = clampNumber(rawAudioParams?.speechRate ?? rawAudioParams?.speech_rate, 0, -50, 100);
  575. const loudnessRate = clampNumber(rawAudioParams?.loudnessRate ?? rawAudioParams?.loudness_rate, 0, -50, 100);
  576. const emotion = String(rawAudioParams?.emotion || '').trim();
  577. const emotionScale = clampOptionalNumber(rawAudioParams?.emotionScale ?? rawAudioParams?.emotion_scale, 1, 5);
  578. const enableSubtitle = parseOptionalBoolean(rawAudioParams?.enableSubtitle ?? rawAudioParams?.enable_subtitle, false);
  579. const silenceDuration = clampOptionalNumber(rawAdditions?.silenceDuration ?? rawAdditions?.silence_duration, 0, 30000);
  580. const enableLanguageDetector = parseOptionalBoolean(rawAdditions?.enableLanguageDetector ?? rawAdditions?.enable_language_detector, false);
  581. const disableMarkdownFilter = parseOptionalBoolean(rawAdditions?.disableMarkdownFilter ?? rawAdditions?.disable_markdown_filter, false);
  582. const disableEmojiFilter = parseOptionalBoolean(rawAdditions?.disableEmojiFilter ?? rawAdditions?.disable_emoji_filter, false);
  583. const explicitLanguage = String((rawAdditions?.explicitLanguage ?? rawAdditions?.explicit_language) || '').trim();
  584. return {
  585. token: normalizeBearerToken(body?.token) || runtimeConfig.voice.defaultToken,
  586. text: String(body?.text || '').trim(),
  587. ssml: String(body?.ssml || '').trim(),
  588. timbreId: String(body?.timbreId || body?.timbre_id || '').trim(),
  589. speakerId: String(body?.speaker_id || body?.speakerId || body?.speaker || body?.volcengine_voice_type || body?.voiceType || body?.voice_type || '').trim(),
  590. isStream: parseOptionalBoolean(body?.isStream ?? body?.is_stream, true),
  591. xApiResourceId: String(body?.x_api_resource_id || body?.xApiResourceId || '').trim(),
  592. model: String(body?.model || '').trim(),
  593. audioParams: {
  594. format,
  595. sampleRate,
  596. speechRate,
  597. loudnessRate,
  598. emotion,
  599. emotionScale,
  600. enableSubtitle
  601. },
  602. additions: {
  603. ...(silenceDuration === null ? {} : { silence_duration: silenceDuration }),
  604. ...(enableLanguageDetector ? { enable_language_detector: true } : {}),
  605. ...(disableMarkdownFilter ? { disable_markdown_filter: true } : {}),
  606. ...(disableEmojiFilter ? { disable_emoji_filter: true } : {}),
  607. ...(explicitLanguage ? { explicit_language: explicitLanguage } : {})
  608. }
  609. };
  610. }
  611. function buildProxySynthesisPayload(payload) {
  612. return {
  613. token: payload.token,
  614. ...(payload.text ? { text: payload.text } : {}),
  615. ...(payload.ssml ? { ssml: payload.ssml } : {}),
  616. ...(payload.timbreId ? { timbreId: payload.timbreId } : {}),
  617. ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}),
  618. isStream: !!payload.isStream,
  619. ...(payload.xApiResourceId ? { x_api_resource_id: payload.xApiResourceId } : {}),
  620. ...(payload.model ? { model: payload.model } : {}),
  621. audio_params: {
  622. format: payload.audioParams.format,
  623. sample_rate: payload.audioParams.sampleRate,
  624. speech_rate: payload.audioParams.speechRate,
  625. loudness_rate: payload.audioParams.loudnessRate,
  626. ...(payload.audioParams.emotion ? { emotion: payload.audioParams.emotion } : {}),
  627. ...(payload.audioParams.emotionScale === null || payload.audioParams.emotionScale === undefined ? {} : { emotion_scale: payload.audioParams.emotionScale }),
  628. ...(payload.audioParams.enableSubtitle ? { enable_subtitle: true } : {})
  629. },
  630. ...(Object.keys(payload.additions).length > 0 ? { additions: payload.additions } : {})
  631. };
  632. }
  633. async function executeVoiceCloneRequest(payload) {
  634. if (!payload.token) {
  635. throw createHttpError('缺少 token 参数', 400);
  636. }
  637. if (!payload.name) {
  638. throw createHttpError('缺少音色名称 name', 400);
  639. }
  640. if (!payload.timbreId && !payload.speakerId) {
  641. throw createHttpError('timbreId与speaker_id不能同时为空', 400);
  642. }
  643. if (!payload.audioData.url && !payload.audioData.base64) {
  644. throw createHttpError('音频url或base64至少提供一个', 400);
  645. }
  646. if (payload.demoText && (payload.demoText.length < 4 || payload.demoText.length > 80)) {
  647. throw createHttpError('试听文本长度需在 4-80 字之间', 400);
  648. }
  649. const clonePayload = await requestVolcTtsJson('voice_clone', {
  650. token: payload.token,
  651. name: payload.name,
  652. ...(payload.timbreId ? { timbreId: payload.timbreId } : {}),
  653. ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}),
  654. audioData: {
  655. ...(payload.audioData.url ? { url: payload.audioData.url } : {}),
  656. ...(payload.audioData.base64 ? { base64: payload.audioData.base64 } : {}),
  657. ...(payload.audioData.format ? { format: payload.audioData.format } : {}),
  658. ...(payload.audioData.text ? { text: payload.audioData.text } : {})
  659. },
  660. language: payload.language,
  661. ...(payload.demoText ? { extra_params: { demo_text: payload.demoText } } : {})
  662. });
  663. if (Number(clonePayload?.code) !== 200 || !clonePayload?.data?.timbre?.objectId) {
  664. throw createHttpError(clonePayload?.error?.message || clonePayload?.message || '音色复刻失败', 502, clonePayload || '');
  665. }
  666. const timbre = clonePayload.data.timbre;
  667. const primaryModel = extractPrimaryVoiceModel(timbre);
  668. const now = new Date().toISOString();
  669. const profile = upsertVoiceProfile({
  670. id: `VOICE-${Date.now()}`,
  671. name: payload.name,
  672. creation_mode: 'clone',
  673. speaker_id: timbre?.speaker_id || payload.speakerId,
  674. timbre_id: timbre?.objectId || payload.timbreId || '',
  675. sample_text: payload.demoText,
  676. source_audio_text: payload.audioData.text,
  677. source_audio_name: payload.sourceAudioName,
  678. source_audio_format: payload.sourceAudioFormat || payload.audioData.format || inferAudioFormatFromUrl(payload.audioData.url),
  679. text_prompt: '',
  680. language: payload.language,
  681. status: Number.isFinite(Number(timbre?.status)) ? Number(timbre.status) : null,
  682. status_label: mapVoiceCloneStatus(timbre?.status),
  683. demo_audio: primaryModel?.demo_audio || '',
  684. available_training_times: null,
  685. image_prompt_name: '',
  686. x_api_resource_id: Array.isArray(primaryModel?.x_api_resource_id) ? String(primaryModel.x_api_resource_id[0] || '') : '',
  687. model_version: String(primaryModel?.version || ''),
  688. icl_speaker_id: String(primaryModel?.icl_speaker_id || ''),
  689. occupied: !!timbre?.occupied,
  690. synthesized_audio_url: '',
  691. synthesized_work_id: '',
  692. last_synthesis_text: '',
  693. latest_audio_url: primaryModel?.demo_audio || '',
  694. message: clonePayload?.data?.tip || '音色复刻成功',
  695. request_id: clonePayload?.request_id || '',
  696. created_at: now,
  697. updated_at: now
  698. });
  699. return {
  700. clonePayload,
  701. profile
  702. };
  703. }
  704. function findVoiceProfileForSynthesis(payload) {
  705. const profiles = readDataFile('voice-profiles');
  706. if (payload.timbreId) {
  707. const byTimbreId = profiles.find((item) => item.timbre_id === payload.timbreId);
  708. if (byTimbreId) {
  709. return byTimbreId;
  710. }
  711. }
  712. if (payload.speakerId) {
  713. return profiles.find((item) => (
  714. String(item.icl_speaker_id || '').trim() === payload.speakerId
  715. || String(item.speaker_id || '').trim() === payload.speakerId
  716. )) || null;
  717. }
  718. return null;
  719. }
  720. function persistSynthesisProfile(profile, payload, synthesis) {
  721. if (!profile) {
  722. return null;
  723. }
  724. return upsertVoiceProfile({
  725. ...profile,
  726. synthesized_audio_url: synthesis.audioUrl,
  727. synthesized_work_id: synthesis.workId,
  728. last_synthesis_text: payload.text,
  729. last_synthesis_ssml: payload.ssml,
  730. last_synthesis_x_api_resource_id: payload.xApiResourceId || profile.x_api_resource_id || '',
  731. last_synthesis_model: payload.model,
  732. last_synthesis_format: payload.audioParams.format,
  733. last_synthesis_sample_rate: payload.audioParams.sampleRate,
  734. last_synthesis_speech_rate: payload.audioParams.speechRate,
  735. last_synthesis_loudness_rate: payload.audioParams.loudnessRate,
  736. last_synthesis_emotion: payload.audioParams.emotion,
  737. last_synthesis_emotion_scale: payload.audioParams.emotionScale,
  738. last_synthesis_enable_subtitle: !!payload.audioParams.enableSubtitle,
  739. last_synthesis_silence_duration: payload.additions?.silence_duration ?? null,
  740. last_synthesis_enable_language_detector: !!payload.additions?.enable_language_detector,
  741. last_synthesis_disable_markdown_filter: !!payload.additions?.disable_markdown_filter,
  742. last_synthesis_disable_emoji_filter: !!payload.additions?.disable_emoji_filter,
  743. last_synthesis_explicit_language: String(payload.additions?.explicit_language || '').trim(),
  744. latest_audio_url: synthesis.audioUrl || profile.latest_audio_url || profile.demo_audio || '',
  745. updated_at: new Date().toISOString()
  746. });
  747. }
  748. async function executeVoiceSynthesisRequest(payload) {
  749. if (!payload.token) {
  750. throw createHttpError('缺少 token 参数', 400);
  751. }
  752. if (!payload.text && !payload.ssml) {
  753. throw createHttpError('文本内容不能为空,text与ssml不能同时为空', 400);
  754. }
  755. const matchedProfile = findVoiceProfileForSynthesis(payload);
  756. if (payload.timbreId) {
  757. const proxyPayload = await requestVolcTtsJson('unidirectional', buildProxySynthesisPayload({
  758. ...payload,
  759. isStream: false
  760. }));
  761. if (Number(proxyPayload?.code) !== 200 || !proxyPayload?.data?.audioUrl) {
  762. throw createHttpError(proxyPayload?.error?.message || proxyPayload?.message || '语音合成失败', 502, proxyPayload || '');
  763. }
  764. const profile = persistSynthesisProfile(matchedProfile, payload, {
  765. audioUrl: proxyPayload.data.audioUrl,
  766. workId: proxyPayload.data.workId || ''
  767. });
  768. return {
  769. response: {
  770. ...proxyPayload,
  771. ...(profile ? { profile } : {})
  772. },
  773. profile
  774. };
  775. }
  776. if (!payload.speakerId) {
  777. throw createHttpError('音色id不能为空', 400);
  778. }
  779. const synthesis = await synthesizeVoiceAudio({
  780. token: payload.token,
  781. speaker: payload.speakerId,
  782. timbreId: matchedProfile?.timbre_id || '',
  783. text: payload.text,
  784. ssml: payload.ssml,
  785. xApiResourceId: payload.xApiResourceId || matchedProfile?.x_api_resource_id || '',
  786. model: payload.model,
  787. isStream: false,
  788. audioParams: payload.audioParams,
  789. additions: payload.additions
  790. });
  791. const profile = persistSynthesisProfile(matchedProfile, payload, synthesis);
  792. return {
  793. response: {
  794. code: 200,
  795. data: {
  796. workId: synthesis.workId,
  797. audioUrl: synthesis.audioUrl
  798. },
  799. ...(profile ? { profile } : {})
  800. },
  801. profile
  802. };
  803. }
  804. // 通用 JSON 数据文件读写
  805. app.post('/api/volcengine/tts/unidirectional', async (req, res) => {
  806. try {
  807. const payload = normalizeVoiceSynthesisPayload(req.body || {});
  808. const { response } = await executeVoiceSynthesisRequest(payload);
  809. res.json(response);
  810. } catch (error) {
  811. res.status(error.status || error.statusCode || 500).json({
  812. code: error.status || error.statusCode || 500,
  813. success: false,
  814. error: error.message || 'TTS 请求失败',
  815. detail: error.detail || '',
  816. });
  817. }
  818. });
  819. app.post('/api/volcengine/tts/voice_clone', async (req, res) => {
  820. try {
  821. const payload = normalizeVoiceClonePayload(req.body || {});
  822. const { response } = await executeVoiceCloneRequest(payload);
  823. res.json(response);
  824. } catch (error) {
  825. res.status(error.status || error.statusCode || 500).json({
  826. code: error.status || error.statusCode || 500,
  827. success: false,
  828. error: error.message || '音色复刻请求失败',
  829. detail: error.detail || '',
  830. });
  831. }
  832. });
  833. const LOCAL_JIMENG_ALLOWED_ENDPOINTS = new Set([
  834. 'getClothesV2',
  835. 'getImgByImg',
  836. 'getImgV4',
  837. 'getText2ImgV3',
  838. 'getText2ImgV31',
  839. 'getImg2ImgV3',
  840. 'getImgV4_pod',
  841. 'getImgV4_goods',
  842. 'getInpaint',
  843. 'getSuperResolution',
  844. 'getVideoV3_720p',
  845. 'getVideoV3_1080p',
  846. 'getVideoV3_Pro',
  847. 'getActor',
  848. 'getActorV2',
  849. 'getDataByTask02',
  850. 'getOhIdentifyMain',
  851. 'getOhDateByTask',
  852. 'getOhDetectMain',
  853. 'getOmniHuman',
  854. ]);
  855. const LOCAL_JIMENG_CHARGEABLE_ENDPOINTS = new Set([
  856. 'getImgV4',
  857. 'getText2ImgV3',
  858. 'getText2ImgV31',
  859. 'getImg2ImgV3',
  860. 'getImgV4_pod',
  861. 'getImgV4_goods',
  862. 'getInpaint',
  863. 'getSuperResolution',
  864. 'getVideoV3_720p',
  865. 'getVideoV3_1080p',
  866. 'getVideoV3_Pro',
  867. 'getActor',
  868. 'getActorV2',
  869. 'getOmniHuman',
  870. ]);
  871. app.post('/api/volcengine/jimeng/:endpoint', async (req, res) => {
  872. const endpoint = String(req.params.endpoint || '').trim();
  873. if (!LOCAL_JIMENG_ALLOWED_ENDPOINTS.has(endpoint)) {
  874. return res.status(400).json({ code: 400, success: false, error: '不支持的即梦接口' });
  875. }
  876. if (LOCAL_JIMENG_CHARGEABLE_ENDPOINTS.has(endpoint)) {
  877. const sessionToken = String(req.headers['x-parse-session-token'] || req.body?.sessionToken || '').trim();
  878. if (!sessionToken) {
  879. return res.status(401).json({
  880. code: 401,
  881. success: false,
  882. error: 'Jimeng generation requires a logged-in Parse session for APIG billing.',
  883. });
  884. }
  885. return res.status(409).json({
  886. code: 409,
  887. success: false,
  888. error: 'Chargeable Jimeng generation must go through cloud-functions/11-jimengManager.js for APIG billing.',
  889. });
  890. }
  891. try {
  892. const token = runtimeConfig.normalizeBearerToken(
  893. req.body?.token
  894. || process.env.JIMENG_TOKEN
  895. || process.env.VOLC_JIMENG_TOKEN
  896. || process.env.VOICE_TOKEN
  897. );
  898. if (!token) {
  899. return res.status(400).json({ code: 400, success: false, error: '未配置 JIMENG_TOKEN、VOLC_JIMENG_TOKEN 或 VOICE_TOKEN' });
  900. }
  901. const baseUrl = String(process.env.JIMENG_BASE_URL || 'https://server.fmode.cn/api/volcengine/jimeng').replace(/\/+$/, '');
  902. const payload = stripEmpty({ ...(req.body || {}), token });
  903. const data = await requestJimengGatewayWithRetry(`${baseUrl}/${endpoint}`, payload, endpoint);
  904. res.json(data);
  905. } catch (error) {
  906. res.status(error.status || 500).json({
  907. code: error.status || 500,
  908. success: false,
  909. error: error.message || '即梦请求失败',
  910. detail: error.detail || '',
  911. });
  912. }
  913. });
  914. app.get('/api/volcengine/jimeng/work/:workId', async (req, res) => {
  915. try {
  916. const workId = String(req.params.workId || '').trim();
  917. if (!workId) return res.status(400).json({ code: 400, success: false, error: '缺少作品 ID' });
  918. const response = await fetch(`${runtimeConfig.parseApiHost}/parse/classes/ImagineWork/${encodeURIComponent(workId)}`, {
  919. headers: { 'X-Parse-Application-Id': runtimeConfig.parseAppId },
  920. });
  921. const { rawText, payload } = await readFetchResponse(response);
  922. if (!response.ok) {
  923. return res.status(response.status).json({
  924. code: response.status,
  925. success: false,
  926. error: payload?.error || rawText || '查询作品结果失败',
  927. });
  928. }
  929. res.json({ code: 200, success: true, data: payload });
  930. } catch (error) {
  931. res.status(500).json({ code: 500, success: false, error: error.message || '查询作品结果失败' });
  932. }
  933. });
  934. async function requestJimengGatewayWithRetry(url, body, endpoint) {
  935. let lastError = null;
  936. const maxAttempts = Math.max(1, Number(process.env.JIMENG_LOCAL_MAX_ATTEMPTS || 3));
  937. for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
  938. try {
  939. const response = await fetch(url, {
  940. method: 'POST',
  941. headers: { 'Content-Type': 'application/json' },
  942. body: JSON.stringify(body),
  943. });
  944. const { rawText, payload } = await readFetchResponse(response);
  945. const status = Number(payload?.code || response.status || 0);
  946. const retryable = !response.ok || status === 408 || status === 429 || status >= 500;
  947. if (!retryable || attempt >= maxAttempts) {
  948. if (!response.ok) {
  949. const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || `HTTP ${response.status}`);
  950. error.status = response.status;
  951. error.detail = payload || rawText || '';
  952. throw error;
  953. }
  954. return payload || { code: 500, success: false, error: '即梦返回异常' };
  955. }
  956. await sleep(Math.min(12000, 1200 * attempt * attempt));
  957. } catch (error) {
  958. lastError = error;
  959. if (attempt >= maxAttempts) break;
  960. await sleep(Math.min(12000, 1200 * attempt * attempt));
  961. }
  962. }
  963. const message = formatNetworkError(lastError);
  964. const error = new Error(`即梦数据上游请求失败:${message};endpoint=${endpoint}`);
  965. error.status = lastError?.status || 500;
  966. error.detail = lastError?.detail || '';
  967. throw error;
  968. }
  969. function readDataFile(name) {
  970. const p = path.join(DATA_DIR, `${name}.json`);
  971. if (!fs.existsSync(p)) { fs.writeFileSync(p, '[]', 'utf-8'); return []; }
  972. return JSON.parse(fs.readFileSync(p, 'utf-8'));
  973. }
  974. function writeDataFile(name, data) {
  975. fs.writeFileSync(path.join(DATA_DIR, `${name}.json`), JSON.stringify(data, null, 2), 'utf-8');
  976. }
  977. function isSafeRemoteUrl(url) {
  978. try {
  979. const parsed = new URL(url);
  980. return ['http:', 'https:'].includes(parsed.protocol);
  981. } catch {
  982. return false;
  983. }
  984. }
  985. function sanitizeFilename(filename) {
  986. const safeName = String(filename || '')
  987. .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
  988. .trim();
  989. return safeName || `video-${Date.now()}.mp4`;
  990. }
  991. function ensureVideoFilename(filename, sourceUrl = '') {
  992. const safeName = sanitizeFilename(filename);
  993. if (path.extname(safeName)) {
  994. return safeName;
  995. }
  996. try {
  997. const parsed = new URL(sourceUrl);
  998. const sourceExt = path.extname(parsed.pathname || '').toLowerCase();
  999. if (sourceExt) {
  1000. return `${safeName}${sourceExt}`;
  1001. }
  1002. } catch {}
  1003. return `${safeName}.mp4`;
  1004. }
  1005. function ensureUniqueVideoFilename(filename) {
  1006. const ext = path.extname(filename) || '.mp4';
  1007. const baseName = path.basename(filename, ext);
  1008. let candidate = filename;
  1009. let counter = 1;
  1010. while (fs.existsSync(path.join(DATA_VIDEO_DIR, candidate)) || fs.existsSync(path.join(LEGACY_VIDEO_DIR, candidate))) {
  1011. candidate = `${baseName}-${Date.now()}-${counter}${ext}`;
  1012. counter += 1;
  1013. }
  1014. return candidate;
  1015. }
  1016. function createManagedVideoEntry({ filename, title, description, tags, thumbnail, duration, resolution, awemeId, authorName, size }) {
  1017. const ext = path.extname(filename).replace('.', '').toLowerCase() || 'mp4';
  1018. const now = new Date().toISOString();
  1019. return {
  1020. id: `VID-${Date.now()}`,
  1021. title: title || path.basename(filename, path.extname(filename)),
  1022. filename,
  1023. size: size || 0,
  1024. duration: Number(duration) || 0,
  1025. created_at: now,
  1026. modified_at: now,
  1027. category: 'downloaded',
  1028. tags: Array.isArray(tags) ? tags : [],
  1029. description: description || '',
  1030. thumbnail: thumbnail || '',
  1031. source: 'downloaded',
  1032. aweme_id: awemeId || '',
  1033. metadata: {
  1034. resolution: resolution || '未知',
  1035. format: ext,
  1036. authorName: authorName || ''
  1037. }
  1038. };
  1039. }
  1040. function normalizeRemoteUrls(primaryUrl, urls = []) {
  1041. return [primaryUrl, ...(Array.isArray(urls) ? urls : [])].filter((url, index, list) => (
  1042. typeof url === 'string'
  1043. && isSafeRemoteUrl(url)
  1044. && list.indexOf(url) === index
  1045. ));
  1046. }
  1047. async function fetchRemoteVideoResponse(urls, requestHeaders = {}) {
  1048. let lastError = null;
  1049. for (const currentUrl of urls) {
  1050. try {
  1051. const response = await fetch(currentUrl, {
  1052. method: 'GET',
  1053. headers: requestHeaders,
  1054. redirect: 'follow'
  1055. });
  1056. if (!response.ok) {
  1057. const detail = await response.text().catch(() => '');
  1058. lastError = new Error(`远程下载失败: ${response.status} ${response.statusText}${detail ? ` ${detail.slice(0, 200)}` : ''}`);
  1059. continue;
  1060. }
  1061. if (!response.body) {
  1062. lastError = new Error('远程响应缺少视频流');
  1063. continue;
  1064. }
  1065. return { url: currentUrl, response };
  1066. } catch (error) {
  1067. lastError = error;
  1068. }
  1069. }
  1070. throw lastError || new Error('没有可用的远程视频地址');
  1071. }
  1072. // ==================== Whisper 转录 ====================
  1073. registerWhisperRoutes(app, {
  1074. exec,
  1075. fs,
  1076. path,
  1077. projectRoot: PROJECT_ROOT,
  1078. whisperDir: WHISPER_DIR,
  1079. readManifest,
  1080. writeManifest,
  1081. resolveVideoPath
  1082. });
  1083. // ==================== Manifest 管理 ====================
  1084. // 已迁移至云函数 manifestManager(cloud-functions/01-manifestManager.js);历史本地参考见 server/routes/manifest-legacy-reference.js。
  1085. // ==================== 视频上传 ====================
  1086. registerUploadRoutes(app, { upload, readManifest, writeManifest });
  1087. // 上传错误处理
  1088. app.use((err, req, res, next) => {
  1089. if (err instanceof multer.MulterError) {
  1090. if (err.code === 'LIMIT_FILE_SIZE') {
  1091. return res.status(413).json({ error: '文件大小超过限制(最大 500MB)' });
  1092. }
  1093. return res.status(400).json({ error: `上传错误: ${err.message}` });
  1094. }
  1095. if (err) {
  1096. return res.status(400).json({ error: err.message });
  1097. }
  1098. next();
  1099. });
  1100. registerAudioExtractionRoutes(app, {
  1101. readManifest,
  1102. resolveVideoPath,
  1103. removeFileIfExists,
  1104. dataRemixAssetDir: DATA_REMIX_ASSET_DIR,
  1105. projectRoot: PROJECT_ROOT
  1106. });
  1107. registerRemixAssetUploadRoutes(app, {
  1108. path,
  1109. assetUpload,
  1110. qiniuUploadUrl: QINIU_UPLOAD_URL,
  1111. qiniuCdnDomain: QINIU_CDN_DOMAIN,
  1112. buildDigitalHumanAssetKey,
  1113. buildQiniuUploadToken
  1114. });
  1115. // ==================== 文件操作 ====================
  1116. registerFileRoutes(app, {
  1117. fs,
  1118. path,
  1119. projectRoot: PROJECT_ROOT,
  1120. whisperDir: WHISPER_DIR,
  1121. readManifest
  1122. });
  1123. registerVideoDownloadRoutes(app, {
  1124. path,
  1125. fs,
  1126. crypto,
  1127. Readable,
  1128. Transform,
  1129. pipeline,
  1130. dataVideoDir: DATA_VIDEO_DIR,
  1131. downloadTasks,
  1132. normalizeRemoteUrls,
  1133. ensureVideoFilename,
  1134. ensureUniqueVideoFilename,
  1135. fetchRemoteVideoResponse,
  1136. createManagedVideoEntry,
  1137. readManifest,
  1138. writeManifest,
  1139. removeFileIfExists
  1140. });
  1141. // ==================== 视频流代理 ====================
  1142. registerVideoProxyRoutes(app, { isSafeRemoteUrl });
  1143. // ==================== AI 重塑记录 ====================
  1144. // ==================== AI 重塑记录 ====================
  1145. // 已迁移至云函数 remixManager(cloud-functions/05-remixManager.js);历史本地参考见 server/routes/cloud-managed-legacy-reference.js。
  1146. // ==================== 一键成片(Quickly 代理) ====================
  1147. registerQuicklyRoutes(app, runtimeConfig.quickly);
  1148. // ==================== 视频合成(图片+音频 → 视频)====================
  1149. registerVideoCompositeRoutes(app, {
  1150. fs,
  1151. path,
  1152. spawn,
  1153. dataDir: DATA_DIR,
  1154. removeFileIfExists
  1155. });
  1156. // ==================== 语音管理 ====================
  1157. // 已迁移至云函数 voiceManager(cloud-functions/06-voiceManager.js);历史本地参考见 server/routes/cloud-managed-legacy-reference.js。
  1158. // ==================== 任务/历史/结果 ====================
  1159. // 已迁移至云函数 taskManager/historyManager/resultManager;历史本地参考见 server/routes/cloud-managed-legacy-reference.js。
  1160. // ==================== LLM 大模型代理 ====================
  1161. registerLlmRoutes(app, {
  1162. llmBaseUrl: runtimeConfig.llm.baseUrl,
  1163. llmApiKey: runtimeConfig.llm.apiKey
  1164. });
  1165. // ==================== Douyin transcript worker ====================
  1166. function cleanText(value) {
  1167. return String(value || '').trim();
  1168. }
  1169. function getVocToken() {
  1170. return cleanText(
  1171. process.env.DOUYIN_API_TOKEN
  1172. || process.env.VOC_TOKEN
  1173. || process.env.TRANSCRIPTION_VOC_TOKEN
  1174. || process.env.VOICE_TOKEN
  1175. || process.env.OPENCLAW_VOC_TOKEN
  1176. || process.env.VOC_SOCIAL_TOKEN
  1177. );
  1178. }
  1179. function bearerAuth(token) {
  1180. const value = cleanText(token);
  1181. return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
  1182. }
  1183. function stripEmpty(value) {
  1184. if (!value || typeof value !== 'object') return value;
  1185. const out = Array.isArray(value) ? [] : {};
  1186. for (const [key, val] of Object.entries(value)) {
  1187. if (val === undefined || val === null || val === '') continue;
  1188. if (val && typeof val === 'object' && !Array.isArray(val)) {
  1189. const nested = stripEmpty(val);
  1190. if (Object.keys(nested).length) out[key] = nested;
  1191. } else {
  1192. out[key] = val;
  1193. }
  1194. }
  1195. return out;
  1196. }
  1197. function readGatewayError(data, fallback) {
  1198. return data?.error?.message || data?.error || data?.mess || data?.message || data?.msg || data?.detail || fallback || '';
  1199. }
  1200. function sleep(ms) {
  1201. return new Promise(resolve => setTimeout(resolve, ms));
  1202. }
  1203. function formatNetworkError(error) {
  1204. const cause = error?.cause?.code || error?.cause?.message || '';
  1205. return `${error?.message || 'fetch failed'}${cause ? `;cause=${cause}` : ''}`;
  1206. }
  1207. async function fetchDouyinGatewayWithRetry(url, init, routePath) {
  1208. let lastError = null;
  1209. const attemptLimit = /fetch_video_comments|fetch_video_comment_replies/.test(routePath)
  1210. ? Math.max(DOUYIN_GATEWAY_MAX_ATTEMPTS, 6)
  1211. : DOUYIN_GATEWAY_MAX_ATTEMPTS;
  1212. for (let attempt = 1; attempt <= attemptLimit; attempt += 1) {
  1213. try {
  1214. const response = await fetch(url, init);
  1215. if (response.ok || response.status < 500 || attempt >= attemptLimit) {
  1216. return response;
  1217. }
  1218. console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${attemptLimit} returned HTTP ${response.status}`);
  1219. await response.body?.cancel().catch(() => {});
  1220. } catch (error) {
  1221. lastError = error;
  1222. const reason = formatNetworkError(error);
  1223. console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${attemptLimit} failed: ${reason}`);
  1224. }
  1225. if (attempt < attemptLimit) {
  1226. const delayMs = /fetch_video_comments|fetch_video_comment_replies/.test(routePath)
  1227. ? Math.min(700 * attempt, 3000)
  1228. : Math.min(300 * attempt, 1200);
  1229. await sleep(delayMs);
  1230. }
  1231. }
  1232. throw lastError || new Error('fetch failed');
  1233. }
  1234. async function requestDouyinGateway(routeName, params = {}, payload = {}) {
  1235. const route = DOUYIN_API_ROUTES[routeName];
  1236. if (!route) {
  1237. throw Object.assign(new Error('不支持的抖音接口'), { statusCode: 400 });
  1238. }
  1239. const token = getVocToken();
  1240. if (!token) {
  1241. throw Object.assign(new Error('本地抖音数据网关未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN。'), { statusCode: 400 });
  1242. }
  1243. const url = new URL(`${DOUYIN_API_BASE_URL}${route.path}`);
  1244. const init = {
  1245. method: route.method,
  1246. headers: {
  1247. 'Content-Type': 'application/json',
  1248. Accept: 'application/json',
  1249. Authorization: bearerAuth(token),
  1250. },
  1251. };
  1252. if (route.method === 'GET') {
  1253. const query = { ...(payload || {}), ...(params || {}) };
  1254. if (routeName === 'comments' || routeName === 'replies') {
  1255. query.count = Math.min(Math.max(Number(query.count) || 20, 1), 20);
  1256. }
  1257. for (const [key, value] of Object.entries(query)) {
  1258. if (value !== undefined && value !== null && value !== '') {
  1259. url.searchParams.set(key, String(value));
  1260. }
  1261. }
  1262. } else {
  1263. init.body = JSON.stringify(stripEmpty(payload || {}));
  1264. }
  1265. let response;
  1266. try {
  1267. response = await fetchDouyinGatewayWithRetry(url.toString(), init, route.path);
  1268. } catch (error) {
  1269. throw Object.assign(new Error(`本地抖音数据网关网络请求失败:${formatNetworkError(error)};attempts=${DOUYIN_GATEWAY_MAX_ATTEMPTS};base=${DOUYIN_API_BASE_URL} route=${route.path}`), {
  1270. statusCode: 502,
  1271. });
  1272. }
  1273. const text = await response.text();
  1274. let data = null;
  1275. try { data = text ? JSON.parse(text) : null; } catch {}
  1276. if (!response.ok || data?.success === false) {
  1277. const upstreamError = readGatewayError(data, text) || '上游未返回错误详情';
  1278. throw Object.assign(new Error(`抖音数据接口请求失败:${upstreamError};HTTP ${response.status};base=${DOUYIN_API_BASE_URL} route=${route.path}`), {
  1279. statusCode: response.status || 500,
  1280. });
  1281. }
  1282. return data || { code: 500, success: false, error: '服务返回异常' };
  1283. }
  1284. app.post('/api/douyin/call', async (req, res) => {
  1285. try {
  1286. const { route, params = {}, payload = {}, optional = false } = req.body || {};
  1287. const data = await requestDouyinGateway(route, params, payload);
  1288. res.json({ success: true, data });
  1289. } catch (error) {
  1290. if (req.body?.optional) {
  1291. return res.json({
  1292. success: false,
  1293. optional: true,
  1294. error: error.message || '本地抖音数据网关调用失败',
  1295. });
  1296. }
  1297. res.status(error.statusCode || 500).json({
  1298. success: false,
  1299. error: error.message || '本地抖音数据网关调用失败',
  1300. });
  1301. }
  1302. });
  1303. app.get('/api/douyin/diagnose', async (req, res) => {
  1304. const routeName = cleanText(req.query.route || 'videoDetail');
  1305. const route = DOUYIN_API_ROUTES[routeName] || DOUYIN_API_ROUTES.videoDetail;
  1306. const result = {
  1307. success: true,
  1308. data: {
  1309. baseUrl: DOUYIN_API_BASE_URL,
  1310. routeName,
  1311. routePath: route.path,
  1312. tokenConfigured: !!getVocToken(),
  1313. probe: null,
  1314. },
  1315. };
  1316. if (String(req.query.probe || '') === '1') {
  1317. try {
  1318. result.data.probe = await requestDouyinGateway(routeName, { aweme_id: cleanText(req.query.awemeId) || '7592116912205630761' });
  1319. } catch (error) {
  1320. result.data.probe = { success: false, error: error.message || '探测失败' };
  1321. }
  1322. }
  1323. res.json(result);
  1324. });
  1325. function createTranscriptJob(input) {
  1326. const now = new Date().toISOString();
  1327. return {
  1328. id: `transcript_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
  1329. awemeId: cleanText(input.awemeId),
  1330. analysisId: cleanText(input.analysisId),
  1331. provider: cleanText(input.provider) || 'iflytek-gateway',
  1332. status: 'queued',
  1333. stageLabel: '已创建转写任务',
  1334. progress: 0,
  1335. warnings: [],
  1336. createdAt: now,
  1337. updatedAt: now
  1338. };
  1339. }
  1340. function updateTranscriptJob(job, patch) {
  1341. Object.assign(job, patch, { updatedAt: new Date().toISOString() });
  1342. transcriptTasks.set(job.id, job);
  1343. return job;
  1344. }
  1345. function findAwemeDetail(node, depth = 0) {
  1346. if (!node || depth > 8) return null;
  1347. if (Array.isArray(node)) {
  1348. for (const item of node) {
  1349. const found = findAwemeDetail(item, depth + 1);
  1350. if (found) return found;
  1351. }
  1352. return null;
  1353. }
  1354. if (typeof node !== 'object') return null;
  1355. if (node.aweme_id && node.video) return node;
  1356. if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail;
  1357. if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info;
  1358. for (const value of Object.values(node)) {
  1359. const found = findAwemeDetail(value, depth + 1);
  1360. if (found) return found;
  1361. }
  1362. return null;
  1363. }
  1364. function extractAwemeId(value) {
  1365. const text = cleanText(value);
  1366. const patterns = [/aweme_id=(\d+)/, /modal_id=(\d+)/, /douyin\.com\/video\/(\d+)/, /douyin\.com\/share\/video\/(\d+)/, /\b(\d{15,25})\b/];
  1367. for (const pattern of patterns) {
  1368. const match = text.match(pattern);
  1369. if (match?.[1]) return match[1];
  1370. }
  1371. return /^\d{15,25}$/.test(text) ? text : '';
  1372. }
  1373. function decodeMaybeBase64Url(value) {
  1374. const text = cleanText(value);
  1375. if (/^https?:\/\//i.test(text)) return text;
  1376. if (!/^[A-Za-z0-9+/=_-]{20,}$/.test(text)) return '';
  1377. try {
  1378. const decoded = Buffer.from(text, 'base64').toString('utf8');
  1379. return /^https?:\/\//i.test(decoded) ? decoded : '';
  1380. } catch {
  1381. return '';
  1382. }
  1383. }
  1384. function isLikelyMediaUrl(url) {
  1385. return /^https?:\/\//i.test(url) && !/\.(?:jpg|jpeg|png|webp|gif)(?:\?|$)/i.test(url);
  1386. }
  1387. function inferMediaKind(pathParts, url) {
  1388. const joined = pathParts.join('.').toLowerCase();
  1389. if (/audio|mp4a|music|sound/.test(joined) || /audio|mp4a/i.test(url)) return 'audio';
  1390. if (/play_addr|download_addr|bit_rate|video|media/.test(joined) || isLikelyMediaUrl(url)) return 'video';
  1391. return 'unknown';
  1392. }
  1393. function collectMediaCandidates(node, pathParts = [], out = []) {
  1394. if (!node) return out;
  1395. if (Array.isArray(node)) {
  1396. node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out));
  1397. return out;
  1398. }
  1399. if (typeof node !== 'object') return out;
  1400. for (const [key, value] of Object.entries(node)) {
  1401. const nextPath = [...pathParts, key];
  1402. if (key === 'url_list' && Array.isArray(value)) {
  1403. value.forEach((item, index) => {
  1404. const url = decodeMaybeBase64Url(item);
  1405. const kind = inferMediaKind(nextPath, url);
  1406. if (url && ['audio', 'video'].includes(kind)) {
  1407. out.push({
  1408. url,
  1409. kind,
  1410. keyPath: nextPath.join('.'),
  1411. index,
  1412. dataSize: Number(node.data_size || node.size || 0),
  1413. bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
  1414. });
  1415. }
  1416. });
  1417. } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') {
  1418. const url = decodeMaybeBase64Url(value);
  1419. const kind = inferMediaKind(nextPath, url);
  1420. if (url && ['audio', 'video'].includes(kind)) {
  1421. out.push({
  1422. url,
  1423. kind,
  1424. keyPath: nextPath.join('.'),
  1425. dataSize: Number(node.data_size || node.size || 0),
  1426. bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
  1427. });
  1428. }
  1429. }
  1430. collectMediaCandidates(value, nextPath, out);
  1431. }
  1432. return out;
  1433. }
  1434. function selectMediaCandidate(detail) {
  1435. const seen = new Set();
  1436. const candidates = collectMediaCandidates(detail)
  1437. .filter(item => isLikelyMediaUrl(item.url))
  1438. .filter(item => {
  1439. if (seen.has(item.url)) return false;
  1440. seen.add(item.url);
  1441. return true;
  1442. });
  1443. candidates.sort((a, b) => {
  1444. const aPreferred = a.kind === 'audio' ? 0 : 1;
  1445. const bPreferred = b.kind === 'audio' ? 0 : 1;
  1446. if (aPreferred !== bPreferred) return aPreferred - bPreferred;
  1447. const rank = item => {
  1448. const keyPath = String(item.keyPath || '').toLowerCase();
  1449. const url = String(item.url || '').toLowerCase();
  1450. if (/strong_beat|beats_tr|music\./.test(`${keyPath} ${url}`)) return 5;
  1451. if (item.kind === 'audio' && keyPath.includes('video.dynamic_audio_list')) return 0;
  1452. if (item.kind === 'audio' && keyPath.includes('video.bit_rate_audio')) return 1;
  1453. if (item.kind === 'audio' && keyPath.includes('music.')) return 4;
  1454. return 2;
  1455. };
  1456. const aRank = rank(a);
  1457. const bRank = rank(b);
  1458. if (aRank !== bRank) return aRank - bRank;
  1459. return Number(b.dataSize || 0) - Number(a.dataSize || 0);
  1460. });
  1461. return candidates[0] || null;
  1462. }
  1463. function durationFromDetail(detail) {
  1464. const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs;
  1465. const numeric = Number(raw || 0);
  1466. if (!Number.isFinite(numeric) || numeric <= 0) return 0;
  1467. return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
  1468. }
  1469. function extensionFromUrl(url, fallback) {
  1470. try {
  1471. const parsed = new URL(url);
  1472. const ext = path.extname(parsed.pathname).toLowerCase();
  1473. if (/^\.(mp4|m4a|mp3|wav|aac|mov|webm)$/i.test(ext)) return ext;
  1474. } catch {}
  1475. return fallback;
  1476. }
  1477. async function fetchTranscriptUpstream(url, init, timeoutMs, label) {
  1478. try {
  1479. return await fetch(url, {
  1480. ...init,
  1481. signal: AbortSignal.timeout(timeoutMs)
  1482. });
  1483. } catch (error) {
  1484. throw new Error(`${label}失败:${formatNetworkError(error)}`);
  1485. }
  1486. }
  1487. async function fetchDouyinDetailForTranscript(input) {
  1488. const supplied = findAwemeDetail(input.detail);
  1489. if (supplied?.aweme_id && supplied.video) return supplied;
  1490. const token = getVocToken();
  1491. if (!token) {
  1492. throw Object.assign(new Error('未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN,无法获取抖音视频详情。'), {
  1493. status: 'needs_provider_config'
  1494. });
  1495. }
  1496. const awemeId = cleanText(input.awemeId) || extractAwemeId(input.sourceUrl || input.url);
  1497. if (!awemeId) {
  1498. throw Object.assign(new Error('缺少 awemeId,无法获取视频详情。'), { status: 'needs_media' });
  1499. }
  1500. const url = new URL(`${DOUYIN_API_BASE_URL}/douyin/app/v3/fetch_one_video_v3`);
  1501. url.searchParams.set('aweme_id', awemeId);
  1502. const response = await fetchTranscriptUpstream(url.toString(), {
  1503. method: 'GET',
  1504. headers: {
  1505. Accept: 'application/json',
  1506. Authorization: bearerAuth(token)
  1507. }
  1508. }, 45000, '获取抖音视频详情');
  1509. const data = await response.json().catch(() => ({}));
  1510. if (!response.ok || data.success === false) {
  1511. throw new Error(data.error?.message || data.error || data.message || data.mess || `抖音详情获取失败 HTTP ${response.status}`);
  1512. }
  1513. const detail = findAwemeDetail(data);
  1514. if (!detail?.aweme_id) {
  1515. throw Object.assign(new Error('抖音详情响应中未找到 aweme_detail。'), { status: 'needs_media' });
  1516. }
  1517. return detail;
  1518. }
  1519. async function downloadTranscriptMedia(candidate, job) {
  1520. fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
  1521. const ext = extensionFromUrl(candidate.url, candidate.kind === 'audio' ? '.m4a' : '.mp4');
  1522. const filePath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}-source${ext}`);
  1523. const { response } = await fetchRemoteVideoResponse([candidate.url], {
  1524. Accept: '*/*',
  1525. 'User-Agent': 'Mozilla/5.0',
  1526. Referer: 'https://www.douyin.com/',
  1527. Origin: 'https://www.douyin.com'
  1528. });
  1529. await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath));
  1530. return filePath;
  1531. }
  1532. function extractAudioForTranscript(inputPath, job) {
  1533. return new Promise((resolve, reject) => {
  1534. fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
  1535. const outputPath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}.m4a`);
  1536. const args = ['-y', '-i', inputPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath];
  1537. const proc = spawn('ffmpeg', args, { cwd: PROJECT_ROOT });
  1538. let stderr = '';
  1539. proc.stderr.on('data', data => { stderr += data.toString(); });
  1540. proc.on('error', err => {
  1541. reject(Object.assign(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并加入 PATH。' : err.message), {
  1542. status: 'needs_media_processing'
  1543. }));
  1544. });
  1545. proc.on('close', code => {
  1546. if (code !== 0 || !fs.existsSync(outputPath)) {
  1547. const lastLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
  1548. reject(Object.assign(new Error(`音频提取失败${lastLine ? `:${lastLine}` : ''}`), {
  1549. status: 'needs_media_processing'
  1550. }));
  1551. return;
  1552. }
  1553. resolve(outputPath);
  1554. });
  1555. });
  1556. }
  1557. async function uploadTranscriptGateway(filePath, durationMs) {
  1558. const token = getVocToken();
  1559. if (!token) {
  1560. throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。'), {
  1561. status: 'needs_provider_config'
  1562. });
  1563. }
  1564. if (!durationMs) {
  1565. throw Object.assign(new Error('缺少音频时长 durationMs,无法提交转写网关。'), { status: 'needs_media' });
  1566. }
  1567. const buffer = fs.readFileSync(filePath);
  1568. const form = new FormData();
  1569. form.append('audio', new Blob([buffer], { type: 'audio/mp4' }), path.basename(filePath));
  1570. form.append('durationMs', String(durationMs));
  1571. form.append('roleType', '1');
  1572. form.append('roleNum', '0');
  1573. const response = await fetchTranscriptUpstream(`${TRANSCRIPTION_GATEWAY_BASE_URL}/upload`, {
  1574. method: 'POST',
  1575. headers: {
  1576. Authorization: bearerAuth(token),
  1577. Accept: 'application/json'
  1578. },
  1579. body: form
  1580. }, 120000, '提交转写任务');
  1581. const data = await response.json().catch(() => ({}));
  1582. if (!response.ok || data.success === false) {
  1583. throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${response.status}`);
  1584. }
  1585. const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
  1586. if (!orderId) throw new Error('转写网关未返回 orderId。');
  1587. return {
  1588. orderId,
  1589. estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0)
  1590. };
  1591. }
  1592. async function queryTranscriptGateway(orderId) {
  1593. const token = getVocToken();
  1594. if (!token) {
  1595. throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'), {
  1596. status: 'needs_provider_config'
  1597. });
  1598. }
  1599. const response = await fetchTranscriptUpstream(`${TRANSCRIPTION_GATEWAY_BASE_URL}/result`, {
  1600. method: 'POST',
  1601. headers: {
  1602. Authorization: bearerAuth(token),
  1603. Accept: 'application/json',
  1604. 'Content-Type': 'application/json'
  1605. },
  1606. body: JSON.stringify({ orderId })
  1607. }, 45000, '查询转写结果');
  1608. const data = await response.json().catch(() => ({}));
  1609. if (!response.ok) {
  1610. throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${response.status}`);
  1611. }
  1612. return data;
  1613. }
  1614. function gatewayValue(data, key) {
  1615. return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
  1616. }
  1617. function normalizeSegmentTime(value) {
  1618. const number = Number(value);
  1619. if (!Number.isFinite(number)) return null;
  1620. return number > 1000 ? number / 1000 : number;
  1621. }
  1622. function normalizeTranscriptSegments(segments) {
  1623. return (Array.isArray(segments) ? segments : []).map(segment => ({
  1624. start: normalizeSegmentTime(segment.start ?? segment.begin ?? segment.bg),
  1625. end: normalizeSegmentTime(segment.end ?? segment.ed),
  1626. text: cleanText(segment.text || segment.onebest || segment.content)
  1627. })).filter(segment => segment.text);
  1628. }
  1629. async function pollTranscriptJob(job) {
  1630. if (!job.orderId || job.status === 'completed' || job.status === 'failed') return job;
  1631. const data = await queryTranscriptGateway(job.orderId);
  1632. const status = cleanText(gatewayValue(data, 'status')).toLowerCase();
  1633. const text = cleanText(gatewayValue(data, 'text'));
  1634. const segments = normalizeTranscriptSegments(gatewayValue(data, 'segments'));
  1635. if (status === 'completed' || text || segments.length) {
  1636. return updateTranscriptJob(job, {
  1637. status: 'completed',
  1638. stageLabel: '转写完成',
  1639. progress: 100,
  1640. text: text || segments.map(segment => segment.text).join('\n'),
  1641. segments
  1642. });
  1643. }
  1644. if (status === 'failed' || status === 'error') {
  1645. return updateTranscriptJob(job, {
  1646. status: 'failed',
  1647. stageLabel: '转写失败',
  1648. errorMessage: gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写网关返回失败'
  1649. });
  1650. }
  1651. return updateTranscriptJob(job, {
  1652. status: 'polling_provider',
  1653. stageLabel: '转写处理中',
  1654. progress: Math.max(Number(job.progress || 0), 85),
  1655. warnings: [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])]
  1656. });
  1657. }
  1658. async function runTranscriptJob(job, input) {
  1659. try {
  1660. updateTranscriptJob(job, { status: 'resolving_detail', stageLabel: '正在获取视频详情', progress: 10 });
  1661. const detail = await fetchDouyinDetailForTranscript(input);
  1662. const awemeId = cleanText(detail.aweme_id || job.awemeId);
  1663. if (awemeId && awemeId !== job.awemeId) updateTranscriptJob(job, { awemeId });
  1664. updateTranscriptJob(job, { status: 'selecting_media', stageLabel: '正在选择可转写媒体', progress: 25 });
  1665. const suppliedMediaUrl = cleanText(input.mediaUrl);
  1666. const candidate = suppliedMediaUrl
  1667. ? { url: suppliedMediaUrl, kind: /audio|m4a|mp3|aac/i.test(suppliedMediaUrl) ? 'audio' : 'video', keyPath: 'input.mediaUrl' }
  1668. : selectMediaCandidate(detail);
  1669. if (!candidate?.url) {
  1670. updateTranscriptJob(job, {
  1671. status: 'needs_media',
  1672. stageLabel: '未找到可转写媒体',
  1673. progress: 25,
  1674. warnings: [...(job.warnings || []), '视频详情中未找到音频或视频下载地址。']
  1675. });
  1676. return;
  1677. }
  1678. updateTranscriptJob(job, {
  1679. status: 'downloading_media',
  1680. stageLabel: '正在下载媒体',
  1681. progress: 45,
  1682. mediaUrl: candidate.url,
  1683. sourceKind: candidate.kind === 'audio' ? 'douyin_audio' : 'douyin_video'
  1684. });
  1685. const sourcePath = await downloadTranscriptMedia(candidate, job);
  1686. updateTranscriptJob(job, { localVideoPath: sourcePath });
  1687. const durationMs = Number(input.durationMs || durationFromDetail(detail) || 0);
  1688. let audioPath = sourcePath;
  1689. if (candidate.kind !== 'audio') {
  1690. updateTranscriptJob(job, { status: 'extracting_audio', stageLabel: '正在提取音频', progress: 65 });
  1691. audioPath = await extractAudioForTranscript(sourcePath, job);
  1692. }
  1693. updateTranscriptJob(job, {
  1694. status: 'submitting_provider',
  1695. stageLabel: '正在提交转写网关',
  1696. progress: 80,
  1697. localAudioPath: audioPath,
  1698. durationMs
  1699. });
  1700. const uploaded = await uploadTranscriptGateway(audioPath, durationMs);
  1701. updateTranscriptJob(job, {
  1702. status: 'polling_provider',
  1703. stageLabel: '转写任务已提交',
  1704. progress: 85,
  1705. orderId: uploaded.orderId,
  1706. estimateTime: uploaded.estimateTime,
  1707. warnings: [...(job.warnings || []), `已提交转写任务:${uploaded.orderId}`]
  1708. });
  1709. } catch (error) {
  1710. updateTranscriptJob(job, {
  1711. status: error.status || 'failed',
  1712. stageLabel: '转写任务失败',
  1713. errorMessage: error.message || '转写任务失败',
  1714. warnings: [...(job.warnings || []), error.message || '转写任务失败']
  1715. });
  1716. }
  1717. }
  1718. app.post('/api/douyin/transcript/start', (req, res) => {
  1719. const input = req.body || {};
  1720. if (!cleanText(input.awemeId) && !cleanText(input.sourceUrl) && !cleanText(input.mediaUrl)) {
  1721. return res.status(400).json({ success: false, error: '缺少 awemeId、sourceUrl 或 mediaUrl,无法创建转写任务。' });
  1722. }
  1723. const job = createTranscriptJob(input);
  1724. updateTranscriptJob(job, {
  1725. status: 'resolving_detail',
  1726. stageLabel: '正在获取视频详情',
  1727. progress: 5
  1728. });
  1729. res.json({ success: true, job: { ...job } });
  1730. setImmediate(() => { void runTranscriptJob(job, input); });
  1731. });
  1732. app.get('/api/douyin/transcript/:jobId', async (req, res) => {
  1733. const job = transcriptTasks.get(req.params.jobId);
  1734. if (!job) {
  1735. return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
  1736. }
  1737. try {
  1738. if (job.status === 'polling_provider' && job.orderId) {
  1739. await pollTranscriptJob(job);
  1740. }
  1741. res.json({ success: true, job: { ...job } });
  1742. } catch (error) {
  1743. updateTranscriptJob(job, {
  1744. status: error.status || 'failed',
  1745. stageLabel: '查询转写结果失败',
  1746. errorMessage: error.message || '查询转写结果失败',
  1747. warnings: [...(job.warnings || []), error.message || '查询转写结果失败']
  1748. });
  1749. res.json({ success: true, job: { ...job } });
  1750. }
  1751. });
  1752. app.post('/api/douyin/transcript/:jobId/retry', (req, res) => {
  1753. const previous = transcriptTasks.get(req.params.jobId);
  1754. if (!previous) {
  1755. return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
  1756. }
  1757. const body = req.body || {};
  1758. const input = {
  1759. ...body,
  1760. awemeId: body.awemeId || previous.awemeId,
  1761. analysisId: body.analysisId || previous.analysisId,
  1762. mediaUrl: body.mediaUrl || previous.mediaUrl
  1763. };
  1764. const job = createTranscriptJob(input);
  1765. updateTranscriptJob(job, {
  1766. status: 'resolving_detail',
  1767. stageLabel: '正在获取视频详情',
  1768. progress: 5
  1769. });
  1770. res.json({ success: true, job: { ...job } });
  1771. setImmediate(() => { void runTranscriptJob(job, input); });
  1772. });
  1773. // ==================== 健康检查 ====================
  1774. registerHealthRoutes(app, {
  1775. fs,
  1776. projectRoot: PROJECT_ROOT,
  1777. manifestPath: MANIFEST_PATH,
  1778. whisperDir: WHISPER_DIR,
  1779. dataVideoDir: DATA_VIDEO_DIR,
  1780. legacyVideoDir: LEGACY_VIDEO_DIR,
  1781. transcriptTempDir: TRANSCRIPT_TEMP_DIR,
  1782. transcriptTasks,
  1783. douyinApiBaseUrl: DOUYIN_API_BASE_URL,
  1784. transcriptionGatewayBaseUrl: TRANSCRIPTION_GATEWAY_BASE_URL,
  1785. douyinGatewayMaxAttempts: DOUYIN_GATEWAY_MAX_ATTEMPTS,
  1786. getVocToken
  1787. });
  1788. // ==================== 启动 ====================
  1789. app.listen(PORT, () => {
  1790. printStartupLog({ port: PORT, projectRoot: PROJECT_ROOT });
  1791. });