server.js 98 KB

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