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