server.js 80 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394
  1. const express = require('express');
  2. const { exec, spawn } = 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 app = express();
  33. const PORT = 3000;
  34. const PROJECT_ROOT = __dirname;
  35. const DATA_DIR = path.join(PROJECT_ROOT, 'data');
  36. const DATA_VIDEO_DIR = path.join(DATA_DIR, 'videos');
  37. const DATA_REMIX_ASSET_DIR = path.join(DATA_DIR, 'remix-assets');
  38. const LEGACY_VIDEO_DIR = path.join(PROJECT_ROOT, 'src', 'video');
  39. const VOICE_SPEAKER_ID_DOC_PATH = path.join(PROJECT_ROOT, 'docs', '音色创建', 'speaker_id.md');
  40. const MANIFEST_PATH = path.join(DATA_DIR, 'manifest.json');
  41. const LEGACY_MANIFEST_PATH = path.join(LEGACY_VIDEO_DIR, 'manifest.json');
  42. const WHISPER_DIR = path.join(PROJECT_ROOT, 'Whisper');
  43. const downloadTasks = new Map();
  44. const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
  45. const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
  46. const QINIU_BUCKET = 'nova-repos';
  47. const QINIU_CDN_DOMAIN = 'https://repos.fmode.cn';
  48. const QINIU_CDN_PREFIX = 'x/openclaw-skills';
  49. const QINIU_UPLOAD_URL = 'https://up-z2.qiniup.com';
  50. const VOLC_SPEECH_BASE_URL = 'https://openspeech.bytedance.com';
  51. const VOLC_TTS_PROXY_BASE_URL = process.env.VOLC_TTS_PROXY_BASE_URL || 'https://server.fmode.cn/api/volcengine/tts';
  52. const VOLC_SPEECH_API_KEY = process.env.VOLC_SPEECH_API_KEY || process.env.BYTEDANCE_SPEECH_API_KEY || process.env.VOICE_CREATION_API_KEY || 'f112a70a-4754-4d73-8a14-a05f9b57bf65';
  53. const VOLC_TTS_RESOURCE_ID = process.env.VOLC_TTS_RESOURCE_ID || '';
  54. const VOLC_SPEECH_APP_KEY = process.env.VOLC_SPEECH_APP_KEY || process.env.VOLC_SPEECH_APP_ID || '';
  55. const VOLC_SPEECH_ACCESS_KEY = process.env.VOLC_SPEECH_ACCESS_KEY || '';
  56. app.use(cors());
  57. app.use(express.json({ limit: '50mb' }));
  58. // 静态文件:提供视频文件的访问
  59. const staticVideoOptions = {
  60. setHeaders: (res, filePath) => {
  61. if (filePath.match(/\.(mp4|mov|webm|mkv|avi)$/i)) {
  62. res.setHeader('Content-Type', 'video/mp4');
  63. res.setHeader('Accept-Ranges', 'bytes');
  64. }
  65. }
  66. };
  67. app.use('/api/video', express.static(DATA_VIDEO_DIR, staticVideoOptions));
  68. app.use('/api/video', express.static(LEGACY_VIDEO_DIR, staticVideoOptions));
  69. // ==================== 文件上传配置 ====================
  70. const uploadStorage = multer.diskStorage({
  71. destination: (req, file, cb) => {
  72. const uploadDir = DATA_VIDEO_DIR;
  73. if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
  74. cb(null, uploadDir);
  75. },
  76. filename: (req, file, cb) => {
  77. // 保留原始文件名,如有冲突则加时间戳
  78. const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
  79. const ext = path.extname(originalName);
  80. const baseName = path.basename(originalName, ext);
  81. const targetPath = path.join(DATA_VIDEO_DIR, originalName);
  82. if (fs.existsSync(targetPath)) {
  83. cb(null, `${baseName}-${Date.now()}${ext}`);
  84. } else {
  85. cb(null, originalName);
  86. }
  87. }
  88. });
  89. const assetUpload = multer({
  90. storage: multer.memoryStorage(),
  91. limits: { fileSize: 50 * 1024 * 1024 }
  92. });
  93. const upload = multer({
  94. storage: uploadStorage,
  95. limits: { fileSize: 500 * 1024 * 1024 }, // 500MB
  96. fileFilter: (req, file, cb) => {
  97. const allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm', 'video/x-matroska'];
  98. if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp4|mov|avi|webm|mkv)$/i)) {
  99. cb(null, true);
  100. } else {
  101. cb(new Error('仅支持视频文件(mp4, mov, avi, webm, mkv)'));
  102. }
  103. }
  104. });
  105. // ==================== 工具函数 ====================
  106. // 确保 data 目录存在
  107. if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
  108. if (!fs.existsSync(DATA_VIDEO_DIR)) fs.mkdirSync(DATA_VIDEO_DIR, { recursive: true });
  109. if (!fs.existsSync(DATA_REMIX_ASSET_DIR)) fs.mkdirSync(DATA_REMIX_ASSET_DIR, { recursive: true });
  110. function ensureManifestFile() {
  111. if (fs.existsSync(MANIFEST_PATH)) return;
  112. if (fs.existsSync(LEGACY_MANIFEST_PATH)) {
  113. fs.copyFileSync(LEGACY_MANIFEST_PATH, MANIFEST_PATH);
  114. return;
  115. }
  116. fs.writeFileSync(MANIFEST_PATH, '[]', 'utf-8');
  117. }
  118. function readManifest() {
  119. ensureManifestFile();
  120. const raw = fs.readFileSync(MANIFEST_PATH, 'utf-8');
  121. return JSON.parse(raw);
  122. }
  123. function writeManifest(data) {
  124. ensureManifestFile();
  125. fs.writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2), 'utf-8');
  126. }
  127. function resolveVideoPath(filename) {
  128. const runtimePath = path.join(DATA_VIDEO_DIR, filename);
  129. if (fs.existsSync(runtimePath)) return runtimePath;
  130. const legacyPath = path.join(LEGACY_VIDEO_DIR, filename);
  131. if (fs.existsSync(legacyPath)) return legacyPath;
  132. return runtimePath;
  133. }
  134. function removeFileIfExists(filePath) {
  135. try {
  136. if (filePath && fs.existsSync(filePath)) {
  137. fs.unlinkSync(filePath);
  138. }
  139. } catch (err) {
  140. // 忽略竞态条件 / 权限问题导致的删除失败,避免 ENOENT 等异常中断主流程
  141. if (err && err.code !== 'ENOENT') {
  142. console.warn(`⚠️ 删除文件失败 ${filePath}: ${err.message}`);
  143. }
  144. }
  145. }
  146. function removeDirectoryIfExists(dirPath) {
  147. if (fs.existsSync(dirPath)) {
  148. fs.rmSync(dirPath, { recursive: true, force: true });
  149. }
  150. }
  151. function toBase64Url(input) {
  152. return Buffer.from(input)
  153. .toString('base64')
  154. .replace(/\+/g, '-')
  155. .replace(/\//g, '_');
  156. }
  157. function buildQiniuUploadToken(key) {
  158. const deadline = Math.floor(Date.now() / 1000) + 3600;
  159. const putPolicy = {
  160. scope: `${QINIU_BUCKET}:${key}`,
  161. deadline
  162. };
  163. const encodedPutPolicy = toBase64Url(JSON.stringify(putPolicy));
  164. const sign = crypto
  165. .createHmac('sha1', QINIU_SECRET_KEY)
  166. .update(encodedPutPolicy)
  167. .digest('base64')
  168. .replace(/\+/g, '-')
  169. .replace(/\//g, '_');
  170. return `${QINIU_ACCESS_KEY}:${sign}:${encodedPutPolicy}`;
  171. }
  172. function buildDigitalHumanAssetKey(fileName, kind) {
  173. const rawName = Buffer.from(fileName || `asset-${Date.now()}`, 'latin1').toString('utf8');
  174. const ext = path.extname(rawName).toLowerCase();
  175. const safeExt = ext && /^[.a-z0-9]+$/i.test(ext) ? ext : '';
  176. const baseName = path.basename(rawName, ext).replace(/[^a-zA-Z0-9_-]/g, '_') || `asset-${Date.now()}`;
  177. const date = new Date();
  178. const yyyy = date.getFullYear();
  179. const mm = String(date.getMonth() + 1).padStart(2, '0');
  180. const dd = String(date.getDate()).padStart(2, '0');
  181. const timestamp = `${yyyy}${mm}${dd}-${Date.now()}`;
  182. return `${QINIU_CDN_PREFIX}/digital-human/${kind}/${yyyy}${mm}${dd}/${timestamp}-${baseName}${safeExt}`;
  183. }
  184. function createRequestId() {
  185. return typeof crypto.randomUUID === 'function'
  186. ? crypto.randomUUID()
  187. : `${Date.now()}-${crypto.randomBytes(8).toString('hex')}`;
  188. }
  189. function buildSpeechHeaders(contentType = 'application/json') {
  190. const headers = {
  191. 'Content-Type': contentType,
  192. 'X-Api-Request-Id': createRequestId()
  193. };
  194. if (VOLC_SPEECH_API_KEY) {
  195. headers['X-Api-Key'] = VOLC_SPEECH_API_KEY;
  196. return headers;
  197. }
  198. if (VOLC_SPEECH_APP_KEY && VOLC_SPEECH_ACCESS_KEY) {
  199. headers['X-Api-App-Key'] = VOLC_SPEECH_APP_KEY;
  200. headers['X-Api-Access-Key'] = VOLC_SPEECH_ACCESS_KEY;
  201. return headers;
  202. }
  203. throw new Error('未配置火山语音鉴权,请设置 VOLC_SPEECH_API_KEY 或 VOLC_SPEECH_APP_KEY + VOLC_SPEECH_ACCESS_KEY');
  204. }
  205. function normalizeOfficialSpeechResourceId(value) {
  206. const resourceId = String(value || '').trim();
  207. return /^seed-(tts|icl)-/i.test(resourceId) ? resourceId : '';
  208. }
  209. function inferOfficialSpeechResourceId(speaker, requestedResourceId = '') {
  210. const explicitResourceId = normalizeOfficialSpeechResourceId(requestedResourceId);
  211. if (explicitResourceId) {
  212. return explicitResourceId;
  213. }
  214. const configuredResourceId = normalizeOfficialSpeechResourceId(VOLC_TTS_RESOURCE_ID);
  215. if (configuredResourceId) {
  216. return configuredResourceId;
  217. }
  218. const normalizedSpeaker = String(speaker || '').trim();
  219. if (/^(S_|icl_|saturn_|dit_)/i.test(normalizedSpeaker)) {
  220. return 'seed-icl-2.0';
  221. }
  222. return 'seed-tts-2.0';
  223. }
  224. function clampNumber(value, fallback, min, max) {
  225. const numericValue = Number(value);
  226. if (!Number.isFinite(numericValue)) {
  227. return fallback;
  228. }
  229. return Math.min(max, Math.max(min, numericValue));
  230. }
  231. function clampOptionalNumber(value, min, max) {
  232. if (value === null || value === undefined || String(value).trim() === '') {
  233. return null;
  234. }
  235. const numericValue = Number(value);
  236. if (!Number.isFinite(numericValue)) {
  237. return null;
  238. }
  239. return Math.min(max, Math.max(min, numericValue));
  240. }
  241. function parseOptionalBoolean(value, fallback = false) {
  242. if (value === null || value === undefined || value === '') {
  243. return fallback;
  244. }
  245. if (typeof value === 'boolean') {
  246. return value;
  247. }
  248. if (typeof value === 'number') {
  249. return value !== 0;
  250. }
  251. const normalizedValue = String(value).trim().toLowerCase();
  252. if (['true', '1', 'yes', 'on'].includes(normalizedValue)) {
  253. return true;
  254. }
  255. if (['false', '0', 'no', 'off'].includes(normalizedValue)) {
  256. return false;
  257. }
  258. return fallback;
  259. }
  260. function normalizeVoiceSynthesisErrorMessage(message) {
  261. const rawMessage = String(message || '').trim();
  262. if (/resource ID is mismatched with speaker related resource/i.test(rawMessage)) {
  263. return '当前本地 X-Api-Key 与所选 speaker_id 不属于同一语音资源,无法直接使用 speaker_id 合成。请先通过音色复刻获取 timbreId 后再合成,或更换与该 speaker_id 匹配的 X-Api-Key。';
  264. }
  265. return rawMessage || '语音合成失败';
  266. }
  267. function mapVoiceDesignStatus(status) {
  268. switch (Number(status)) {
  269. case 0:
  270. return '未找到';
  271. case 1:
  272. return '训练中';
  273. case 2:
  274. return '可用';
  275. case 3:
  276. return '失败';
  277. case 4:
  278. return '已激活';
  279. default:
  280. return '未知';
  281. }
  282. }
  283. function mapVoiceCloneStatus(status) {
  284. switch (String(status ?? '')) {
  285. case '0':
  286. return '未占用';
  287. case '1':
  288. return '可用';
  289. case '2':
  290. return '训练中';
  291. case '3':
  292. return '失败';
  293. case '404':
  294. return '已删除';
  295. default:
  296. return '未知';
  297. }
  298. }
  299. function normalizeBearerToken(token) {
  300. const rawToken = String(token || '').trim();
  301. if (!rawToken) {
  302. return '';
  303. }
  304. return /^Bearer\s+/i.test(rawToken) ? rawToken : `Bearer ${rawToken}`;
  305. }
  306. function inferAudioFormat(file) {
  307. const mimeType = String(file?.mimetype || '').toLowerCase();
  308. if (mimeType.includes('mpeg') || mimeType.includes('mp3')) {
  309. return 'mp3';
  310. }
  311. if (mimeType.includes('wav')) {
  312. return 'wav';
  313. }
  314. if (mimeType.includes('m4a') || mimeType.includes('mp4')) {
  315. return 'm4a';
  316. }
  317. if (mimeType.includes('aac')) {
  318. return 'aac';
  319. }
  320. if (mimeType.includes('flac')) {
  321. return 'flac';
  322. }
  323. if (mimeType.includes('ogg') || mimeType.includes('opus')) {
  324. return 'ogg_opus';
  325. }
  326. if (mimeType.includes('pcm')) {
  327. return 'pcm';
  328. }
  329. const ext = path.extname(file?.originalname || '').toLowerCase();
  330. switch (ext) {
  331. case '.wav':
  332. return 'wav';
  333. case '.m4a':
  334. return 'm4a';
  335. case '.aac':
  336. return 'aac';
  337. case '.flac':
  338. return 'flac';
  339. case '.ogg':
  340. case '.opus':
  341. return 'ogg_opus';
  342. case '.pcm':
  343. return 'pcm';
  344. case '.mp3':
  345. default:
  346. return 'mp3';
  347. }
  348. }
  349. async function readFetchResponse(response) {
  350. const rawText = await response.text();
  351. let payload = null;
  352. try {
  353. payload = rawText ? JSON.parse(rawText) : null;
  354. } catch {
  355. payload = null;
  356. }
  357. return {
  358. rawText,
  359. payload
  360. };
  361. }
  362. function extractPrimaryVoiceModel(timbre) {
  363. if (!Array.isArray(timbre?.models) || timbre.models.length === 0) {
  364. return {};
  365. }
  366. return timbre.models[0] || {};
  367. }
  368. function readVoiceSpeakerIdOptions() {
  369. if (!fs.existsSync(VOICE_SPEAKER_ID_DOC_PATH)) {
  370. return [];
  371. }
  372. const rawText = fs.readFileSync(VOICE_SPEAKER_ID_DOC_PATH, 'utf-8');
  373. const seen = new Set();
  374. const options = [];
  375. for (const rawLine of rawText.split(/\r?\n/)) {
  376. const speakerId = rawLine.trim().match(/^S_[A-Za-z0-9]+$/)?.[0] || '';
  377. if (!speakerId || seen.has(speakerId)) {
  378. continue;
  379. }
  380. seen.add(speakerId);
  381. options.push(speakerId);
  382. }
  383. return options;
  384. }
  385. function upsertVoiceProfile(profile) {
  386. const profiles = readDataFile('voice-profiles');
  387. const existingIndex = profiles.findIndex((item) => (
  388. (!!profile.timbre_id && item.timbre_id === profile.timbre_id)
  389. || (!!profile.id && item.id === profile.id)
  390. ));
  391. if (existingIndex >= 0) {
  392. const existingProfile = profiles[existingIndex];
  393. const nextProfile = {
  394. ...existingProfile,
  395. ...profile,
  396. id: existingProfile.id || profile.id,
  397. created_at: existingProfile.created_at || profile.created_at,
  398. updated_at: profile.updated_at || new Date().toISOString()
  399. };
  400. profiles[existingIndex] = nextProfile;
  401. writeDataFile('voice-profiles', profiles);
  402. return nextProfile;
  403. }
  404. profiles.unshift(profile);
  405. writeDataFile('voice-profiles', profiles);
  406. return profile;
  407. }
  408. async function requestVolcTtsJson(endpoint, requestBody) {
  409. const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, {
  410. method: 'POST',
  411. headers: {
  412. 'Content-Type': 'application/json'
  413. },
  414. body: JSON.stringify(requestBody)
  415. });
  416. const { rawText, payload } = await readFetchResponse(response);
  417. if (!response.ok) {
  418. const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败');
  419. error.status = response.status;
  420. error.detail = payload || rawText || '';
  421. throw error;
  422. }
  423. return payload;
  424. }
  425. async function proxyVolcTtsStream(endpoint, requestBody, res) {
  426. const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, {
  427. method: 'POST',
  428. headers: {
  429. 'Content-Type': 'application/json'
  430. },
  431. body: JSON.stringify(requestBody)
  432. });
  433. if (!response.ok) {
  434. const { rawText, payload } = await readFetchResponse(response);
  435. const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 流式请求失败');
  436. error.status = response.status;
  437. error.detail = payload || rawText || '';
  438. throw error;
  439. }
  440. res.status(response.status);
  441. res.setHeader('Content-Type', response.headers.get('content-type') || 'application/x-ndjson; charset=utf-8');
  442. res.setHeader('Cache-Control', 'no-cache, no-transform');
  443. if (!response.body) {
  444. res.end();
  445. return;
  446. }
  447. await pipeline(Readable.fromWeb(response.body), res);
  448. }
  449. async function synthesizeVoiceAudio({ token, speaker, timbreId, text, ssml, xApiResourceId, model, isStream = false, audioParams = {}, additions = {} }) {
  450. const normalizedSpeaker = String(speaker || '').trim();
  451. const fallbackSpeaker = String(timbreId || '').trim();
  452. const resolvedSpeaker = normalizedSpeaker || fallbackSpeaker;
  453. const requestedResourceId = String(xApiResourceId || '').trim();
  454. const headerResourceId = inferOfficialSpeechResourceId(resolvedSpeaker, requestedResourceId);
  455. const resolvedModel = String(model || '').trim();
  456. const serializedAdditions = Object.keys(additions).length > 0 ? JSON.stringify(additions) : '';
  457. const requestBody = {
  458. req_params: {
  459. ...(ssml ? { ssml } : { text }),
  460. speaker: resolvedSpeaker,
  461. audio_params: {
  462. format: audioParams.format || 'mp3',
  463. sample_rate: audioParams.sampleRate ?? 24000,
  464. ...(audioParams.speechRate === null || audioParams.speechRate === undefined ? {} : { speech_rate: audioParams.speechRate }),
  465. ...(audioParams.loudnessRate === null || audioParams.loudnessRate === undefined ? {} : { loudness_rate: audioParams.loudnessRate }),
  466. ...(audioParams.emotion ? { emotion: audioParams.emotion } : {}),
  467. ...(audioParams.emotionScale === null || audioParams.emotionScale === undefined ? {} : { emotion_scale: audioParams.emotionScale }),
  468. ...(audioParams.enableSubtitle ? { enable_subtitle: true } : {})
  469. },
  470. ...(serializedAdditions ? { additions: serializedAdditions } : {}),
  471. ...(resolvedModel ? { model: resolvedModel } : {})
  472. }
  473. };
  474. const headers = buildSpeechHeaders();
  475. headers['X-Api-Resource-Id'] = headerResourceId;
  476. headers['Connection'] = 'keep-alive';
  477. const response = await fetch(`${VOLC_SPEECH_BASE_URL}/api/v3/tts/unidirectional`, {
  478. method: 'POST',
  479. headers,
  480. body: JSON.stringify(requestBody)
  481. });
  482. const { rawText, payload } = await readFetchResponse(response);
  483. if (!response.ok) {
  484. const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败'));
  485. error.status = response.status;
  486. error.detail = payload || rawText || '';
  487. throw error;
  488. }
  489. if (Number(payload?.code) !== 200 || !payload?.data?.audioUrl) {
  490. const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || '语音合成失败'));
  491. error.status = 502;
  492. error.detail = payload || '';
  493. throw error;
  494. }
  495. return {
  496. audioUrl: payload.data.audioUrl,
  497. workId: payload.data.workId || '',
  498. payload
  499. };
  500. }
  501. function createHttpError(message, status = 500, detail = '') {
  502. const error = new Error(message || '请求失败');
  503. error.status = status;
  504. error.detail = detail;
  505. return error;
  506. }
  507. function inferAudioFormatFromUrl(url) {
  508. try {
  509. const parsed = new URL(String(url || '').trim());
  510. return inferAudioFormat({ originalname: parsed.pathname || '', mimetype: '' });
  511. } catch {
  512. return inferAudioFormat({ originalname: String(url || '').trim(), mimetype: '' });
  513. }
  514. }
  515. function normalizeVoiceClonePayload(body) {
  516. const audioData = body?.audioData && typeof body.audioData === 'object' ? body.audioData : {};
  517. const extraParams = body?.extra_params && typeof body.extra_params === 'object' ? body.extra_params : {};
  518. return {
  519. token: normalizeBearerToken(body?.token),
  520. name: String(body?.name || body?.displayName || '').trim(),
  521. timbreId: String(body?.timbreId || '').trim(),
  522. speakerId: String(body?.speaker_id || body?.speakerId || '').trim(),
  523. audioData: {
  524. url: String(audioData?.url || '').trim(),
  525. base64: String(audioData?.base64 || '').trim(),
  526. format: String(audioData?.format || '').trim().toLowerCase(),
  527. text: String(audioData?.text || body?.audioText || '').trim()
  528. },
  529. language: Number(body?.language ?? 0) === 1 ? 1 : 0,
  530. demoText: String(extraParams?.demo_text || body?.sampleText || '').trim(),
  531. sourceAudioName: String(body?.sourceAudioName || body?.source_audio_name || '').trim(),
  532. sourceAudioFormat: String(body?.sourceAudioFormat || body?.source_audio_format || '').trim().toLowerCase()
  533. };
  534. }
  535. function normalizeVoiceSynthesisPayload(body) {
  536. const rawAudioParams = body?.audio_params && typeof body.audio_params === 'object'
  537. ? body.audio_params
  538. : (body?.audioParams && typeof body.audioParams === 'object' ? body.audioParams : {});
  539. const rawAdditions = body?.additions && typeof body.additions === 'object' ? body.additions : {};
  540. const formatCandidate = String(rawAudioParams?.format || '').trim().toLowerCase();
  541. const format = ['mp3', 'ogg_opus', 'pcm'].includes(formatCandidate) ? formatCandidate : 'mp3';
  542. const sampleRateCandidate = Number(rawAudioParams?.sampleRate ?? rawAudioParams?.sample_rate);
  543. const sampleRate = [8000, 16000, 22050, 24000, 32000, 44100, 48000].includes(sampleRateCandidate) ? sampleRateCandidate : 24000;
  544. const speechRate = clampNumber(rawAudioParams?.speechRate ?? rawAudioParams?.speech_rate, 0, -50, 100);
  545. const loudnessRate = clampNumber(rawAudioParams?.loudnessRate ?? rawAudioParams?.loudness_rate, 0, -50, 100);
  546. const emotion = String(rawAudioParams?.emotion || '').trim();
  547. const emotionScale = clampOptionalNumber(rawAudioParams?.emotionScale ?? rawAudioParams?.emotion_scale, 1, 5);
  548. const enableSubtitle = parseOptionalBoolean(rawAudioParams?.enableSubtitle ?? rawAudioParams?.enable_subtitle, false);
  549. const silenceDuration = clampOptionalNumber(rawAdditions?.silenceDuration ?? rawAdditions?.silence_duration, 0, 30000);
  550. const enableLanguageDetector = parseOptionalBoolean(rawAdditions?.enableLanguageDetector ?? rawAdditions?.enable_language_detector, false);
  551. const disableMarkdownFilter = parseOptionalBoolean(rawAdditions?.disableMarkdownFilter ?? rawAdditions?.disable_markdown_filter, false);
  552. const disableEmojiFilter = parseOptionalBoolean(rawAdditions?.disableEmojiFilter ?? rawAdditions?.disable_emoji_filter, false);
  553. const explicitLanguage = String((rawAdditions?.explicitLanguage ?? rawAdditions?.explicit_language) || '').trim();
  554. return {
  555. token: normalizeBearerToken(body?.token),
  556. text: String(body?.text || '').trim(),
  557. ssml: String(body?.ssml || '').trim(),
  558. timbreId: String(body?.timbreId || body?.timbre_id || '').trim(),
  559. speakerId: String(body?.speaker_id || body?.speakerId || body?.speaker || '').trim(),
  560. isStream: parseOptionalBoolean(body?.isStream ?? body?.is_stream, true),
  561. xApiResourceId: String(body?.x_api_resource_id || body?.xApiResourceId || '').trim(),
  562. model: String(body?.model || '').trim(),
  563. audioParams: {
  564. format,
  565. sampleRate,
  566. speechRate,
  567. loudnessRate,
  568. emotion,
  569. emotionScale,
  570. enableSubtitle
  571. },
  572. additions: {
  573. ...(silenceDuration === null ? {} : { silence_duration: silenceDuration }),
  574. ...(enableLanguageDetector ? { enable_language_detector: true } : {}),
  575. ...(disableMarkdownFilter ? { disable_markdown_filter: true } : {}),
  576. ...(disableEmojiFilter ? { disable_emoji_filter: true } : {}),
  577. ...(explicitLanguage ? { explicit_language: explicitLanguage } : {})
  578. }
  579. };
  580. }
  581. function buildProxySynthesisPayload(payload) {
  582. return {
  583. token: payload.token,
  584. ...(payload.text ? { text: payload.text } : {}),
  585. ...(payload.ssml ? { ssml: payload.ssml } : {}),
  586. ...(payload.timbreId ? { timbreId: payload.timbreId } : {}),
  587. ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}),
  588. isStream: !!payload.isStream,
  589. ...(payload.xApiResourceId ? { x_api_resource_id: payload.xApiResourceId } : {}),
  590. ...(payload.model ? { model: payload.model } : {}),
  591. audio_params: {
  592. format: payload.audioParams.format,
  593. sample_rate: payload.audioParams.sampleRate,
  594. speech_rate: payload.audioParams.speechRate,
  595. loudness_rate: payload.audioParams.loudnessRate,
  596. ...(payload.audioParams.emotion ? { emotion: payload.audioParams.emotion } : {}),
  597. ...(payload.audioParams.emotionScale === null || payload.audioParams.emotionScale === undefined ? {} : { emotion_scale: payload.audioParams.emotionScale }),
  598. ...(payload.audioParams.enableSubtitle ? { enable_subtitle: true } : {})
  599. },
  600. ...(Object.keys(payload.additions).length > 0 ? { additions: payload.additions } : {})
  601. };
  602. }
  603. async function executeVoiceCloneRequest(payload) {
  604. if (!payload.token) {
  605. throw createHttpError('缺少 token 参数', 400);
  606. }
  607. if (!payload.name) {
  608. throw createHttpError('缺少音色名称 name', 400);
  609. }
  610. if (!payload.timbreId && !payload.speakerId) {
  611. throw createHttpError('timbreId与speaker_id不能同时为空', 400);
  612. }
  613. if (!payload.audioData.url && !payload.audioData.base64) {
  614. throw createHttpError('音频url或base64至少提供一个', 400);
  615. }
  616. if (payload.demoText && (payload.demoText.length < 4 || payload.demoText.length > 80)) {
  617. throw createHttpError('试听文本长度需在 4-80 字之间', 400);
  618. }
  619. const clonePayload = await requestVolcTtsJson('voice_clone', {
  620. token: payload.token,
  621. name: payload.name,
  622. ...(payload.timbreId ? { timbreId: payload.timbreId } : {}),
  623. ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}),
  624. audioData: {
  625. ...(payload.audioData.url ? { url: payload.audioData.url } : {}),
  626. ...(payload.audioData.base64 ? { base64: payload.audioData.base64 } : {}),
  627. ...(payload.audioData.format ? { format: payload.audioData.format } : {}),
  628. ...(payload.audioData.text ? { text: payload.audioData.text } : {})
  629. },
  630. language: payload.language,
  631. ...(payload.demoText ? { extra_params: { demo_text: payload.demoText } } : {})
  632. });
  633. if (Number(clonePayload?.code) !== 200 || !clonePayload?.data?.timbre?.objectId) {
  634. throw createHttpError(clonePayload?.error?.message || clonePayload?.message || '音色复刻失败', 502, clonePayload || '');
  635. }
  636. const timbre = clonePayload.data.timbre;
  637. const primaryModel = extractPrimaryVoiceModel(timbre);
  638. const now = new Date().toISOString();
  639. const profile = upsertVoiceProfile({
  640. id: `VOICE-${Date.now()}`,
  641. name: payload.name,
  642. creation_mode: 'clone',
  643. speaker_id: timbre?.speaker_id || payload.speakerId,
  644. timbre_id: timbre?.objectId || payload.timbreId || '',
  645. sample_text: payload.demoText,
  646. source_audio_text: payload.audioData.text,
  647. source_audio_name: payload.sourceAudioName,
  648. source_audio_format: payload.sourceAudioFormat || payload.audioData.format || inferAudioFormatFromUrl(payload.audioData.url),
  649. text_prompt: '',
  650. language: payload.language,
  651. status: Number.isFinite(Number(timbre?.status)) ? Number(timbre.status) : null,
  652. status_label: mapVoiceCloneStatus(timbre?.status),
  653. demo_audio: primaryModel?.demo_audio || '',
  654. available_training_times: null,
  655. image_prompt_name: '',
  656. x_api_resource_id: Array.isArray(primaryModel?.x_api_resource_id) ? String(primaryModel.x_api_resource_id[0] || '') : '',
  657. model_version: String(primaryModel?.version || ''),
  658. icl_speaker_id: String(primaryModel?.icl_speaker_id || ''),
  659. occupied: !!timbre?.occupied,
  660. synthesized_audio_url: '',
  661. synthesized_work_id: '',
  662. last_synthesis_text: '',
  663. latest_audio_url: primaryModel?.demo_audio || '',
  664. message: clonePayload?.data?.tip || '音色复刻成功',
  665. request_id: clonePayload?.request_id || '',
  666. created_at: now,
  667. updated_at: now
  668. });
  669. return {
  670. clonePayload,
  671. profile
  672. };
  673. }
  674. function findVoiceProfileForSynthesis(payload) {
  675. const profiles = readDataFile('voice-profiles');
  676. if (payload.timbreId) {
  677. const byTimbreId = profiles.find((item) => item.timbre_id === payload.timbreId);
  678. if (byTimbreId) {
  679. return byTimbreId;
  680. }
  681. }
  682. if (payload.speakerId) {
  683. return profiles.find((item) => (
  684. String(item.icl_speaker_id || '').trim() === payload.speakerId
  685. || String(item.speaker_id || '').trim() === payload.speakerId
  686. )) || null;
  687. }
  688. return null;
  689. }
  690. function persistSynthesisProfile(profile, payload, synthesis) {
  691. if (!profile) {
  692. return null;
  693. }
  694. return upsertVoiceProfile({
  695. ...profile,
  696. synthesized_audio_url: synthesis.audioUrl,
  697. synthesized_work_id: synthesis.workId,
  698. last_synthesis_text: payload.text,
  699. last_synthesis_ssml: payload.ssml,
  700. last_synthesis_x_api_resource_id: payload.xApiResourceId || profile.x_api_resource_id || '',
  701. last_synthesis_model: payload.model,
  702. last_synthesis_format: payload.audioParams.format,
  703. last_synthesis_sample_rate: payload.audioParams.sampleRate,
  704. last_synthesis_speech_rate: payload.audioParams.speechRate,
  705. last_synthesis_loudness_rate: payload.audioParams.loudnessRate,
  706. last_synthesis_emotion: payload.audioParams.emotion,
  707. last_synthesis_emotion_scale: payload.audioParams.emotionScale,
  708. last_synthesis_enable_subtitle: !!payload.audioParams.enableSubtitle,
  709. last_synthesis_silence_duration: payload.additions?.silence_duration ?? null,
  710. last_synthesis_enable_language_detector: !!payload.additions?.enable_language_detector,
  711. last_synthesis_disable_markdown_filter: !!payload.additions?.disable_markdown_filter,
  712. last_synthesis_disable_emoji_filter: !!payload.additions?.disable_emoji_filter,
  713. last_synthesis_explicit_language: String(payload.additions?.explicit_language || '').trim(),
  714. latest_audio_url: synthesis.audioUrl || profile.latest_audio_url || profile.demo_audio || '',
  715. updated_at: new Date().toISOString()
  716. });
  717. }
  718. async function executeVoiceSynthesisRequest(payload) {
  719. if (!payload.token) {
  720. throw createHttpError('缺少 token 参数', 400);
  721. }
  722. if (!payload.text && !payload.ssml) {
  723. throw createHttpError('文本内容不能为空,text与ssml不能同时为空', 400);
  724. }
  725. const matchedProfile = findVoiceProfileForSynthesis(payload);
  726. if (payload.timbreId) {
  727. const proxyPayload = await requestVolcTtsJson('unidirectional', buildProxySynthesisPayload({
  728. ...payload,
  729. isStream: false
  730. }));
  731. if (Number(proxyPayload?.code) !== 200 || !proxyPayload?.data?.audioUrl) {
  732. throw createHttpError(proxyPayload?.error?.message || proxyPayload?.message || '语音合成失败', 502, proxyPayload || '');
  733. }
  734. const profile = persistSynthesisProfile(matchedProfile, payload, {
  735. audioUrl: proxyPayload.data.audioUrl,
  736. workId: proxyPayload.data.workId || ''
  737. });
  738. return {
  739. response: {
  740. ...proxyPayload,
  741. ...(profile ? { profile } : {})
  742. },
  743. profile
  744. };
  745. }
  746. if (!payload.speakerId) {
  747. throw createHttpError('音色id不能为空', 400);
  748. }
  749. const synthesis = await synthesizeVoiceAudio({
  750. token: payload.token,
  751. speaker: payload.speakerId,
  752. timbreId: matchedProfile?.timbre_id || '',
  753. text: payload.text,
  754. ssml: payload.ssml,
  755. xApiResourceId: payload.xApiResourceId || matchedProfile?.x_api_resource_id || '',
  756. model: payload.model,
  757. isStream: false,
  758. audioParams: payload.audioParams,
  759. additions: payload.additions
  760. });
  761. const profile = persistSynthesisProfile(matchedProfile, payload, synthesis);
  762. return {
  763. response: {
  764. code: 200,
  765. data: {
  766. workId: synthesis.workId,
  767. audioUrl: synthesis.audioUrl
  768. },
  769. ...(profile ? { profile } : {})
  770. },
  771. profile
  772. };
  773. }
  774. // 通用 JSON 数据文件读写
  775. function readDataFile(name) {
  776. const p = path.join(DATA_DIR, `${name}.json`);
  777. if (!fs.existsSync(p)) { fs.writeFileSync(p, '[]', 'utf-8'); return []; }
  778. return JSON.parse(fs.readFileSync(p, 'utf-8'));
  779. }
  780. function writeDataFile(name, data) {
  781. fs.writeFileSync(path.join(DATA_DIR, `${name}.json`), JSON.stringify(data, null, 2), 'utf-8');
  782. }
  783. function isSafeRemoteUrl(url) {
  784. try {
  785. const parsed = new URL(url);
  786. return ['http:', 'https:'].includes(parsed.protocol);
  787. } catch {
  788. return false;
  789. }
  790. }
  791. function sanitizeFilename(filename) {
  792. const safeName = String(filename || '')
  793. .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
  794. .trim();
  795. return safeName || `video-${Date.now()}.mp4`;
  796. }
  797. function ensureVideoFilename(filename, sourceUrl = '') {
  798. const safeName = sanitizeFilename(filename);
  799. if (path.extname(safeName)) {
  800. return safeName;
  801. }
  802. try {
  803. const parsed = new URL(sourceUrl);
  804. const sourceExt = path.extname(parsed.pathname || '').toLowerCase();
  805. if (sourceExt) {
  806. return `${safeName}${sourceExt}`;
  807. }
  808. } catch {}
  809. return `${safeName}.mp4`;
  810. }
  811. function ensureUniqueVideoFilename(filename) {
  812. const ext = path.extname(filename) || '.mp4';
  813. const baseName = path.basename(filename, ext);
  814. let candidate = filename;
  815. let counter = 1;
  816. while (fs.existsSync(path.join(DATA_VIDEO_DIR, candidate)) || fs.existsSync(path.join(LEGACY_VIDEO_DIR, candidate))) {
  817. candidate = `${baseName}-${Date.now()}-${counter}${ext}`;
  818. counter += 1;
  819. }
  820. return candidate;
  821. }
  822. function createManagedVideoEntry({ filename, title, description, tags, thumbnail, duration, resolution, awemeId, authorName, size }) {
  823. const ext = path.extname(filename).replace('.', '').toLowerCase() || 'mp4';
  824. const now = new Date().toISOString();
  825. return {
  826. id: `VID-${Date.now()}`,
  827. title: title || path.basename(filename, path.extname(filename)),
  828. filename,
  829. size: size || 0,
  830. duration: Number(duration) || 0,
  831. created_at: now,
  832. modified_at: now,
  833. category: 'downloaded',
  834. tags: Array.isArray(tags) ? tags : [],
  835. description: description || '',
  836. thumbnail: thumbnail || '',
  837. source: 'downloaded',
  838. aweme_id: awemeId || '',
  839. metadata: {
  840. resolution: resolution || '未知',
  841. format: ext,
  842. authorName: authorName || ''
  843. }
  844. };
  845. }
  846. function normalizeRemoteUrls(primaryUrl, urls = []) {
  847. return [primaryUrl, ...(Array.isArray(urls) ? urls : [])].filter((url, index, list) => (
  848. typeof url === 'string'
  849. && isSafeRemoteUrl(url)
  850. && list.indexOf(url) === index
  851. ));
  852. }
  853. async function fetchRemoteVideoResponse(urls, requestHeaders = {}) {
  854. let lastError = null;
  855. for (const currentUrl of urls) {
  856. try {
  857. const response = await fetch(currentUrl, {
  858. method: 'GET',
  859. headers: requestHeaders,
  860. redirect: 'follow'
  861. });
  862. if (!response.ok) {
  863. const detail = await response.text().catch(() => '');
  864. lastError = new Error(`远程下载失败: ${response.status} ${response.statusText}${detail ? ` ${detail.slice(0, 200)}` : ''}`);
  865. continue;
  866. }
  867. if (!response.body) {
  868. lastError = new Error('远程响应缺少视频流');
  869. continue;
  870. }
  871. return { url: currentUrl, response };
  872. } catch (error) {
  873. lastError = error;
  874. }
  875. }
  876. throw lastError || new Error('没有可用的远程视频地址');
  877. }
  878. // ==================== Whisper 转录 ====================
  879. // POST /api/whisper/transcribe
  880. // body: { videoId, language?, model? }
  881. app.post('/api/whisper/transcribe', async (req, res) => {
  882. const { videoId, language = 'Chinese', model = 'base' } = req.body;
  883. if (!videoId) {
  884. return res.status(400).json({ error: '缺少 videoId 参数' });
  885. }
  886. // 从 manifest 查找视频
  887. const manifest = readManifest();
  888. const video = manifest.find(v => v.id === videoId);
  889. if (!video) {
  890. return res.status(404).json({ error: `未找到视频: ${videoId}` });
  891. }
  892. const videoPath = resolveVideoPath(video.filename);
  893. if (!fs.existsSync(videoPath)) {
  894. return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
  895. }
  896. // 为该视频创建专属输出目录
  897. const baseName = video.filename.replace(/\.[^.]+$/, '');
  898. const outputDir = path.join(WHISPER_DIR, baseName);
  899. if (!fs.existsSync(outputDir)) {
  900. fs.mkdirSync(outputDir, { recursive: true });
  901. }
  902. console.log(`🎙️ 开始 Whisper 转录: ${video.filename}`);
  903. console.log(` 模型: ${model}, 语言: ${language}`);
  904. console.log(` 输出目录: ${outputDir}`);
  905. // 执行 Whisper 命令
  906. const cmd = `whisper "${videoPath}" --model ${model} --language ${language} --output_dir "${outputDir}"`;
  907. try {
  908. const result = await new Promise((resolve, reject) => {
  909. const process = exec(cmd, {
  910. cwd: PROJECT_ROOT,
  911. timeout: 10 * 60 * 1000, // 10分钟超时
  912. maxBuffer: 10 * 1024 * 1024
  913. });
  914. let stdout = '';
  915. let stderr = '';
  916. process.stdout.on('data', (data) => {
  917. stdout += data;
  918. console.log(` [whisper] ${data.toString().trim()}`);
  919. });
  920. process.stderr.on('data', (data) => {
  921. stderr += data;
  922. });
  923. process.on('close', (code) => {
  924. if (code === 0) {
  925. resolve({ stdout, stderr });
  926. } else {
  927. reject(new Error(`Whisper 退出码: ${code}\n${stderr}`));
  928. }
  929. });
  930. process.on('error', (err) => {
  931. reject(new Error(`无法启动 Whisper: ${err.message}`));
  932. });
  933. });
  934. // 读取生成的文件
  935. const txtFile = path.join(outputDir, `${baseName}.txt`);
  936. const srtFile = path.join(outputDir, `${baseName}.srt`);
  937. const transcript = fs.existsSync(txtFile) ? fs.readFileSync(txtFile, 'utf-8') : '';
  938. const srt = fs.existsSync(srtFile) ? fs.readFileSync(srtFile, 'utf-8') : '';
  939. if (!transcript) {
  940. return res.status(500).json({ error: 'Whisper 执行完成但未生成文字稿' });
  941. }
  942. // 更新 manifest 中该视频的 whisper 字段
  943. const whisperPaths = {
  944. transcript: `Whisper/${baseName}/${baseName}.txt`,
  945. srt: `Whisper/${baseName}/${baseName}.srt`
  946. };
  947. // 检查是否存在其他输出文件
  948. const jsonFile = path.join(outputDir, `${baseName}.json`);
  949. const vttFile = path.join(outputDir, `${baseName}.vtt`);
  950. const tsvFile = path.join(outputDir, `${baseName}.tsv`);
  951. if (fs.existsSync(jsonFile)) whisperPaths.segments = `Whisper/${baseName}/${baseName}.json`;
  952. video.whisper = whisperPaths;
  953. writeManifest(manifest);
  954. console.log(`✅ Whisper 转录完成: ${baseName}`);
  955. res.json({
  956. success: true,
  957. videoId: video.id,
  958. transcript,
  959. srt,
  960. whisper: whisperPaths,
  961. outputDir: `Whisper/${baseName}`
  962. });
  963. } catch (err) {
  964. console.error(`❌ Whisper 转录失败:`, err.message);
  965. res.status(500).json({
  966. error: `Whisper 转录失败: ${err.message}`,
  967. hint: '请确保已安装 Whisper: pip install openai-whisper'
  968. });
  969. }
  970. });
  971. // GET /api/whisper/status — 检查 Whisper 是否可用
  972. app.get('/api/whisper/status', (req, res) => {
  973. exec('whisper --help', { timeout: 5000 }, (err) => {
  974. if (err) {
  975. res.json({ available: false, message: '未检测到 Whisper,请执行: pip install openai-whisper' });
  976. } else {
  977. res.json({ available: true, message: 'Whisper 已安装' });
  978. }
  979. });
  980. });
  981. // ==================== Manifest 管理 ====================
  982. // GET /api/manifest — 获取完整 manifest
  983. app.get('/api/manifest', (req, res) => {
  984. try {
  985. const manifest = readManifest();
  986. res.json(manifest);
  987. } catch (err) {
  988. res.status(500).json({ error: `读取 manifest 失败: ${err.message}` });
  989. }
  990. });
  991. // PUT /api/manifest/:videoId — 更新指定视频条目
  992. app.put('/api/manifest/:videoId', (req, res) => {
  993. try {
  994. const manifest = readManifest();
  995. const idx = manifest.findIndex(v => v.id === req.params.videoId);
  996. if (idx === -1) {
  997. return res.status(404).json({ error: `未找到视频: ${req.params.videoId}` });
  998. }
  999. // 合并更新字段
  1000. manifest[idx] = { ...manifest[idx], ...req.body };
  1001. writeManifest(manifest);
  1002. res.json({ success: true, video: manifest[idx] });
  1003. } catch (err) {
  1004. res.status(500).json({ error: `更新 manifest 失败: ${err.message}` });
  1005. }
  1006. });
  1007. // POST /api/manifest — 添加新视频条目
  1008. app.post('/api/manifest', (req, res) => {
  1009. try {
  1010. const manifest = readManifest();
  1011. const newEntry = req.body;
  1012. if (!newEntry.id || !newEntry.filename) {
  1013. return res.status(400).json({ error: '缺少 id 或 filename' });
  1014. }
  1015. // 检查重复
  1016. if (manifest.find(v => v.id === newEntry.id)) {
  1017. return res.status(409).json({ error: `视频 ${newEntry.id} 已存在` });
  1018. }
  1019. manifest.push(newEntry);
  1020. writeManifest(manifest);
  1021. res.json({ success: true, video: newEntry });
  1022. } catch (err) {
  1023. res.status(500).json({ error: `添加视频失败: ${err.message}` });
  1024. }
  1025. });
  1026. // DELETE /api/manifest/:videoId — 删除指定视频条目及关联文件
  1027. app.delete('/api/manifest/:videoId', (req, res) => {
  1028. try {
  1029. const manifest = readManifest();
  1030. const idx = manifest.findIndex(v => v.id === req.params.videoId);
  1031. if (idx === -1) {
  1032. return res.status(404).json({ error: `未找到视频: ${req.params.videoId}` });
  1033. }
  1034. const [video] = manifest.splice(idx, 1);
  1035. const filename = typeof video.filename === 'string' ? video.filename : '';
  1036. // 先清理关联文件,所有步骤都做容错;最后再持久化 manifest,避免中途异常导致状态不一致
  1037. if (filename) {
  1038. try {
  1039. const videoPath = resolveVideoPath(filename);
  1040. removeFileIfExists(videoPath);
  1041. } catch (cleanupErr) {
  1042. console.warn(`⚠️ 删除视频文件失败 (${filename}):`, cleanupErr.message);
  1043. }
  1044. } else {
  1045. console.warn(`⚠️ 视频条目 ${video.id} 缺少 filename 字段,跳过文件清理`);
  1046. }
  1047. try {
  1048. const remixFilePath = path.join(REMIXES_DIR, `${video.id}.json`);
  1049. removeFileIfExists(remixFilePath);
  1050. } catch (cleanupErr) {
  1051. console.warn(`⚠️ 删除 remix 文件失败 (${video.id}):`, cleanupErr.message);
  1052. }
  1053. if (filename) {
  1054. try {
  1055. const baseName = filename.replace(/\.[^.]+$/, '');
  1056. const whisperOutputDir = path.join(WHISPER_DIR, baseName);
  1057. removeDirectoryIfExists(whisperOutputDir);
  1058. } catch (cleanupErr) {
  1059. console.warn(`⚠️ 删除 Whisper 输出目录失败 (${filename}):`, cleanupErr.message);
  1060. }
  1061. }
  1062. writeManifest(manifest);
  1063. console.log(`🗑️ 视频已删除: ${video.title || filename || video.id}`);
  1064. res.json({ success: true, videoId: video.id, filename });
  1065. } catch (err) {
  1066. console.error('❌ DELETE /api/manifest 失败:', err);
  1067. res.status(500).json({ error: `删除视频失败: ${err.message}` });
  1068. }
  1069. });
  1070. // ==================== 视频上传 ====================
  1071. // POST /api/upload/video — 上传视频文件
  1072. app.post('/api/upload/video', upload.single('video'), (req, res) => {
  1073. try {
  1074. if (!req.file) {
  1075. return res.status(400).json({ error: '未收到视频文件' });
  1076. }
  1077. const file = req.file;
  1078. const filename = file.filename;
  1079. const ext = path.extname(filename).replace('.', '').toLowerCase();
  1080. // 生成视频 ID
  1081. const videoId = `VID-${Date.now()}`;
  1082. // 获取文件大小
  1083. const fileStat = fs.statSync(file.path);
  1084. // 创建 manifest 条目
  1085. const videoEntry = {
  1086. id: videoId,
  1087. title: req.body.title || path.basename(filename, path.extname(filename)),
  1088. filename: filename,
  1089. size: fileStat.size,
  1090. duration: 0, // 前端可以通过 video 元素获取
  1091. category: 'uploaded',
  1092. tags: req.body.tags ? JSON.parse(req.body.tags) : [],
  1093. description: req.body.description || '用户上传的视频',
  1094. source: 'uploaded',
  1095. metadata: {
  1096. resolution: '未知',
  1097. format: ext || 'mp4'
  1098. }
  1099. };
  1100. // 添加到 manifest
  1101. const manifest = readManifest();
  1102. manifest.push(videoEntry);
  1103. writeManifest(manifest);
  1104. console.log(`📤 视频上传成功: ${filename} (${(fileStat.size / 1024 / 1024).toFixed(1)}MB) → ${videoId}`);
  1105. res.json({
  1106. success: true,
  1107. video: videoEntry,
  1108. filepath: `/backend/video/${filename}`
  1109. });
  1110. } catch (err) {
  1111. console.error('❌ 视频上传失败:', err.message);
  1112. res.status(500).json({ error: `上传失败: ${err.message}` });
  1113. }
  1114. });
  1115. // 上传错误处理
  1116. app.use((err, req, res, next) => {
  1117. if (err instanceof multer.MulterError) {
  1118. if (err.code === 'LIMIT_FILE_SIZE') {
  1119. return res.status(413).json({ error: '文件大小超过限制(最大 500MB)' });
  1120. }
  1121. return res.status(400).json({ error: `上传错误: ${err.message}` });
  1122. }
  1123. if (err) {
  1124. return res.status(400).json({ error: err.message });
  1125. }
  1126. next();
  1127. });
  1128. app.post('/api/remix/extract-audio', (req, res) => {
  1129. const { videoId } = req.body || {};
  1130. if (!videoId) {
  1131. return res.status(400).json({ error: '缺少 videoId 参数' });
  1132. }
  1133. const manifest = readManifest();
  1134. const video = manifest.find(v => v.id === videoId);
  1135. if (!video) {
  1136. return res.status(404).json({ error: `未找到视频: ${videoId}` });
  1137. }
  1138. const videoPath = resolveVideoPath(video.filename);
  1139. if (!fs.existsSync(videoPath)) {
  1140. return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
  1141. }
  1142. const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`;
  1143. const outputFilename = `${safeBaseName}-${Date.now()}.wav`;
  1144. const outputPath = path.join(DATA_REMIX_ASSET_DIR, outputFilename);
  1145. const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', outputPath];
  1146. const process = spawn('ffmpeg', ffmpegArgs, { cwd: PROJECT_ROOT });
  1147. let stderr = '';
  1148. process.stderr.on('data', (data) => {
  1149. stderr += data.toString();
  1150. });
  1151. process.on('error', (err) => {
  1152. removeFileIfExists(outputPath);
  1153. const message = /ENOENT/i.test(err.message)
  1154. ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
  1155. : `无法启动 ffmpeg:${err.message}`;
  1156. res.status(500).json({ error: message });
  1157. });
  1158. process.on('close', (code) => {
  1159. if (code !== 0 || !fs.existsSync(outputPath)) {
  1160. removeFileIfExists(outputPath);
  1161. const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
  1162. const normalizedMessage = /ffmpeg/i.test(stderr) && /not recognized|not found|no such file/i.test(stderr)
  1163. ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
  1164. : `音频提取失败${lastErrorLine ? `: ${lastErrorLine}` : ''}`;
  1165. return res.status(500).json({ error: normalizedMessage });
  1166. }
  1167. res.setHeader('Content-Type', 'audio/wav');
  1168. res.setHeader('Content-Disposition', `attachment; filename="${outputFilename}"`);
  1169. const stream = fs.createReadStream(outputPath);
  1170. stream.on('close', () => {
  1171. removeFileIfExists(outputPath);
  1172. });
  1173. stream.on('error', () => {
  1174. removeFileIfExists(outputPath);
  1175. if (!res.headersSent) {
  1176. res.status(500).json({ error: '音频文件读取失败' });
  1177. } else {
  1178. res.end();
  1179. }
  1180. });
  1181. stream.pipe(res);
  1182. });
  1183. });
  1184. // POST /api/extract-audio-mp3 — 提取视频音轨为 MP3(轻量,适合发给 Gemini 音频识别)
  1185. app.post('/api/extract-audio-mp3', (req, res) => {
  1186. const { videoId } = req.body || {};
  1187. if (!videoId) {
  1188. return res.status(400).json({ error: '缺少 videoId 参数' });
  1189. }
  1190. const manifest = readManifest();
  1191. const video = manifest.find(v => v.id === videoId);
  1192. if (!video) {
  1193. console.warn(`⚠️ extract-audio-mp3: 未找到 videoId=${videoId}`);
  1194. return res.status(404).json({ error: `未找到视频: ${videoId}` });
  1195. }
  1196. const videoPath = resolveVideoPath(video.filename);
  1197. if (!fs.existsSync(videoPath)) {
  1198. console.warn(`⚠️ extract-audio-mp3: 视频文件不存在: ${videoPath}`);
  1199. return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
  1200. }
  1201. const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`;
  1202. // 使用 AAC(ffmpeg 内置,无需 libmp3lame),封装为 m4a;Gemini 支持 audio/mp4
  1203. const outputFilename = `${safeBaseName}-${Date.now()}.m4a`;
  1204. const outputPath = path.join(DATA_REMIX_ASSET_DIR, outputFilename);
  1205. const outputMimeType = 'audio/mp4';
  1206. console.log(`🎵 开始音频提取: ${videoPath} → ${outputPath}`);
  1207. // 64kbps 单声道 16kHz:体积小且足够语音识别
  1208. const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath];
  1209. const proc = spawn('ffmpeg', ffmpegArgs, { cwd: PROJECT_ROOT });
  1210. let stderr = '';
  1211. let responded = false;
  1212. const safeRespond = (status, body) => {
  1213. if (responded) return;
  1214. responded = true;
  1215. res.status(status).json(body);
  1216. };
  1217. proc.stderr.on('data', (data) => { stderr += data.toString(); });
  1218. proc.on('error', (err) => {
  1219. removeFileIfExists(outputPath);
  1220. const message = /ENOENT/i.test(err.message)
  1221. ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
  1222. : `无法启动 ffmpeg:${err.message}`;
  1223. console.error(`❌ extract-audio-mp3 spawn error: ${err.message}`);
  1224. safeRespond(500, { error: message });
  1225. });
  1226. proc.on('close', (code) => {
  1227. if (code !== 0 || !fs.existsSync(outputPath)) {
  1228. const tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');
  1229. console.error(`❌ ffmpeg exit code=${code} 输出文件存在=${fs.existsSync(outputPath)}`);
  1230. console.error(` stderr 末尾: ${tail}`);
  1231. removeFileIfExists(outputPath);
  1232. const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
  1233. return safeRespond(500, { error: `音频提取失败 (exit=${code})${lastErrorLine ? `: ${lastErrorLine}` : ''}` });
  1234. }
  1235. const stats = fs.statSync(outputPath);
  1236. const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
  1237. console.log(`🎵 音频提取完成: ${outputFilename} (${sizeMB}MB)`);
  1238. // 返回 base64 编码的 mp3(方便前端直接发给 Gemini)
  1239. const audioBuffer = fs.readFileSync(outputPath);
  1240. const audioBase64 = audioBuffer.toString('base64');
  1241. removeFileIfExists(outputPath);
  1242. safeRespond(200, {
  1243. success: true,
  1244. audio: {
  1245. base64: audioBase64,
  1246. mimeType: outputMimeType,
  1247. sizeMB: parseFloat(sizeMB),
  1248. filename: outputFilename
  1249. }
  1250. });
  1251. });
  1252. });
  1253. app.post('/api/remix/upload-asset', assetUpload.single('file'), async (req, res) => {
  1254. try {
  1255. if (!req.file) {
  1256. return res.status(400).json({ error: '未收到素材文件' });
  1257. }
  1258. const mimeType = req.file.mimetype || 'application/octet-stream';
  1259. const kind = mimeType.startsWith('audio/') ? 'audio' : 'image';
  1260. const key = buildDigitalHumanAssetKey(req.file.originalname, kind);
  1261. const token = buildQiniuUploadToken(key);
  1262. const formData = new FormData();
  1263. formData.append('token', token);
  1264. formData.append('key', key);
  1265. formData.append('file', new Blob([req.file.buffer], { type: mimeType }), path.basename(key));
  1266. const response = await fetch(QINIU_UPLOAD_URL, {
  1267. method: 'POST',
  1268. body: formData
  1269. });
  1270. const text = await response.text();
  1271. let payload = null;
  1272. try {
  1273. payload = text ? JSON.parse(text) : null;
  1274. } catch {
  1275. payload = null;
  1276. }
  1277. if (!response.ok) {
  1278. return res.status(response.status).json({
  1279. error: payload?.error || payload?.message || text || '七牛素材上传失败',
  1280. detail: payload || text || ''
  1281. });
  1282. }
  1283. const uploadedKey = payload?.key || key;
  1284. const url = `${QINIU_CDN_DOMAIN.replace(/\/$/, '')}/${uploadedKey}`;
  1285. if (!uploadedKey || !url) {
  1286. return res.status(500).json({ error: '七牛未返回素材 Key', detail: payload || text || '' });
  1287. }
  1288. res.json({
  1289. success: true,
  1290. url,
  1291. key: uploadedKey,
  1292. mimeType,
  1293. kind
  1294. });
  1295. } catch (error) {
  1296. console.error('❌ 上传重塑素材失败:', error);
  1297. res.status(500).json({ error: `上传重塑素材失败: ${error.message}` });
  1298. }
  1299. });
  1300. // ==================== 文件操作 ====================
  1301. // GET /api/files/whisper/:videoId — 获取指定视频的 Whisper 输出文件列表
  1302. app.get('/api/files/whisper/:videoId', (req, res) => {
  1303. try {
  1304. const manifest = readManifest();
  1305. const video = manifest.find(v => v.id === req.params.videoId);
  1306. if (!video) {
  1307. return res.status(404).json({ error: `未找到视频: ${req.params.videoId}` });
  1308. }
  1309. const baseName = video.filename.replace(/\.[^.]+$/, '');
  1310. const outputDir = path.join(WHISPER_DIR, baseName);
  1311. if (!fs.existsSync(outputDir)) {
  1312. return res.json({ files: [], exists: false });
  1313. }
  1314. const files = fs.readdirSync(outputDir).map(f => ({
  1315. name: f,
  1316. path: `Whisper/${baseName}/${f}`,
  1317. size: fs.statSync(path.join(outputDir, f)).size
  1318. }));
  1319. res.json({ files, exists: true });
  1320. } catch (err) {
  1321. res.status(500).json({ error: err.message });
  1322. }
  1323. });
  1324. // GET /api/files/read — 读取项目内文件内容
  1325. app.get('/api/files/read', (req, res) => {
  1326. const filePath = req.query.path;
  1327. if (!filePath) {
  1328. return res.status(400).json({ error: '缺少 path 参数' });
  1329. }
  1330. const fullPath = path.join(PROJECT_ROOT, filePath);
  1331. // 安全检查:不允许读取项目目录外的文件
  1332. if (!fullPath.startsWith(PROJECT_ROOT)) {
  1333. return res.status(403).json({ error: '路径不在项目目录内' });
  1334. }
  1335. if (!fs.existsSync(fullPath)) {
  1336. return res.status(404).json({ error: '文件不存在' });
  1337. }
  1338. const ext = path.extname(fullPath).toLowerCase();
  1339. if (['.json'].includes(ext)) {
  1340. res.json(JSON.parse(fs.readFileSync(fullPath, 'utf-8')));
  1341. } else {
  1342. res.type('text/plain').send(fs.readFileSync(fullPath, 'utf-8'));
  1343. }
  1344. });
  1345. app.post('/api/download/video', (req, res) => {
  1346. const {
  1347. url,
  1348. urls,
  1349. filename,
  1350. title,
  1351. description,
  1352. tags,
  1353. thumbnail,
  1354. duration,
  1355. resolution,
  1356. awemeId,
  1357. authorName
  1358. } = req.body || {};
  1359. if (!url || typeof url !== 'string') {
  1360. return res.status(400).json({ error: '缺少 url 参数' });
  1361. }
  1362. const candidateUrls = normalizeRemoteUrls(url, urls);
  1363. if (candidateUrls.length === 0) {
  1364. return res.status(400).json({ error: '无效的视频地址' });
  1365. }
  1366. const safeFilename = ensureUniqueVideoFilename(ensureVideoFilename(filename || title || awemeId || 'douyin-video.mp4', url));
  1367. const filePath = path.join(DATA_VIDEO_DIR, safeFilename);
  1368. const taskId = `DL-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
  1369. const task = {
  1370. id: taskId,
  1371. status: 'pending',
  1372. progress: 0,
  1373. downloadedBytes: 0,
  1374. totalBytes: 0,
  1375. filename: safeFilename,
  1376. created_at: new Date().toISOString()
  1377. };
  1378. downloadTasks.set(taskId, task);
  1379. res.json({ success: true, taskId, filename: safeFilename });
  1380. (async () => {
  1381. try {
  1382. task.status = 'downloading';
  1383. console.log(`📥 开始下载远程视频: ${safeFilename} ← ${candidateUrls[0]}`);
  1384. const { url: resolvedUrl, response } = await fetchRemoteVideoResponse(candidateUrls, {
  1385. 'Accept': '*/*',
  1386. 'User-Agent': 'Mozilla/5.0',
  1387. 'Referer': 'https://www.douyin.com/',
  1388. 'Origin': 'https://www.douyin.com'
  1389. });
  1390. task.sourceUrl = resolvedUrl;
  1391. const totalBytes = Number.parseInt(response.headers.get('content-length') || '0', 10) || 0;
  1392. task.totalBytes = totalBytes;
  1393. let downloadedBytes = 0;
  1394. let chunkCount = 0;
  1395. const progressStream = new Transform({
  1396. transform(chunk, encoding, callback) {
  1397. downloadedBytes += chunk.length;
  1398. chunkCount += 1;
  1399. task.downloadedBytes = downloadedBytes;
  1400. task.progress = totalBytes > 0
  1401. ? Math.min(99, Math.round((downloadedBytes / totalBytes) * 100))
  1402. : Math.min(95, Math.max(task.progress || 0, Math.min(95, chunkCount)));
  1403. callback(null, chunk);
  1404. }
  1405. });
  1406. await pipeline(
  1407. Readable.fromWeb(response.body),
  1408. progressStream,
  1409. fs.createWriteStream(filePath)
  1410. );
  1411. const stat = fs.statSync(filePath);
  1412. const manifest = readManifest();
  1413. const videoEntry = createManagedVideoEntry({
  1414. filename: safeFilename,
  1415. title,
  1416. description,
  1417. tags,
  1418. thumbnail,
  1419. duration,
  1420. resolution,
  1421. awemeId,
  1422. authorName,
  1423. size: stat.size
  1424. });
  1425. manifest.push(videoEntry);
  1426. writeManifest(manifest);
  1427. task.status = 'completed';
  1428. task.progress = 100;
  1429. task.completed_at = new Date().toISOString();
  1430. task.video = videoEntry;
  1431. console.log(`✅ 远程视频下载完成: ${safeFilename} (${(stat.size / 1024 / 1024).toFixed(1)}MB)`);
  1432. } catch (error) {
  1433. removeFileIfExists(filePath);
  1434. task.status = 'failed';
  1435. task.error = error.message;
  1436. task.failed_at = new Date().toISOString();
  1437. console.error('❌ 远程视频下载失败:', error);
  1438. }
  1439. })();
  1440. });
  1441. app.get('/api/download/video/:taskId', (req, res) => {
  1442. const task = downloadTasks.get(req.params.taskId);
  1443. if (!task) {
  1444. return res.status(404).json({ error: '未找到下载任务' });
  1445. }
  1446. res.json(task);
  1447. });
  1448. // ==================== 视频流代理 ====================
  1449. app.get('/api/video-proxy', async (req, res) => {
  1450. const { url, filename = 'douyin-video.mp4', download } = req.query;
  1451. if (!url || typeof url !== 'string') {
  1452. return res.status(400).json({ error: '缺少 url 参数' });
  1453. }
  1454. if (!isSafeRemoteUrl(url)) {
  1455. return res.status(400).json({ error: '无效的视频地址' });
  1456. }
  1457. try {
  1458. const upstreamHeaders = {
  1459. 'Accept': req.headers.accept || '*/*',
  1460. 'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
  1461. 'Referer': 'https://www.douyin.com/',
  1462. 'Origin': 'https://www.douyin.com'
  1463. };
  1464. if (req.headers.range) {
  1465. upstreamHeaders.Range = req.headers.range;
  1466. }
  1467. const response = await fetch(url, {
  1468. method: 'GET',
  1469. headers: upstreamHeaders,
  1470. redirect: 'follow'
  1471. });
  1472. if (!response.ok && response.status !== 206) {
  1473. const errorText = await response.text().catch(() => '');
  1474. return res.status(response.status).json({
  1475. error: '远程视频请求失败',
  1476. status: response.status,
  1477. detail: errorText
  1478. });
  1479. }
  1480. res.status(response.status);
  1481. const passthroughHeaders = [
  1482. 'content-type',
  1483. 'content-length',
  1484. 'content-range',
  1485. 'accept-ranges',
  1486. 'etag',
  1487. 'last-modified',
  1488. 'cache-control'
  1489. ];
  1490. passthroughHeaders.forEach((headerName) => {
  1491. const value = response.headers.get(headerName);
  1492. if (value) {
  1493. res.setHeader(headerName, value);
  1494. }
  1495. });
  1496. if (!response.headers.get('content-type')) {
  1497. res.setHeader('Content-Type', 'video/mp4');
  1498. }
  1499. res.setHeader(
  1500. 'Content-Disposition',
  1501. download === '1'
  1502. ? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
  1503. : `inline; filename*=UTF-8''${encodeURIComponent(filename)}`
  1504. );
  1505. if (!response.body) {
  1506. return res.end();
  1507. }
  1508. Readable.fromWeb(response.body).pipe(res);
  1509. } catch (error) {
  1510. console.error('视频代理失败:', error);
  1511. res.status(500).json({ error: `视频代理失败: ${error.message}` });
  1512. }
  1513. });
  1514. // ==================== AI 重塑记录 ====================
  1515. const REMIXES_DIR = path.join(DATA_DIR, 'remixes');
  1516. if (!fs.existsSync(REMIXES_DIR)) fs.mkdirSync(REMIXES_DIR, { recursive: true });
  1517. function readRemixes(videoId) {
  1518. const p = path.join(REMIXES_DIR, `${videoId}.json`);
  1519. if (!fs.existsSync(p)) return [];
  1520. return JSON.parse(fs.readFileSync(p, 'utf-8'));
  1521. }
  1522. function writeRemixes(videoId, data) {
  1523. fs.writeFileSync(path.join(REMIXES_DIR, `${videoId}.json`), JSON.stringify(data, null, 2), 'utf-8');
  1524. }
  1525. // GET /api/remixes/:videoId — 获取某视频的所有重塑记录
  1526. app.get('/api/remixes/:videoId', (req, res) => {
  1527. res.json(readRemixes(req.params.videoId));
  1528. });
  1529. // POST /api/remixes/:videoId — 创建或更新一条重塑记录
  1530. // body: { remixId, ...remixData }
  1531. app.post('/api/remixes/:videoId', (req, res) => {
  1532. const remixes = readRemixes(req.params.videoId);
  1533. const { remixId } = req.body;
  1534. const idx = remixes.findIndex(r => r.remixId === remixId);
  1535. if (idx >= 0) {
  1536. // 更新已有记录(合并 segments)
  1537. remixes[idx] = { ...remixes[idx], ...req.body, updated_at: new Date().toISOString() };
  1538. } else {
  1539. // 新增记录
  1540. remixes.unshift({ ...req.body, created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
  1541. }
  1542. writeRemixes(req.params.videoId, remixes);
  1543. res.json({ success: true });
  1544. });
  1545. // DELETE /api/remixes/:videoId/:remixId — 删除某条重塑记录
  1546. app.delete('/api/remixes/:videoId/:remixId', (req, res) => {
  1547. let remixes = readRemixes(req.params.videoId);
  1548. remixes = remixes.filter(r => r.remixId !== req.params.remixId);
  1549. writeRemixes(req.params.videoId, remixes);
  1550. res.json({ success: true });
  1551. });
  1552. // GET /api/remixes — 获取所有视频的重塑记录汇总
  1553. app.get('/api/remixes', (req, res) => {
  1554. const files = fs.readdirSync(REMIXES_DIR).filter(f => f.endsWith('.json'));
  1555. const all = {};
  1556. files.forEach(f => {
  1557. const videoId = f.replace('.json', '');
  1558. all[videoId] = JSON.parse(fs.readFileSync(path.join(REMIXES_DIR, f), 'utf-8'));
  1559. });
  1560. res.json(all);
  1561. });
  1562. // ==================== 一键成片(Quickly 代理) ====================
  1563. const QUICKLY_APP_KEY = 'ZmNmOGRhNjYzZTAx';
  1564. const QUICKLY_APP_SECRET = 'eaa12154c248cad9159a9d6ea8bedf46';
  1565. const QUICKLY_ACCOUNT_ID = '12859_117409';
  1566. const QUICKLY_CALLBACK_URL = 'https://server.fmode.cn/api/functions/cut/onemerge';
  1567. const QUICKLY_RELAY_URL = 'https://server.fmode.cn/api/functions';
  1568. // POST /api/quickly/create — 代理一键成片请求(服务端签名)
  1569. app.post('/api/quickly/create', async (req, res) => {
  1570. try {
  1571. const { videoUrls, options = {} } = req.body;
  1572. if (!videoUrls || !Array.isArray(videoUrls) || videoUrls.length === 0) {
  1573. return res.status(400).json({ error: '缺少 videoUrls 参数' });
  1574. }
  1575. const timestamp = Date.now().toString();
  1576. const signStr = timestamp + '#' + QUICKLY_APP_SECRET;
  1577. const sign = crypto.createHash('md5').update(signStr).digest('hex');
  1578. const materialList = videoUrls.map(url => ({ type: 'video', value: url }));
  1579. const preId = timestamp;
  1580. const apiBody = {
  1581. account_id: QUICKLY_ACCOUNT_ID,
  1582. callback_url: QUICKLY_CALLBACK_URL,
  1583. material_list: materialList,
  1584. tags: options.tags || '视频,AI生成',
  1585. proportion: options.proportion || '9:16',
  1586. video_duration: options.videoDuration || { min: 10, max: 30 },
  1587. pre_id: preId,
  1588. compose_number: 1,
  1589. ai_voice: options.aiVoice ?? 1,
  1590. ai_bgm: options.aiBgm ?? 1,
  1591. ai_flower: 1,
  1592. ai_subtitle: options.aiSubtitle ?? 0,
  1593. original_voice: 0
  1594. };
  1595. const relayData = JSON.stringify({
  1596. apiPath: '/v2/video/vlog/create',
  1597. apiBody: apiBody,
  1598. appKey: QUICKLY_APP_KEY,
  1599. timestamp: timestamp,
  1600. sign: sign
  1601. });
  1602. const body = JSON.stringify({ action: 'relay', relayData: relayData });
  1603. console.log(`📦 一键成片 - timestamp=${timestamp}, sign=${sign}`);
  1604. console.log(` 材料: ${videoUrls.length} 个视频`);
  1605. // 使用 Node 原生 fetch 发送请求
  1606. const response = await fetch(QUICKLY_RELAY_URL, {
  1607. method: 'POST',
  1608. headers: { 'Content-Type': 'application/json' },
  1609. body: body
  1610. });
  1611. const result = await response.json();
  1612. console.log('📦 一键成片 - 响应:', JSON.stringify(result).substring(0, 300));
  1613. res.json(result);
  1614. } catch (err) {
  1615. console.error('❌ 一键成片代理失败:', err.message);
  1616. res.status(500).json({ error: err.message });
  1617. }
  1618. });
  1619. // POST /api/quickly/query — 代理查询一键成片结果(通过 Parse 云函数)
  1620. app.post('/api/quickly/query', async (req, res) => {
  1621. try {
  1622. const { taskId } = req.body;
  1623. if (!taskId) return res.status(400).json({ error: '缺少 taskId 参数' });
  1624. const body = JSON.stringify({
  1625. id: 'sWvRr8RvPT',
  1626. _ApplicationId: 'ncloudmaster',
  1627. action: 'query',
  1628. taskId: taskId
  1629. });
  1630. const QUICKLY_QUERY_URL = 'https://server.fmode.cn/api/functions';
  1631. const response = await fetch(QUICKLY_QUERY_URL, {
  1632. method: 'POST',
  1633. headers: { 'Content-Type': 'application/json' },
  1634. body: body
  1635. });
  1636. const result = await response.json();
  1637. res.json(result);
  1638. } catch (err) {
  1639. console.error('❌ 一键成片查询失败:', err.message);
  1640. res.status(500).json({ error: err.message });
  1641. }
  1642. });
  1643. // ==================== 视频合成(图片+音频 → 视频)====================
  1644. const COMPOSITE_DIR = path.join(DATA_DIR, 'composite');
  1645. if (!fs.existsSync(COMPOSITE_DIR)) fs.mkdirSync(COMPOSITE_DIR, { recursive: true });
  1646. // 下载远程文件到本地
  1647. async function downloadFile(url, destPath) {
  1648. const response = await fetch(url, { redirect: 'follow' });
  1649. if (!response.ok) throw new Error(`下载失败 (${response.status}): ${url}`);
  1650. const buffer = Buffer.from(await response.arrayBuffer());
  1651. fs.writeFileSync(destPath, buffer);
  1652. return destPath;
  1653. }
  1654. // 获取音频时长(秒)
  1655. function getAudioDuration(audioPath) {
  1656. return new Promise((resolve, reject) => {
  1657. const proc = spawn('ffprobe', [
  1658. '-v', 'error', '-show_entries', 'format=duration',
  1659. '-of', 'default=noprint_wrappers=1:nokey=1', audioPath
  1660. ]);
  1661. let stdout = '';
  1662. proc.stdout.on('data', d => stdout += d.toString());
  1663. proc.on('error', reject);
  1664. proc.on('close', code => {
  1665. const dur = parseFloat(stdout.trim());
  1666. if (code !== 0 || isNaN(dur)) reject(new Error('无法获取音频时长'));
  1667. else resolve(dur);
  1668. });
  1669. });
  1670. }
  1671. // 将图片转为 jpg(确保 ffmpeg 兼容性)
  1672. function convertImageToJpg(inputPath, outputPath) {
  1673. return new Promise((resolve, reject) => {
  1674. const proc = spawn('ffmpeg', ['-y', '-i', inputPath, '-frames:v', '1', outputPath]);
  1675. let stderr = '';
  1676. proc.stderr.on('data', d => stderr += d.toString());
  1677. proc.on('error', reject);
  1678. proc.on('close', code => {
  1679. if (code !== 0) reject(new Error(`图片转换失败: ${stderr.split('\n').filter(Boolean).slice(-1)[0]}`));
  1680. else resolve(outputPath);
  1681. });
  1682. });
  1683. }
  1684. // 单个片段:图片 + 音频 → 视频
  1685. function createSegmentVideo(imagePath, audioPath, outputPath, duration) {
  1686. return new Promise(async (resolve, reject) => {
  1687. try {
  1688. // 先将图片统一转为 jpg 确保兼容性
  1689. const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg';
  1690. await convertImageToJpg(imagePath, jpgPath);
  1691. const args = [
  1692. '-y',
  1693. '-loop', '1', '-i', jpgPath,
  1694. '-i', audioPath,
  1695. '-c:v', 'h264_mf',
  1696. '-c:a', 'aac', '-b:a', '192k',
  1697. '-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black',
  1698. '-pix_fmt', 'yuv420p',
  1699. '-t', String(duration),
  1700. '-shortest',
  1701. outputPath
  1702. ];
  1703. const proc = spawn('ffmpeg', args);
  1704. let stderr = '';
  1705. proc.stderr.on('data', d => stderr += d.toString());
  1706. proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
  1707. proc.on('close', code => {
  1708. removeFileIfExists(jpgPath);
  1709. if (code !== 0) reject(new Error(`片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
  1710. else resolve(outputPath);
  1711. });
  1712. } catch (err) {
  1713. reject(err);
  1714. }
  1715. });
  1716. }
  1717. // 拼接多个视频片段
  1718. function concatVideos(segmentPaths, outputPath) {
  1719. return new Promise((resolve, reject) => {
  1720. // 创建 concat 文件列表
  1721. const listPath = outputPath + '.txt';
  1722. const listContent = segmentPaths.map(p => `file '${p.replace(/\\/g, '/')}'`).join('\n');
  1723. fs.writeFileSync(listPath, listContent, 'utf-8');
  1724. const args = [
  1725. '-y', '-f', 'concat', '-safe', '0',
  1726. '-i', listPath,
  1727. '-c', 'copy',
  1728. outputPath
  1729. ];
  1730. const proc = spawn('ffmpeg', args);
  1731. let stderr = '';
  1732. proc.stderr.on('data', d => stderr += d.toString());
  1733. proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
  1734. proc.on('close', code => {
  1735. removeFileIfExists(listPath);
  1736. if (code !== 0) reject(new Error(`视频拼接失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
  1737. else resolve(outputPath);
  1738. });
  1739. });
  1740. }
  1741. // POST /api/video/composite — 合成视频(图片+音频 → 最终视频)
  1742. app.post('/api/video/composite', async (req, res) => {
  1743. const { segments, title } = req.body;
  1744. // segments: [{ imageUrl, audioUrl, id }]
  1745. if (!Array.isArray(segments) || segments.length === 0) {
  1746. return res.status(400).json({ error: '缺少 segments 参数' });
  1747. }
  1748. const jobId = `VG-${Date.now()}`;
  1749. const jobDir = path.join(COMPOSITE_DIR, jobId);
  1750. fs.mkdirSync(jobDir, { recursive: true });
  1751. try {
  1752. console.log(`🎬 开始合成视频: ${jobId}, ${segments.length} 个片段`);
  1753. // 1. 下载所有素材
  1754. const segmentPaths = [];
  1755. for (let i = 0; i < segments.length; i++) {
  1756. const seg = segments[i];
  1757. if (!seg.imageUrl || !seg.audioUrl) {
  1758. console.warn(`⚠️ 片段 ${seg.id || i} 缺少素材,跳过`);
  1759. continue;
  1760. }
  1761. const imgExt = (seg.imageUrl.match(/\.(jpg|jpeg|png|webp|gif)/i) || ['.jpg'])[0] || '.jpg';
  1762. const imgPath = path.join(jobDir, `img-${i}${imgExt}`);
  1763. const audioExt = (seg.audioUrl.match(/\.(mp3|wav|aac|ogg|m4a)/i) || ['.mp3'])[0] || '.mp3';
  1764. const audioPath = path.join(jobDir, `audio-${i}${audioExt}`);
  1765. const segVideoPath = path.join(jobDir, `seg-${i}.mp4`);
  1766. console.log(` 📥 下载片段 ${i + 1}/${segments.length}...`);
  1767. await downloadFile(seg.imageUrl, imgPath);
  1768. await downloadFile(seg.audioUrl, audioPath);
  1769. // 获取音频时长
  1770. const duration = await getAudioDuration(audioPath);
  1771. console.log(` 🎞️ 合成片段 ${i + 1}/${segments.length} (${duration.toFixed(1)}s)...`);
  1772. await createSegmentVideo(imgPath, audioPath, segVideoPath, duration);
  1773. segmentPaths.push(segVideoPath);
  1774. }
  1775. if (segmentPaths.length === 0) {
  1776. return res.status(400).json({ error: '没有有效的素材片段' });
  1777. }
  1778. // 2. 拼接所有片段
  1779. const safeTitle = String(title || 'video').replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').substring(0, 50);
  1780. const finalFilename = `${safeTitle}-${jobId}.mp4`;
  1781. const finalPath = path.join(COMPOSITE_DIR, finalFilename);
  1782. console.log(` 🔗 拼接 ${segmentPaths.length} 个片段...`);
  1783. if (segmentPaths.length === 1) {
  1784. // 只有一个片段,直接复制
  1785. fs.copyFileSync(segmentPaths[0], finalPath);
  1786. } else {
  1787. await concatVideos(segmentPaths, finalPath);
  1788. }
  1789. // 3. 清理临时文件
  1790. try {
  1791. fs.rmSync(jobDir, { recursive: true, force: true });
  1792. } catch {}
  1793. const fileSize = fs.statSync(finalPath).size;
  1794. console.log(`✅ 视频合成完成: ${finalFilename} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
  1795. res.json({
  1796. success: true,
  1797. videoUrl: `/api/video/composite/${finalFilename}`,
  1798. filename: finalFilename,
  1799. size: fileSize,
  1800. segments: segmentPaths.length
  1801. });
  1802. } catch (err) {
  1803. console.error(`❌ 视频合成失败:`, err.message);
  1804. // 清理
  1805. try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
  1806. res.status(500).json({ error: `视频合成失败: ${err.message}` });
  1807. }
  1808. });
  1809. // GET /api/video/composite/:filename — 下载合成视频
  1810. app.get('/api/video/composite/:filename', (req, res) => {
  1811. const filePath = path.join(COMPOSITE_DIR, req.params.filename);
  1812. if (!fs.existsSync(filePath)) {
  1813. return res.status(404).json({ error: '视频文件不存在' });
  1814. }
  1815. res.setHeader('Content-Type', 'video/mp4');
  1816. fs.createReadStream(filePath).pipe(res);
  1817. });
  1818. // ==================== 任务管理 ====================
  1819. // GET /api/tasks — 获取所有任务
  1820. app.get('/api/tasks', (req, res) => {
  1821. res.json(readDataFile('tasks'));
  1822. });
  1823. // POST /api/tasks — 创建新任务
  1824. app.post('/api/tasks', (req, res) => {
  1825. const tasks = readDataFile('tasks');
  1826. const task = { ...req.body, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
  1827. // 确保始终有有效 ID(即使前端传了空 ID)
  1828. if (!task.id) task.id = `TASK-${Date.now()}`;
  1829. tasks.unshift(task);
  1830. writeDataFile('tasks', tasks);
  1831. res.json({ success: true, task });
  1832. });
  1833. // PUT /api/tasks/:id — 更新任务
  1834. app.put('/api/tasks/:id', (req, res) => {
  1835. const tasks = readDataFile('tasks');
  1836. const idx = tasks.findIndex(t => t.id === req.params.id);
  1837. if (idx === -1) return res.status(404).json({ error: `任务不存在: ${req.params.id}` });
  1838. tasks[idx] = { ...tasks[idx], ...req.body, updated_at: new Date().toISOString() };
  1839. writeDataFile('tasks', tasks);
  1840. res.json({ success: true, task: tasks[idx] });
  1841. });
  1842. // DELETE /api/tasks/:id — 删除任务
  1843. app.delete('/api/tasks/:id', (req, res) => {
  1844. let tasks = readDataFile('tasks');
  1845. const len = tasks.length;
  1846. tasks = tasks.filter(t => t.id !== req.params.id);
  1847. if (tasks.length === len) return res.status(404).json({ error: `任务不存在: ${req.params.id}` });
  1848. writeDataFile('tasks', tasks);
  1849. res.json({ success: true });
  1850. });
  1851. // ==================== 生成历史 ====================
  1852. // GET /api/history — 获取历史记录
  1853. app.get('/api/history', (req, res) => {
  1854. res.json(readDataFile('history'));
  1855. });
  1856. // POST /api/history — 添加历史记录
  1857. app.post('/api/history', (req, res) => {
  1858. const history = readDataFile('history');
  1859. const record = { id: `HIS-${Date.now()}`, created_at: new Date().toISOString(), ...req.body };
  1860. history.unshift(record);
  1861. writeDataFile('history', history);
  1862. res.json({ success: true, record });
  1863. });
  1864. // DELETE /api/history/:id — 删除历史记录
  1865. app.delete('/api/history/:id', (req, res) => {
  1866. let history = readDataFile('history');
  1867. const len = history.length;
  1868. history = history.filter(h => h.id !== req.params.id);
  1869. if (history.length === len) return res.status(404).json({ error: `记录不存在: ${req.params.id}` });
  1870. writeDataFile('history', history);
  1871. res.json({ success: true });
  1872. });
  1873. // DELETE /api/history — 清空所有历史
  1874. app.delete('/api/history', (req, res) => {
  1875. writeDataFile('history', []);
  1876. res.json({ success: true });
  1877. });
  1878. // ==================== 生成结果 ====================
  1879. // GET /api/results — 获取所有结果
  1880. app.get('/api/results', (req, res) => {
  1881. res.json(readDataFile('results'));
  1882. });
  1883. // POST /api/results — 添加结果
  1884. app.post('/api/results', (req, res) => {
  1885. const results = readDataFile('results');
  1886. const result = { id: `RES-${Date.now()}`, created_at: new Date().toISOString(), ...req.body };
  1887. results.unshift(result);
  1888. writeDataFile('results', results);
  1889. res.json({ success: true, result });
  1890. });
  1891. // PUT /api/results/:id — 更新结果
  1892. app.put('/api/results/:id', (req, res) => {
  1893. const results = readDataFile('results');
  1894. const idx = results.findIndex(r => r.id === req.params.id);
  1895. if (idx === -1) return res.status(404).json({ error: `结果不存在: ${req.params.id}` });
  1896. results[idx] = { ...results[idx], ...req.body };
  1897. writeDataFile('results', results);
  1898. res.json({ success: true, result: results[idx] });
  1899. });
  1900. // DELETE /api/results/:id — 删除结果
  1901. app.delete('/api/results/:id', (req, res) => {
  1902. let results = readDataFile('results');
  1903. const len = results.length;
  1904. results = results.filter(r => r.id !== req.params.id);
  1905. if (results.length === len) return res.status(404).json({ error: `结果不存在: ${req.params.id}` });
  1906. writeDataFile('results', results);
  1907. res.json({ success: true });
  1908. });
  1909. // ==================== LLM 大模型代理 ====================
  1910. const LLM_BASE_URL = 'http://server.fmode.cn:9999';
  1911. const LLM_API_KEY = 'sk-MFBOnsAtZiqlwwMgMLKCFmPy55pMohQEGMqsIw3aJrIgvoEO';
  1912. // POST /api/llm/chat — OpenAI ChatCompletions 代理
  1913. app.post('/api/llm/chat', async (req, res) => {
  1914. try {
  1915. const { model, messages, temperature, max_tokens, stream, ...rest } = req.body;
  1916. if (!messages || !Array.isArray(messages)) {
  1917. return res.status(400).json({ error: '缺少 messages 参数' });
  1918. }
  1919. const payload = {
  1920. model: model || 'gpt-4o-mini',
  1921. messages,
  1922. temperature: temperature ?? 0.7,
  1923. max_tokens: max_tokens || 4096,
  1924. stream: stream || false,
  1925. ...rest
  1926. };
  1927. const url = `${LLM_BASE_URL}/v1/chat/completions`;
  1928. console.log(`🤖 LLM Chat 请求: model=${payload.model}, messages=${messages.length}条`);
  1929. if (payload.stream) {
  1930. // 流式响应
  1931. const response = await fetch(url, {
  1932. method: 'POST',
  1933. headers: {
  1934. 'Content-Type': 'application/json',
  1935. 'Authorization': `Bearer ${LLM_API_KEY}`
  1936. },
  1937. body: JSON.stringify(payload)
  1938. });
  1939. if (!response.ok) {
  1940. const errText = await response.text();
  1941. console.error('🤖 LLM Stream 错误:', response.status, errText);
  1942. return res.status(response.status).json({ error: errText });
  1943. }
  1944. res.setHeader('Content-Type', 'text/event-stream');
  1945. res.setHeader('Cache-Control', 'no-cache');
  1946. res.setHeader('Connection', 'keep-alive');
  1947. const reader = response.body;
  1948. reader.on('data', (chunk) => res.write(chunk));
  1949. reader.on('end', () => res.end());
  1950. reader.on('error', (err) => {
  1951. console.error('🤖 LLM Stream 读取错误:', err.message);
  1952. res.end();
  1953. });
  1954. } else {
  1955. // 非流式响应
  1956. const response = await fetch(url, {
  1957. method: 'POST',
  1958. headers: {
  1959. 'Content-Type': 'application/json',
  1960. 'Authorization': `Bearer ${LLM_API_KEY}`
  1961. },
  1962. body: JSON.stringify(payload)
  1963. });
  1964. const data = await response.json();
  1965. if (!response.ok) {
  1966. console.error('🤖 LLM Chat 错误:', response.status, data);
  1967. return res.status(response.status).json(data);
  1968. }
  1969. console.log(`🤖 LLM Chat 完成: tokens=${data?.usage?.total_tokens || '?'}`);
  1970. res.json(data);
  1971. }
  1972. } catch (err) {
  1973. console.error('🤖 LLM Chat 异常:', err.message);
  1974. res.status(500).json({ error: `LLM 请求失败: ${err.message}` });
  1975. }
  1976. });
  1977. // POST /api/llm/gemini — Gemini 原生格式代理(支持媒体识别)
  1978. app.post('/api/llm/gemini', async (req, res) => {
  1979. try {
  1980. const { model, contents, generationConfig, safetySettings, systemInstruction } = req.body;
  1981. if (!contents) {
  1982. return res.status(400).json({ error: '缺少 contents 参数' });
  1983. }
  1984. const geminiModel = model || 'gemini-2.5-flash';
  1985. const url = `${LLM_BASE_URL}/v1beta/models/${geminiModel}:generateContent`;
  1986. const payload = { contents };
  1987. if (generationConfig) payload.generationConfig = generationConfig;
  1988. if (safetySettings) payload.safetySettings = safetySettings;
  1989. if (systemInstruction) payload.systemInstruction = systemInstruction;
  1990. console.log(`🤖 Gemini 请求: model=${geminiModel}, parts=${contents?.[0]?.parts?.length || 0}`);
  1991. const response = await fetch(url, {
  1992. method: 'POST',
  1993. headers: {
  1994. 'Content-Type': 'application/json',
  1995. 'Authorization': `Bearer ${LLM_API_KEY}`
  1996. },
  1997. body: JSON.stringify(payload)
  1998. });
  1999. const data = await response.json();
  2000. if (!response.ok) {
  2001. console.error('🤖 Gemini 错误:', response.status, data);
  2002. return res.status(response.status).json(data);
  2003. }
  2004. console.log(`🤖 Gemini 完成: tokens=${data?.usageMetadata?.totalTokenCount || '?'}`);
  2005. res.json(data);
  2006. } catch (err) {
  2007. console.error('🤖 Gemini 异常:', err.message);
  2008. res.status(500).json({ error: `Gemini 请求失败: ${err.message}` });
  2009. }
  2010. });
  2011. // ==================== 健康检查 ====================
  2012. app.get('/api/health', (req, res) => {
  2013. res.json({
  2014. status: 'ok',
  2015. timestamp: new Date().toISOString(),
  2016. project: PROJECT_ROOT,
  2017. services: {
  2018. manifest: fs.existsSync(MANIFEST_PATH),
  2019. whisperDir: fs.existsSync(WHISPER_DIR),
  2020. videoDir: fs.existsSync(DATA_VIDEO_DIR) || fs.existsSync(LEGACY_VIDEO_DIR)
  2021. }
  2022. });
  2023. });
  2024. // ==================== 启动 ====================
  2025. app.listen(PORT, () => {
  2026. console.log('');
  2027. console.log('========================================');
  2028. console.log(` 🚀 后端服务已启动: http://localhost:${PORT}`);
  2029. console.log(` 📁 项目根目录: ${PROJECT_ROOT}`);
  2030. console.log(` 📋 接口列表:`);
  2031. console.log(` GET /api/health — 健康检查`);
  2032. console.log(` GET /api/whisper/status — Whisper 可用性`);
  2033. console.log(` POST /api/whisper/transcribe — 语音转文字`);
  2034. console.log(` GET /api/manifest — 获取视频清单`);
  2035. console.log(` PUT /api/manifest/:videoId — 更新视频信息`);
  2036. console.log(` POST /api/manifest — 添加视频条目`);
  2037. console.log(` GET /api/files/whisper/:id — Whisper 输出文件`);
  2038. console.log(` GET /api/files/read?path= — 读取项目文件`);
  2039. console.log(` POST /api/upload/video — 上传视频文件`);
  2040. console.log(` POST /api/remix/extract-audio — 提取视频音频`);
  2041. console.log(` POST /api/remix/upload-asset — 上传重塑素材到 Parse`);
  2042. console.log(` POST /api/download/video — 下载远程视频到本地库`);
  2043. console.log(` GET /api/download/video/:id — 查询下载任务进度`);
  2044. console.log(` ---`);
  2045. console.log(` GET /api/tasks — 获取任务列表`);
  2046. console.log(` POST /api/tasks — 创建任务`);
  2047. console.log(` PUT /api/tasks/:id — 更新任务`);
  2048. console.log(` DEL /api/tasks/:id — 删除任务`);
  2049. console.log(` ---`);
  2050. console.log(` GET /api/history — 获取历史记录`);
  2051. console.log(` POST /api/history — 添加历史`);
  2052. console.log(` DEL /api/history/:id — 删除历史`);
  2053. console.log(` DEL /api/history — 清空历史`);
  2054. console.log(` ---`);
  2055. console.log(` GET /api/results — 获取结果`);
  2056. console.log(` POST /api/results — 添加结果`);
  2057. console.log(` PUT /api/results/:id — 更新结果`);
  2058. console.log(` DEL /api/results/:id — 删除结果`);
  2059. console.log(` ---`);
  2060. console.log(` POST /api/llm/chat — LLM 对话(OpenAI格式)`);
  2061. console.log(` POST /api/llm/gemini — Gemini 媒体识别`);
  2062. console.log('========================================');
  2063. console.log('');
  2064. });