| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009 |
- const express = require('express');
- const { spawn, exec } = require('child_process');
- const path = require('path');
- const fs = require('fs');
- const cors = require('cors');
- const crypto = require('crypto');
- const multer = require('multer');
- const { Readable, Transform } = require('stream');
- const { pipeline } = require('stream/promises');
- const LOCAL_ENV_PATH = path.join(__dirname, '.env');
- if (fs.existsSync(LOCAL_ENV_PATH)) {
- const envText = fs.readFileSync(LOCAL_ENV_PATH, 'utf-8');
- for (const rawLine of envText.split(/\r?\n/)) {
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) {
- continue;
- }
- const separatorIndex = line.indexOf('=');
- if (separatorIndex <= 0) {
- continue;
- }
- const key = line.slice(0, separatorIndex).trim();
- let value = line.slice(separatorIndex + 1).trim();
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- if (key && process.env[key] === undefined) {
- process.env[key] = value;
- }
- }
- }
- const runtimeConfig = require('./server/config/runtime-config');
- const { registerHealthRoutes } = require('./server/routes/health');
- const { registerFileRoutes } = require('./server/routes/files');
- const { registerVideoProxyRoutes } = require('./server/routes/video-proxy');
- const { registerLlmRoutes } = require('./server/routes/llm');
- const { registerQuicklyRoutes } = require('./server/routes/quickly');
- const { registerUploadRoutes } = require('./server/routes/upload');
- const { registerAudioExtractionRoutes } = require('./server/routes/audio-extraction');
- const { registerVideoDownloadRoutes } = require('./server/routes/video-download');
- const { registerVideoCompositeRoutes } = require('./server/routes/video-composite');
- const { registerWhisperRoutes } = require('./server/routes/whisper');
- const { registerRemixAssetUploadRoutes } = require('./server/routes/remix-asset-upload');
- const { printStartupLog } = require('./server/startup-log');
- const app = express();
- const PORT = runtimeConfig.port;
- const PROJECT_ROOT = __dirname;
- const DATA_DIR = path.join(PROJECT_ROOT, 'data');
- const DATA_VIDEO_DIR = path.join(DATA_DIR, 'videos');
- const DATA_REMIX_ASSET_DIR = path.join(DATA_DIR, 'remix-assets');
- const LEGACY_VIDEO_DIR = path.join(PROJECT_ROOT, 'src', 'video');
- const VOICE_SPEAKER_ID_DOC_PATH = path.join(PROJECT_ROOT, 'docs', '音色创建', 'speaker_id.md');
- const MANIFEST_PATH = path.join(DATA_DIR, 'manifest.json');
- const LEGACY_MANIFEST_PATH = path.join(LEGACY_VIDEO_DIR, 'manifest.json');
- const WHISPER_DIR = path.join(PROJECT_ROOT, 'Whisper');
- const downloadTasks = new Map();
- const transcriptTasks = new Map();
- const TRANSCRIPT_TEMP_DIR = path.join(DATA_DIR, 'douyin-transcripts');
- const DOUYIN_API_BASE_URL = runtimeConfig.douyinApiBaseUrl;
- const TRANSCRIPTION_GATEWAY_BASE_URL = runtimeConfig.transcriptionGatewayBaseUrl;
- const DOUYIN_GATEWAY_MAX_ATTEMPTS = runtimeConfig.douyinGatewayMaxAttempts;
- const DOUYIN_API_ROUTES = {
- searchVideos: { method: 'POST', path: '/douyin/search/fetch_general_search_v2' },
- challengeSearch: { method: 'POST', path: '/douyin/search/fetch_challenge_search_v2' },
- videoDetail: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_v3' },
- videoByShareUrl: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_by_share_url' },
- userProfileWeb: { method: 'GET', path: '/douyin/web/handler_user_profile_v2' },
- userProfileApp: { method: 'GET', path: '/douyin/app/v3/handler_user_profile' },
- userPosts: { method: 'GET', path: '/douyin/app/v3/fetch_user_post_videos' },
- comments: { method: 'GET', path: '/douyin/app/v3/fetch_video_comments' },
- replies: { method: 'GET', path: '/douyin/app/v3/fetch_video_comment_replies' },
- };
- const QINIU_ACCESS_KEY = runtimeConfig.qiniu.accessKey;
- const QINIU_SECRET_KEY = runtimeConfig.qiniu.secretKey;
- const QINIU_BUCKET = runtimeConfig.qiniu.bucket;
- const QINIU_CDN_DOMAIN = runtimeConfig.qiniu.cdnDomain;
- const QINIU_CDN_PREFIX = runtimeConfig.qiniu.cdnPrefix;
- const QINIU_UPLOAD_URL = runtimeConfig.qiniu.uploadUrl;
- const VOLC_SPEECH_BASE_URL = runtimeConfig.volc.speechBaseUrl;
- const VOLC_TTS_PROXY_BASE_URL = runtimeConfig.volc.ttsProxyBaseUrl;
- const VOLC_SPEECH_API_KEY = runtimeConfig.volc.speechApiKey;
- const VOLC_TTS_RESOURCE_ID = runtimeConfig.volc.ttsResourceId;
- const VOLC_SPEECH_APP_KEY = runtimeConfig.volc.speechAppKey;
- const VOLC_SPEECH_ACCESS_KEY = runtimeConfig.volc.speechAccessKey;
- app.use(cors());
- app.use(express.json({ limit: '50mb' }));
- // 静态文件:提供视频文件的访问
- const staticVideoOptions = {
- setHeaders: (res, filePath) => {
- if (filePath.match(/\.(mp4|mov|webm|mkv|avi)$/i)) {
- res.setHeader('Content-Type', 'video/mp4');
- res.setHeader('Accept-Ranges', 'bytes');
- }
- }
- };
- app.use('/api/video', express.static(DATA_VIDEO_DIR, staticVideoOptions));
- app.use('/api/video', express.static(LEGACY_VIDEO_DIR, staticVideoOptions));
- // ==================== 文件上传配置 ====================
- const uploadStorage = multer.diskStorage({
- destination: (req, file, cb) => {
- const uploadDir = DATA_VIDEO_DIR;
- if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
- cb(null, uploadDir);
- },
- filename: (req, file, cb) => {
- // 保留原始文件名,如有冲突则加时间戳
- const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
- const ext = path.extname(originalName);
- const baseName = path.basename(originalName, ext);
- const targetPath = path.join(DATA_VIDEO_DIR, originalName);
- if (fs.existsSync(targetPath)) {
- cb(null, `${baseName}-${Date.now()}${ext}`);
- } else {
- cb(null, originalName);
- }
- }
- });
- const assetUpload = multer({
- storage: multer.memoryStorage(),
- // 200MB 以支持动作迁移 / 素材拼接场景的参考视频上传
- limits: { fileSize: 200 * 1024 * 1024 }
- });
- const upload = multer({
- storage: uploadStorage,
- limits: { fileSize: 500 * 1024 * 1024 }, // 500MB
- fileFilter: (req, file, cb) => {
- const allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm', 'video/x-matroska'];
- if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp4|mov|avi|webm|mkv)$/i)) {
- cb(null, true);
- } else {
- cb(new Error('仅支持视频文件(mp4, mov, avi, webm, mkv)'));
- }
- }
- });
- // ==================== 工具函数 ====================
- // 确保 data 目录存在
- if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
- if (!fs.existsSync(DATA_VIDEO_DIR)) fs.mkdirSync(DATA_VIDEO_DIR, { recursive: true });
- if (!fs.existsSync(DATA_REMIX_ASSET_DIR)) fs.mkdirSync(DATA_REMIX_ASSET_DIR, { recursive: true });
- function ensureManifestFile() {
- if (fs.existsSync(MANIFEST_PATH)) return;
- if (fs.existsSync(LEGACY_MANIFEST_PATH)) {
- fs.copyFileSync(LEGACY_MANIFEST_PATH, MANIFEST_PATH);
- return;
- }
- fs.writeFileSync(MANIFEST_PATH, '[]', 'utf-8');
- }
- function readManifest() {
- ensureManifestFile();
- const raw = fs.readFileSync(MANIFEST_PATH, 'utf-8');
- return JSON.parse(raw);
- }
- function writeManifest(data) {
- ensureManifestFile();
- fs.writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2), 'utf-8');
- }
- function resolveVideoPath(filename) {
- const runtimePath = path.join(DATA_VIDEO_DIR, filename);
- if (fs.existsSync(runtimePath)) return runtimePath;
- const legacyPath = path.join(LEGACY_VIDEO_DIR, filename);
- if (fs.existsSync(legacyPath)) return legacyPath;
- return runtimePath;
- }
- function removeFileIfExists(filePath) {
- try {
- if (filePath && fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- }
- } catch (err) {
- // 忽略竞态条件 / 权限问题导致的删除失败,避免 ENOENT 等异常中断主流程
- if (err && err.code !== 'ENOENT') {
- console.warn(`⚠️ 删除文件失败 ${filePath}: ${err.message}`);
- }
- }
- }
- function removeDirectoryIfExists(dirPath) {
- if (fs.existsSync(dirPath)) {
- fs.rmSync(dirPath, { recursive: true, force: true });
- }
- }
- function toBase64Url(input) {
- return Buffer.from(input)
- .toString('base64')
- .replace(/\+/g, '-')
- .replace(/\//g, '_');
- }
- function buildQiniuUploadToken(key) {
- const deadline = Math.floor(Date.now() / 1000) + 3600;
- const putPolicy = {
- scope: `${QINIU_BUCKET}:${key}`,
- deadline
- };
- const encodedPutPolicy = toBase64Url(JSON.stringify(putPolicy));
- const sign = crypto
- .createHmac('sha1', QINIU_SECRET_KEY)
- .update(encodedPutPolicy)
- .digest('base64')
- .replace(/\+/g, '-')
- .replace(/\//g, '_');
- return `${QINIU_ACCESS_KEY}:${sign}:${encodedPutPolicy}`;
- }
- function buildDigitalHumanAssetKey(fileName, kind) {
- const rawName = Buffer.from(fileName || `asset-${Date.now()}`, 'latin1').toString('utf8');
- const ext = path.extname(rawName).toLowerCase();
- const safeExt = ext && /^[.a-z0-9]+$/i.test(ext) ? ext : '';
- const baseName = path.basename(rawName, ext).replace(/[^a-zA-Z0-9_-]/g, '_') || `asset-${Date.now()}`;
- const date = new Date();
- const yyyy = date.getFullYear();
- const mm = String(date.getMonth() + 1).padStart(2, '0');
- const dd = String(date.getDate()).padStart(2, '0');
- const timestamp = `${yyyy}${mm}${dd}-${Date.now()}`;
- return `${QINIU_CDN_PREFIX}/digital-human/${kind}/${yyyy}${mm}${dd}/${timestamp}-${baseName}${safeExt}`;
- }
- function createRequestId() {
- return typeof crypto.randomUUID === 'function'
- ? crypto.randomUUID()
- : `${Date.now()}-${crypto.randomBytes(8).toString('hex')}`;
- }
- function buildSpeechHeaders(contentType = 'application/json') {
- const headers = {
- 'Content-Type': contentType,
- 'X-Api-Request-Id': createRequestId()
- };
- if (VOLC_SPEECH_API_KEY) {
- headers['X-Api-Key'] = VOLC_SPEECH_API_KEY;
- return headers;
- }
- if (VOLC_SPEECH_APP_KEY && VOLC_SPEECH_ACCESS_KEY) {
- headers['X-Api-App-Key'] = VOLC_SPEECH_APP_KEY;
- headers['X-Api-Access-Key'] = VOLC_SPEECH_ACCESS_KEY;
- return headers;
- }
- throw new Error('未配置火山语音鉴权,请设置 VOLC_SPEECH_API_KEY 或 VOLC_SPEECH_APP_KEY + VOLC_SPEECH_ACCESS_KEY');
- }
- function normalizeOfficialSpeechResourceId(value) {
- const resourceId = String(value || '').trim();
- return /^seed-(tts|icl)-/i.test(resourceId) ? resourceId : '';
- }
- function inferOfficialSpeechResourceId(speaker, requestedResourceId = '') {
- const explicitResourceId = normalizeOfficialSpeechResourceId(requestedResourceId);
- if (explicitResourceId) {
- return explicitResourceId;
- }
- const configuredResourceId = normalizeOfficialSpeechResourceId(VOLC_TTS_RESOURCE_ID);
- if (configuredResourceId) {
- return configuredResourceId;
- }
- const normalizedSpeaker = String(speaker || '').trim();
- if (/^(S_|icl_|saturn_|dit_)/i.test(normalizedSpeaker)) {
- return 'seed-icl-2.0';
- }
- return 'seed-tts-2.0';
- }
- function clampNumber(value, fallback, min, max) {
- const numericValue = Number(value);
- if (!Number.isFinite(numericValue)) {
- return fallback;
- }
- return Math.min(max, Math.max(min, numericValue));
- }
- function clampOptionalNumber(value, min, max) {
- if (value === null || value === undefined || String(value).trim() === '') {
- return null;
- }
- const numericValue = Number(value);
- if (!Number.isFinite(numericValue)) {
- return null;
- }
- return Math.min(max, Math.max(min, numericValue));
- }
- function parseOptionalBoolean(value, fallback = false) {
- if (value === null || value === undefined || value === '') {
- return fallback;
- }
- if (typeof value === 'boolean') {
- return value;
- }
- if (typeof value === 'number') {
- return value !== 0;
- }
- const normalizedValue = String(value).trim().toLowerCase();
- if (['true', '1', 'yes', 'on'].includes(normalizedValue)) {
- return true;
- }
- if (['false', '0', 'no', 'off'].includes(normalizedValue)) {
- return false;
- }
- return fallback;
- }
- function normalizeVoiceSynthesisErrorMessage(message) {
- const rawMessage = String(message || '').trim();
- if (/resource ID is mismatched with speaker related resource/i.test(rawMessage)) {
- return '当前本地 X-Api-Key 与所选 speaker_id 不属于同一语音资源,无法直接使用 speaker_id 合成。请先通过音色复刻获取 timbreId 后再合成,或更换与该 speaker_id 匹配的 X-Api-Key。';
- }
- return rawMessage || '语音合成失败';
- }
- function mapVoiceDesignStatus(status) {
- switch (Number(status)) {
- case 0:
- return '未找到';
- case 1:
- return '训练中';
- case 2:
- return '可用';
- case 3:
- return '失败';
- case 4:
- return '已激活';
- default:
- return '未知';
- }
- }
- function mapVoiceCloneStatus(status) {
- switch (String(status ?? '')) {
- case '0':
- return '未占用';
- case '1':
- return '可用';
- case '2':
- return '训练中';
- case '3':
- return '失败';
- case '404':
- return '已删除';
- default:
- return '未知';
- }
- }
- function normalizeBearerToken(token) {
- const rawToken = String(token || '').trim();
- if (!rawToken) {
- return '';
- }
- return /^Bearer\s+/i.test(rawToken) ? rawToken : `Bearer ${rawToken}`;
- }
- function inferAudioFormat(file) {
- const mimeType = String(file?.mimetype || '').toLowerCase();
- if (mimeType.includes('mpeg') || mimeType.includes('mp3')) {
- return 'mp3';
- }
- if (mimeType.includes('wav')) {
- return 'wav';
- }
- if (mimeType.includes('m4a') || mimeType.includes('mp4')) {
- return 'm4a';
- }
- if (mimeType.includes('aac')) {
- return 'aac';
- }
- if (mimeType.includes('flac')) {
- return 'flac';
- }
- if (mimeType.includes('ogg') || mimeType.includes('opus')) {
- return 'ogg_opus';
- }
- if (mimeType.includes('pcm')) {
- return 'pcm';
- }
- const ext = path.extname(file?.originalname || '').toLowerCase();
- switch (ext) {
- case '.wav':
- return 'wav';
- case '.m4a':
- return 'm4a';
- case '.aac':
- return 'aac';
- case '.flac':
- return 'flac';
- case '.ogg':
- case '.opus':
- return 'ogg_opus';
- case '.pcm':
- return 'pcm';
- case '.mp3':
- default:
- return 'mp3';
- }
- }
- async function readFetchResponse(response) {
- const rawText = await response.text();
- let payload = null;
- try {
- payload = rawText ? JSON.parse(rawText) : null;
- } catch {
- payload = null;
- }
- return {
- rawText,
- payload
- };
- }
- function extractPrimaryVoiceModel(timbre) {
- if (!Array.isArray(timbre?.models) || timbre.models.length === 0) {
- return {};
- }
- return timbre.models[0] || {};
- }
- function readVoiceSpeakerIdOptions() {
- if (!fs.existsSync(VOICE_SPEAKER_ID_DOC_PATH)) {
- return [];
- }
- const rawText = fs.readFileSync(VOICE_SPEAKER_ID_DOC_PATH, 'utf-8');
- const seen = new Set();
- const options = [];
- for (const rawLine of rawText.split(/\r?\n/)) {
- const speakerId = rawLine.trim().match(/^S_[A-Za-z0-9]+$/)?.[0] || '';
- if (!speakerId || seen.has(speakerId)) {
- continue;
- }
- seen.add(speakerId);
- options.push(speakerId);
- }
- return options;
- }
- function upsertVoiceProfile(profile) {
- const profiles = readDataFile('voice-profiles');
- const existingIndex = profiles.findIndex((item) => (
- (!!profile.timbre_id && item.timbre_id === profile.timbre_id)
- || (!!profile.id && item.id === profile.id)
- ));
- if (existingIndex >= 0) {
- const existingProfile = profiles[existingIndex];
- const nextProfile = {
- ...existingProfile,
- ...profile,
- id: existingProfile.id || profile.id,
- created_at: existingProfile.created_at || profile.created_at,
- updated_at: profile.updated_at || new Date().toISOString()
- };
- profiles[existingIndex] = nextProfile;
- writeDataFile('voice-profiles', profiles);
- return nextProfile;
- }
- profiles.unshift(profile);
- writeDataFile('voice-profiles', profiles);
- return profile;
- }
- async function requestVolcTtsJson(endpoint, requestBody) {
- const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(requestBody)
- });
- const { rawText, payload } = await readFetchResponse(response);
- if (!response.ok) {
- const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败');
- error.status = response.status;
- error.detail = payload || rawText || '';
- throw error;
- }
- return payload;
- }
- async function proxyVolcTtsStream(endpoint, requestBody, res) {
- const response = await fetch(`${VOLC_TTS_PROXY_BASE_URL}/${endpoint}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(requestBody)
- });
- if (!response.ok) {
- const { rawText, payload } = await readFetchResponse(response);
- const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 流式请求失败');
- error.status = response.status;
- error.detail = payload || rawText || '';
- throw error;
- }
- res.status(response.status);
- res.setHeader('Content-Type', response.headers.get('content-type') || 'application/x-ndjson; charset=utf-8');
- res.setHeader('Cache-Control', 'no-cache, no-transform');
- if (!response.body) {
- res.end();
- return;
- }
- await pipeline(Readable.fromWeb(response.body), res);
- }
- async function synthesizeVoiceAudio({ token, speaker, timbreId, text, ssml, xApiResourceId, model, isStream = false, audioParams = {}, additions = {} }) {
- const normalizedSpeaker = String(speaker || '').trim();
- const fallbackSpeaker = String(timbreId || '').trim();
- const resolvedSpeaker = normalizedSpeaker || fallbackSpeaker;
- const requestedResourceId = String(xApiResourceId || '').trim();
- const headerResourceId = inferOfficialSpeechResourceId(resolvedSpeaker, requestedResourceId);
- const resolvedModel = String(model || '').trim();
- const serializedAdditions = Object.keys(additions).length > 0 ? JSON.stringify(additions) : '';
- const requestBody = {
- req_params: {
- ...(ssml ? { ssml } : { text }),
- speaker: resolvedSpeaker,
- audio_params: {
- format: audioParams.format || 'mp3',
- sample_rate: audioParams.sampleRate ?? 24000,
- ...(audioParams.speechRate === null || audioParams.speechRate === undefined ? {} : { speech_rate: audioParams.speechRate }),
- ...(audioParams.loudnessRate === null || audioParams.loudnessRate === undefined ? {} : { loudness_rate: audioParams.loudnessRate }),
- ...(audioParams.emotion ? { emotion: audioParams.emotion } : {}),
- ...(audioParams.emotionScale === null || audioParams.emotionScale === undefined ? {} : { emotion_scale: audioParams.emotionScale }),
- ...(audioParams.enableSubtitle ? { enable_subtitle: true } : {})
- },
- ...(serializedAdditions ? { additions: serializedAdditions } : {}),
- ...(resolvedModel ? { model: resolvedModel } : {})
- }
- };
- const headers = buildSpeechHeaders();
- headers['X-Api-Resource-Id'] = headerResourceId;
- headers['Connection'] = 'keep-alive';
- const response = await fetch(`${VOLC_SPEECH_BASE_URL}/api/v3/tts/unidirectional`, {
- method: 'POST',
- headers,
- body: JSON.stringify(requestBody)
- });
- const { rawText, payload } = await readFetchResponse(response);
- if (!response.ok) {
- const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || payload?.error || rawText || 'TTS 请求失败'));
- error.status = response.status;
- error.detail = payload || rawText || '';
- throw error;
- }
- if (Number(payload?.code) !== 200 || !payload?.data?.audioUrl) {
- const error = new Error(normalizeVoiceSynthesisErrorMessage(payload?.error?.message || payload?.message || '语音合成失败'));
- error.status = 502;
- error.detail = payload || '';
- throw error;
- }
- return {
- audioUrl: payload.data.audioUrl,
- workId: payload.data.workId || '',
- payload
- };
- }
- function createHttpError(message, status = 500, detail = '') {
- const error = new Error(message || '请求失败');
- error.status = status;
- error.detail = detail;
- return error;
- }
- function inferAudioFormatFromUrl(url) {
- try {
- const parsed = new URL(String(url || '').trim());
- return inferAudioFormat({ originalname: parsed.pathname || '', mimetype: '' });
- } catch {
- return inferAudioFormat({ originalname: String(url || '').trim(), mimetype: '' });
- }
- }
- function normalizeVoiceClonePayload(body) {
- const audioData = body?.audioData && typeof body.audioData === 'object' ? body.audioData : {};
- const extraParams = body?.extra_params && typeof body.extra_params === 'object' ? body.extra_params : {};
- return {
- token: normalizeBearerToken(body?.token) || runtimeConfig.voice.defaultToken,
- name: String(body?.name || body?.displayName || '').trim(),
- timbreId: String(body?.timbreId || '').trim(),
- speakerId: String(body?.speaker_id || body?.speakerId || '').trim(),
- audioData: {
- url: String(audioData?.url || '').trim(),
- base64: String(audioData?.base64 || '').trim(),
- format: String(audioData?.format || '').trim().toLowerCase(),
- text: String(audioData?.text || body?.audioText || '').trim()
- },
- language: Number(body?.language ?? 0) === 1 ? 1 : 0,
- demoText: String(extraParams?.demo_text || body?.sampleText || '').trim(),
- sourceAudioName: String(body?.sourceAudioName || body?.source_audio_name || '').trim(),
- sourceAudioFormat: String(body?.sourceAudioFormat || body?.source_audio_format || '').trim().toLowerCase()
- };
- }
- function normalizeVoiceSynthesisPayload(body) {
- const rawAudioParams = body?.audio_params && typeof body.audio_params === 'object'
- ? body.audio_params
- : (body?.audioParams && typeof body.audioParams === 'object' ? body.audioParams : {});
- const rawAdditions = body?.additions && typeof body.additions === 'object' ? body.additions : {};
- const formatCandidate = String(rawAudioParams?.format || '').trim().toLowerCase();
- const format = ['mp3', 'ogg_opus', 'pcm'].includes(formatCandidate) ? formatCandidate : 'mp3';
- const sampleRateCandidate = Number(rawAudioParams?.sampleRate ?? rawAudioParams?.sample_rate);
- const sampleRate = [8000, 16000, 22050, 24000, 32000, 44100, 48000].includes(sampleRateCandidate) ? sampleRateCandidate : 24000;
- const speechRate = clampNumber(rawAudioParams?.speechRate ?? rawAudioParams?.speech_rate, 0, -50, 100);
- const loudnessRate = clampNumber(rawAudioParams?.loudnessRate ?? rawAudioParams?.loudness_rate, 0, -50, 100);
- const emotion = String(rawAudioParams?.emotion || '').trim();
- const emotionScale = clampOptionalNumber(rawAudioParams?.emotionScale ?? rawAudioParams?.emotion_scale, 1, 5);
- const enableSubtitle = parseOptionalBoolean(rawAudioParams?.enableSubtitle ?? rawAudioParams?.enable_subtitle, false);
- const silenceDuration = clampOptionalNumber(rawAdditions?.silenceDuration ?? rawAdditions?.silence_duration, 0, 30000);
- const enableLanguageDetector = parseOptionalBoolean(rawAdditions?.enableLanguageDetector ?? rawAdditions?.enable_language_detector, false);
- const disableMarkdownFilter = parseOptionalBoolean(rawAdditions?.disableMarkdownFilter ?? rawAdditions?.disable_markdown_filter, false);
- const disableEmojiFilter = parseOptionalBoolean(rawAdditions?.disableEmojiFilter ?? rawAdditions?.disable_emoji_filter, false);
- const explicitLanguage = String((rawAdditions?.explicitLanguage ?? rawAdditions?.explicit_language) || '').trim();
- return {
- token: normalizeBearerToken(body?.token) || runtimeConfig.voice.defaultToken,
- text: String(body?.text || '').trim(),
- ssml: String(body?.ssml || '').trim(),
- timbreId: String(body?.timbreId || body?.timbre_id || '').trim(),
- speakerId: String(body?.speaker_id || body?.speakerId || body?.speaker || body?.volcengine_voice_type || body?.voiceType || body?.voice_type || '').trim(),
- isStream: parseOptionalBoolean(body?.isStream ?? body?.is_stream, true),
- xApiResourceId: String(body?.x_api_resource_id || body?.xApiResourceId || '').trim(),
- model: String(body?.model || '').trim(),
- audioParams: {
- format,
- sampleRate,
- speechRate,
- loudnessRate,
- emotion,
- emotionScale,
- enableSubtitle
- },
- additions: {
- ...(silenceDuration === null ? {} : { silence_duration: silenceDuration }),
- ...(enableLanguageDetector ? { enable_language_detector: true } : {}),
- ...(disableMarkdownFilter ? { disable_markdown_filter: true } : {}),
- ...(disableEmojiFilter ? { disable_emoji_filter: true } : {}),
- ...(explicitLanguage ? { explicit_language: explicitLanguage } : {})
- }
- };
- }
- function buildProxySynthesisPayload(payload) {
- return {
- token: payload.token,
- ...(payload.text ? { text: payload.text } : {}),
- ...(payload.ssml ? { ssml: payload.ssml } : {}),
- ...(payload.timbreId ? { timbreId: payload.timbreId } : {}),
- ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}),
- isStream: !!payload.isStream,
- ...(payload.xApiResourceId ? { x_api_resource_id: payload.xApiResourceId } : {}),
- ...(payload.model ? { model: payload.model } : {}),
- audio_params: {
- format: payload.audioParams.format,
- sample_rate: payload.audioParams.sampleRate,
- speech_rate: payload.audioParams.speechRate,
- loudness_rate: payload.audioParams.loudnessRate,
- ...(payload.audioParams.emotion ? { emotion: payload.audioParams.emotion } : {}),
- ...(payload.audioParams.emotionScale === null || payload.audioParams.emotionScale === undefined ? {} : { emotion_scale: payload.audioParams.emotionScale }),
- ...(payload.audioParams.enableSubtitle ? { enable_subtitle: true } : {})
- },
- ...(Object.keys(payload.additions).length > 0 ? { additions: payload.additions } : {})
- };
- }
- async function executeVoiceCloneRequest(payload) {
- if (!payload.token) {
- throw createHttpError('缺少 token 参数', 400);
- }
- if (!payload.name) {
- throw createHttpError('缺少音色名称 name', 400);
- }
- if (!payload.timbreId && !payload.speakerId) {
- throw createHttpError('timbreId与speaker_id不能同时为空', 400);
- }
- if (!payload.audioData.url && !payload.audioData.base64) {
- throw createHttpError('音频url或base64至少提供一个', 400);
- }
- if (payload.demoText && (payload.demoText.length < 4 || payload.demoText.length > 80)) {
- throw createHttpError('试听文本长度需在 4-80 字之间', 400);
- }
- const clonePayload = await requestVolcTtsJson('voice_clone', {
- token: payload.token,
- name: payload.name,
- ...(payload.timbreId ? { timbreId: payload.timbreId } : {}),
- ...(payload.speakerId ? { speaker_id: payload.speakerId } : {}),
- audioData: {
- ...(payload.audioData.url ? { url: payload.audioData.url } : {}),
- ...(payload.audioData.base64 ? { base64: payload.audioData.base64 } : {}),
- ...(payload.audioData.format ? { format: payload.audioData.format } : {}),
- ...(payload.audioData.text ? { text: payload.audioData.text } : {})
- },
- language: payload.language,
- ...(payload.demoText ? { extra_params: { demo_text: payload.demoText } } : {})
- });
- if (Number(clonePayload?.code) !== 200 || !clonePayload?.data?.timbre?.objectId) {
- throw createHttpError(clonePayload?.error?.message || clonePayload?.message || '音色复刻失败', 502, clonePayload || '');
- }
- const timbre = clonePayload.data.timbre;
- const primaryModel = extractPrimaryVoiceModel(timbre);
- const now = new Date().toISOString();
- const profile = upsertVoiceProfile({
- id: `VOICE-${Date.now()}`,
- name: payload.name,
- creation_mode: 'clone',
- speaker_id: timbre?.speaker_id || payload.speakerId,
- timbre_id: timbre?.objectId || payload.timbreId || '',
- sample_text: payload.demoText,
- source_audio_text: payload.audioData.text,
- source_audio_name: payload.sourceAudioName,
- source_audio_format: payload.sourceAudioFormat || payload.audioData.format || inferAudioFormatFromUrl(payload.audioData.url),
- text_prompt: '',
- language: payload.language,
- status: Number.isFinite(Number(timbre?.status)) ? Number(timbre.status) : null,
- status_label: mapVoiceCloneStatus(timbre?.status),
- demo_audio: primaryModel?.demo_audio || '',
- available_training_times: null,
- image_prompt_name: '',
- x_api_resource_id: Array.isArray(primaryModel?.x_api_resource_id) ? String(primaryModel.x_api_resource_id[0] || '') : '',
- model_version: String(primaryModel?.version || ''),
- icl_speaker_id: String(primaryModel?.icl_speaker_id || ''),
- occupied: !!timbre?.occupied,
- synthesized_audio_url: '',
- synthesized_work_id: '',
- last_synthesis_text: '',
- latest_audio_url: primaryModel?.demo_audio || '',
- message: clonePayload?.data?.tip || '音色复刻成功',
- request_id: clonePayload?.request_id || '',
- created_at: now,
- updated_at: now
- });
- return {
- clonePayload,
- profile
- };
- }
- function findVoiceProfileForSynthesis(payload) {
- const profiles = readDataFile('voice-profiles');
- if (payload.timbreId) {
- const byTimbreId = profiles.find((item) => item.timbre_id === payload.timbreId);
- if (byTimbreId) {
- return byTimbreId;
- }
- }
- if (payload.speakerId) {
- return profiles.find((item) => (
- String(item.icl_speaker_id || '').trim() === payload.speakerId
- || String(item.speaker_id || '').trim() === payload.speakerId
- )) || null;
- }
- return null;
- }
- function persistSynthesisProfile(profile, payload, synthesis) {
- if (!profile) {
- return null;
- }
- return upsertVoiceProfile({
- ...profile,
- synthesized_audio_url: synthesis.audioUrl,
- synthesized_work_id: synthesis.workId,
- last_synthesis_text: payload.text,
- last_synthesis_ssml: payload.ssml,
- last_synthesis_x_api_resource_id: payload.xApiResourceId || profile.x_api_resource_id || '',
- last_synthesis_model: payload.model,
- last_synthesis_format: payload.audioParams.format,
- last_synthesis_sample_rate: payload.audioParams.sampleRate,
- last_synthesis_speech_rate: payload.audioParams.speechRate,
- last_synthesis_loudness_rate: payload.audioParams.loudnessRate,
- last_synthesis_emotion: payload.audioParams.emotion,
- last_synthesis_emotion_scale: payload.audioParams.emotionScale,
- last_synthesis_enable_subtitle: !!payload.audioParams.enableSubtitle,
- last_synthesis_silence_duration: payload.additions?.silence_duration ?? null,
- last_synthesis_enable_language_detector: !!payload.additions?.enable_language_detector,
- last_synthesis_disable_markdown_filter: !!payload.additions?.disable_markdown_filter,
- last_synthesis_disable_emoji_filter: !!payload.additions?.disable_emoji_filter,
- last_synthesis_explicit_language: String(payload.additions?.explicit_language || '').trim(),
- latest_audio_url: synthesis.audioUrl || profile.latest_audio_url || profile.demo_audio || '',
- updated_at: new Date().toISOString()
- });
- }
- async function executeVoiceSynthesisRequest(payload) {
- if (!payload.token) {
- throw createHttpError('缺少 token 参数', 400);
- }
- if (!payload.text && !payload.ssml) {
- throw createHttpError('文本内容不能为空,text与ssml不能同时为空', 400);
- }
- const matchedProfile = findVoiceProfileForSynthesis(payload);
- if (payload.timbreId) {
- const proxyPayload = await requestVolcTtsJson('unidirectional', buildProxySynthesisPayload({
- ...payload,
- isStream: false
- }));
- if (Number(proxyPayload?.code) !== 200 || !proxyPayload?.data?.audioUrl) {
- throw createHttpError(proxyPayload?.error?.message || proxyPayload?.message || '语音合成失败', 502, proxyPayload || '');
- }
- const profile = persistSynthesisProfile(matchedProfile, payload, {
- audioUrl: proxyPayload.data.audioUrl,
- workId: proxyPayload.data.workId || ''
- });
- return {
- response: {
- ...proxyPayload,
- ...(profile ? { profile } : {})
- },
- profile
- };
- }
- if (!payload.speakerId) {
- throw createHttpError('音色id不能为空', 400);
- }
- const synthesis = await synthesizeVoiceAudio({
- token: payload.token,
- speaker: payload.speakerId,
- timbreId: matchedProfile?.timbre_id || '',
- text: payload.text,
- ssml: payload.ssml,
- xApiResourceId: payload.xApiResourceId || matchedProfile?.x_api_resource_id || '',
- model: payload.model,
- isStream: false,
- audioParams: payload.audioParams,
- additions: payload.additions
- });
- const profile = persistSynthesisProfile(matchedProfile, payload, synthesis);
- return {
- response: {
- code: 200,
- data: {
- workId: synthesis.workId,
- audioUrl: synthesis.audioUrl
- },
- ...(profile ? { profile } : {})
- },
- profile
- };
- }
- // 通用 JSON 数据文件读写
- app.post('/api/volcengine/tts/unidirectional', async (req, res) => {
- try {
- const payload = normalizeVoiceSynthesisPayload(req.body || {});
- const { response } = await executeVoiceSynthesisRequest(payload);
- res.json(response);
- } catch (error) {
- res.status(error.status || error.statusCode || 500).json({
- code: error.status || error.statusCode || 500,
- success: false,
- error: error.message || 'TTS 请求失败',
- detail: error.detail || '',
- });
- }
- });
- app.post('/api/volcengine/tts/voice_clone', async (req, res) => {
- try {
- const payload = normalizeVoiceClonePayload(req.body || {});
- const { response } = await executeVoiceCloneRequest(payload);
- res.json(response);
- } catch (error) {
- res.status(error.status || error.statusCode || 500).json({
- code: error.status || error.statusCode || 500,
- success: false,
- error: error.message || '音色复刻请求失败',
- detail: error.detail || '',
- });
- }
- });
- const LOCAL_JIMENG_ALLOWED_ENDPOINTS = new Set([
- 'getClothesV2',
- 'getImgByImg',
- 'getImgV4',
- 'getText2ImgV3',
- 'getText2ImgV31',
- 'getImg2ImgV3',
- 'getImgV4_pod',
- 'getImgV4_goods',
- 'getInpaint',
- 'getSuperResolution',
- 'getVideoV3_720p',
- 'getVideoV3_1080p',
- 'getVideoV3_Pro',
- 'getActor',
- 'getActorV2',
- 'getDataByTask02',
- 'getOhIdentifyMain',
- 'getOhDateByTask',
- 'getOhDetectMain',
- 'getOmniHuman',
- ]);
- const LOCAL_JIMENG_CHARGEABLE_ENDPOINTS = new Set([
- 'getImgV4',
- 'getText2ImgV3',
- 'getText2ImgV31',
- 'getImg2ImgV3',
- 'getImgV4_pod',
- 'getImgV4_goods',
- 'getInpaint',
- 'getSuperResolution',
- 'getVideoV3_720p',
- 'getVideoV3_1080p',
- 'getVideoV3_Pro',
- 'getActor',
- 'getActorV2',
- 'getOmniHuman',
- ]);
- app.post('/api/volcengine/jimeng/:endpoint', async (req, res) => {
- const endpoint = String(req.params.endpoint || '').trim();
- if (!LOCAL_JIMENG_ALLOWED_ENDPOINTS.has(endpoint)) {
- return res.status(400).json({ code: 400, success: false, error: '不支持的即梦接口' });
- }
- if (LOCAL_JIMENG_CHARGEABLE_ENDPOINTS.has(endpoint)) {
- const sessionToken = String(req.headers['x-parse-session-token'] || req.body?.sessionToken || '').trim();
- if (!sessionToken) {
- return res.status(401).json({
- code: 401,
- success: false,
- error: 'Jimeng generation requires a logged-in Parse session for APIG billing.',
- });
- }
- return res.status(409).json({
- code: 409,
- success: false,
- error: 'Chargeable Jimeng generation must go through cloud-functions/11-jimengManager.js for APIG billing.',
- });
- }
- try {
- const token = runtimeConfig.normalizeBearerToken(
- req.body?.token
- || process.env.JIMENG_TOKEN
- || process.env.VOLC_JIMENG_TOKEN
- || process.env.VOICE_TOKEN
- );
- if (!token) {
- return res.status(400).json({ code: 400, success: false, error: '未配置 JIMENG_TOKEN、VOLC_JIMENG_TOKEN 或 VOICE_TOKEN' });
- }
- const baseUrl = String(process.env.JIMENG_BASE_URL || 'https://server.fmode.cn/api/volcengine/jimeng').replace(/\/+$/, '');
- const payload = stripEmpty({ ...(req.body || {}), token });
- const data = await requestJimengGatewayWithRetry(`${baseUrl}/${endpoint}`, payload, endpoint);
- res.json(data);
- } catch (error) {
- res.status(error.status || 500).json({
- code: error.status || 500,
- success: false,
- error: error.message || '即梦请求失败',
- detail: error.detail || '',
- });
- }
- });
- app.get('/api/volcengine/jimeng/work/:workId', async (req, res) => {
- try {
- const workId = String(req.params.workId || '').trim();
- if (!workId) return res.status(400).json({ code: 400, success: false, error: '缺少作品 ID' });
- const response = await fetch(`${runtimeConfig.parseApiHost}/parse/classes/ImagineWork/${encodeURIComponent(workId)}`, {
- headers: { 'X-Parse-Application-Id': runtimeConfig.parseAppId },
- });
- const { rawText, payload } = await readFetchResponse(response);
- if (!response.ok) {
- return res.status(response.status).json({
- code: response.status,
- success: false,
- error: payload?.error || rawText || '查询作品结果失败',
- });
- }
- res.json({ code: 200, success: true, data: payload });
- } catch (error) {
- res.status(500).json({ code: 500, success: false, error: error.message || '查询作品结果失败' });
- }
- });
- async function requestJimengGatewayWithRetry(url, body, endpoint) {
- let lastError = null;
- const maxAttempts = Math.max(1, Number(process.env.JIMENG_LOCAL_MAX_ATTEMPTS || 3));
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
- try {
- const response = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- const { rawText, payload } = await readFetchResponse(response);
- const status = Number(payload?.code || response.status || 0);
- const retryable = !response.ok || status === 408 || status === 429 || status >= 500;
- if (!retryable || attempt >= maxAttempts) {
- if (!response.ok) {
- const error = new Error(payload?.error?.message || payload?.message || payload?.error || rawText || `HTTP ${response.status}`);
- error.status = response.status;
- error.detail = payload || rawText || '';
- throw error;
- }
- return payload || { code: 500, success: false, error: '即梦返回异常' };
- }
- await sleep(Math.min(12000, 1200 * attempt * attempt));
- } catch (error) {
- lastError = error;
- if (attempt >= maxAttempts) break;
- await sleep(Math.min(12000, 1200 * attempt * attempt));
- }
- }
- const message = formatNetworkError(lastError);
- const error = new Error(`即梦数据上游请求失败:${message};endpoint=${endpoint}`);
- error.status = lastError?.status || 500;
- error.detail = lastError?.detail || '';
- throw error;
- }
- function readDataFile(name) {
- const p = path.join(DATA_DIR, `${name}.json`);
- if (!fs.existsSync(p)) { fs.writeFileSync(p, '[]', 'utf-8'); return []; }
- return JSON.parse(fs.readFileSync(p, 'utf-8'));
- }
- function writeDataFile(name, data) {
- fs.writeFileSync(path.join(DATA_DIR, `${name}.json`), JSON.stringify(data, null, 2), 'utf-8');
- }
- function isSafeRemoteUrl(url) {
- try {
- const parsed = new URL(url);
- return ['http:', 'https:'].includes(parsed.protocol);
- } catch {
- return false;
- }
- }
- function sanitizeFilename(filename) {
- const safeName = String(filename || '')
- .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
- .trim();
- return safeName || `video-${Date.now()}.mp4`;
- }
- function ensureVideoFilename(filename, sourceUrl = '') {
- const safeName = sanitizeFilename(filename);
- if (path.extname(safeName)) {
- return safeName;
- }
- try {
- const parsed = new URL(sourceUrl);
- const sourceExt = path.extname(parsed.pathname || '').toLowerCase();
- if (sourceExt) {
- return `${safeName}${sourceExt}`;
- }
- } catch {}
- return `${safeName}.mp4`;
- }
- function ensureUniqueVideoFilename(filename) {
- const ext = path.extname(filename) || '.mp4';
- const baseName = path.basename(filename, ext);
- let candidate = filename;
- let counter = 1;
- while (fs.existsSync(path.join(DATA_VIDEO_DIR, candidate)) || fs.existsSync(path.join(LEGACY_VIDEO_DIR, candidate))) {
- candidate = `${baseName}-${Date.now()}-${counter}${ext}`;
- counter += 1;
- }
- return candidate;
- }
- function createManagedVideoEntry({ filename, title, description, tags, thumbnail, duration, resolution, awemeId, authorName, size }) {
- const ext = path.extname(filename).replace('.', '').toLowerCase() || 'mp4';
- const now = new Date().toISOString();
- return {
- id: `VID-${Date.now()}`,
- title: title || path.basename(filename, path.extname(filename)),
- filename,
- size: size || 0,
- duration: Number(duration) || 0,
- created_at: now,
- modified_at: now,
- category: 'downloaded',
- tags: Array.isArray(tags) ? tags : [],
- description: description || '',
- thumbnail: thumbnail || '',
- source: 'downloaded',
- aweme_id: awemeId || '',
- metadata: {
- resolution: resolution || '未知',
- format: ext,
- authorName: authorName || ''
- }
- };
- }
- function normalizeRemoteUrls(primaryUrl, urls = []) {
- return [primaryUrl, ...(Array.isArray(urls) ? urls : [])].filter((url, index, list) => (
- typeof url === 'string'
- && isSafeRemoteUrl(url)
- && list.indexOf(url) === index
- ));
- }
- async function fetchRemoteVideoResponse(urls, requestHeaders = {}) {
- let lastError = null;
- for (const currentUrl of urls) {
- try {
- const response = await fetch(currentUrl, {
- method: 'GET',
- headers: requestHeaders,
- redirect: 'follow'
- });
- if (!response.ok) {
- const detail = await response.text().catch(() => '');
- lastError = new Error(`远程下载失败: ${response.status} ${response.statusText}${detail ? ` ${detail.slice(0, 200)}` : ''}`);
- continue;
- }
- if (!response.body) {
- lastError = new Error('远程响应缺少视频流');
- continue;
- }
- return { url: currentUrl, response };
- } catch (error) {
- lastError = error;
- }
- }
- throw lastError || new Error('没有可用的远程视频地址');
- }
- // ==================== Whisper 转录 ====================
- registerWhisperRoutes(app, {
- exec,
- fs,
- path,
- projectRoot: PROJECT_ROOT,
- whisperDir: WHISPER_DIR,
- readManifest,
- writeManifest,
- resolveVideoPath
- });
- // ==================== Manifest 管理 ====================
- // 已迁移至云函数 manifestManager(cloud-functions/01-manifestManager.js);历史本地参考见 server/routes/manifest-legacy-reference.js。
- // ==================== 视频上传 ====================
- registerUploadRoutes(app, { upload, readManifest, writeManifest });
- // 上传错误处理
- app.use((err, req, res, next) => {
- if (err instanceof multer.MulterError) {
- if (err.code === 'LIMIT_FILE_SIZE') {
- return res.status(413).json({ error: '文件大小超过限制(最大 500MB)' });
- }
- return res.status(400).json({ error: `上传错误: ${err.message}` });
- }
- if (err) {
- return res.status(400).json({ error: err.message });
- }
- next();
- });
- registerAudioExtractionRoutes(app, {
- readManifest,
- resolveVideoPath,
- removeFileIfExists,
- dataRemixAssetDir: DATA_REMIX_ASSET_DIR,
- projectRoot: PROJECT_ROOT
- });
- registerRemixAssetUploadRoutes(app, {
- path,
- assetUpload,
- qiniuUploadUrl: QINIU_UPLOAD_URL,
- qiniuCdnDomain: QINIU_CDN_DOMAIN,
- buildDigitalHumanAssetKey,
- buildQiniuUploadToken
- });
- // ==================== 文件操作 ====================
- registerFileRoutes(app, {
- fs,
- path,
- projectRoot: PROJECT_ROOT,
- whisperDir: WHISPER_DIR,
- readManifest
- });
- registerVideoDownloadRoutes(app, {
- path,
- fs,
- crypto,
- Readable,
- Transform,
- pipeline,
- dataVideoDir: DATA_VIDEO_DIR,
- downloadTasks,
- normalizeRemoteUrls,
- ensureVideoFilename,
- ensureUniqueVideoFilename,
- fetchRemoteVideoResponse,
- createManagedVideoEntry,
- readManifest,
- writeManifest,
- removeFileIfExists
- });
- // ==================== 视频流代理 ====================
- registerVideoProxyRoutes(app, { isSafeRemoteUrl });
- // ==================== AI 重塑记录 ====================
- // ==================== AI 重塑记录 ====================
- // 已迁移至云函数 remixManager(cloud-functions/05-remixManager.js);历史本地参考见 server/routes/cloud-managed-legacy-reference.js。
- // ==================== 一键成片(Quickly 代理) ====================
- registerQuicklyRoutes(app, runtimeConfig.quickly);
- // ==================== 视频合成(图片+音频 → 视频)====================
- registerVideoCompositeRoutes(app, {
- fs,
- path,
- spawn,
- dataDir: DATA_DIR,
- removeFileIfExists
- });
- // ==================== 语音管理 ====================
- // 已迁移至云函数 voiceManager(cloud-functions/06-voiceManager.js);历史本地参考见 server/routes/cloud-managed-legacy-reference.js。
- // ==================== 任务/历史/结果 ====================
- // 已迁移至云函数 taskManager/historyManager/resultManager;历史本地参考见 server/routes/cloud-managed-legacy-reference.js。
- // ==================== LLM 大模型代理 ====================
- registerLlmRoutes(app, {
- llmBaseUrl: runtimeConfig.llm.baseUrl,
- llmApiKey: runtimeConfig.llm.apiKey
- });
- // ==================== Douyin transcript worker ====================
- function cleanText(value) {
- return String(value || '').trim();
- }
- function getVocToken() {
- return cleanText(
- process.env.DOUYIN_API_TOKEN
- || process.env.VOC_TOKEN
- || process.env.TRANSCRIPTION_VOC_TOKEN
- || process.env.VOICE_TOKEN
- || process.env.OPENCLAW_VOC_TOKEN
- || process.env.VOC_SOCIAL_TOKEN
- );
- }
- function bearerAuth(token) {
- const value = cleanText(token);
- return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
- }
- function stripEmpty(value) {
- if (!value || typeof value !== 'object') return value;
- const out = Array.isArray(value) ? [] : {};
- for (const [key, val] of Object.entries(value)) {
- if (val === undefined || val === null || val === '') continue;
- if (val && typeof val === 'object' && !Array.isArray(val)) {
- const nested = stripEmpty(val);
- if (Object.keys(nested).length) out[key] = nested;
- } else {
- out[key] = val;
- }
- }
- return out;
- }
- function readGatewayError(data, fallback) {
- return data?.error?.message || data?.error || data?.mess || data?.message || data?.msg || data?.detail || fallback || '';
- }
- function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
- function formatNetworkError(error) {
- const cause = error?.cause?.code || error?.cause?.message || '';
- return `${error?.message || 'fetch failed'}${cause ? `;cause=${cause}` : ''}`;
- }
- async function fetchDouyinGatewayWithRetry(url, init, routePath) {
- let lastError = null;
- const attemptLimit = /fetch_video_comments|fetch_video_comment_replies/.test(routePath)
- ? Math.max(DOUYIN_GATEWAY_MAX_ATTEMPTS, 6)
- : DOUYIN_GATEWAY_MAX_ATTEMPTS;
- for (let attempt = 1; attempt <= attemptLimit; attempt += 1) {
- try {
- const response = await fetch(url, init);
- if (response.ok || response.status < 500 || attempt >= attemptLimit) {
- return response;
- }
- console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${attemptLimit} returned HTTP ${response.status}`);
- await response.body?.cancel().catch(() => {});
- } catch (error) {
- lastError = error;
- const reason = formatNetworkError(error);
- console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${attemptLimit} failed: ${reason}`);
- }
- if (attempt < attemptLimit) {
- const delayMs = /fetch_video_comments|fetch_video_comment_replies/.test(routePath)
- ? Math.min(700 * attempt, 3000)
- : Math.min(300 * attempt, 1200);
- await sleep(delayMs);
- }
- }
- throw lastError || new Error('fetch failed');
- }
- async function requestDouyinGateway(routeName, params = {}, payload = {}) {
- const route = DOUYIN_API_ROUTES[routeName];
- if (!route) {
- throw Object.assign(new Error('不支持的抖音接口'), { statusCode: 400 });
- }
- const token = getVocToken();
- if (!token) {
- throw Object.assign(new Error('本地抖音数据网关未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN。'), { statusCode: 400 });
- }
- const url = new URL(`${DOUYIN_API_BASE_URL}${route.path}`);
- const init = {
- method: route.method,
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- Authorization: bearerAuth(token),
- },
- };
- if (route.method === 'GET') {
- const query = { ...(payload || {}), ...(params || {}) };
- if (routeName === 'comments' || routeName === 'replies') {
- query.count = Math.min(Math.max(Number(query.count) || 20, 1), 20);
- }
- for (const [key, value] of Object.entries(query)) {
- if (value !== undefined && value !== null && value !== '') {
- url.searchParams.set(key, String(value));
- }
- }
- } else {
- init.body = JSON.stringify(stripEmpty(payload || {}));
- }
- let response;
- try {
- response = await fetchDouyinGatewayWithRetry(url.toString(), init, route.path);
- } catch (error) {
- throw Object.assign(new Error(`本地抖音数据网关网络请求失败:${formatNetworkError(error)};attempts=${DOUYIN_GATEWAY_MAX_ATTEMPTS};base=${DOUYIN_API_BASE_URL} route=${route.path}`), {
- statusCode: 502,
- });
- }
- const text = await response.text();
- let data = null;
- try { data = text ? JSON.parse(text) : null; } catch {}
- if (!response.ok || data?.success === false) {
- const upstreamError = readGatewayError(data, text) || '上游未返回错误详情';
- throw Object.assign(new Error(`抖音数据接口请求失败:${upstreamError};HTTP ${response.status};base=${DOUYIN_API_BASE_URL} route=${route.path}`), {
- statusCode: response.status || 500,
- });
- }
- return data || { code: 500, success: false, error: '服务返回异常' };
- }
- app.post('/api/douyin/call', async (req, res) => {
- try {
- const { route, params = {}, payload = {}, optional = false } = req.body || {};
- const data = await requestDouyinGateway(route, params, payload);
- res.json({ success: true, data });
- } catch (error) {
- if (req.body?.optional) {
- return res.json({
- success: false,
- optional: true,
- error: error.message || '本地抖音数据网关调用失败',
- });
- }
- res.status(error.statusCode || 500).json({
- success: false,
- error: error.message || '本地抖音数据网关调用失败',
- });
- }
- });
- app.get('/api/douyin/diagnose', async (req, res) => {
- const routeName = cleanText(req.query.route || 'videoDetail');
- const route = DOUYIN_API_ROUTES[routeName] || DOUYIN_API_ROUTES.videoDetail;
- const result = {
- success: true,
- data: {
- baseUrl: DOUYIN_API_BASE_URL,
- routeName,
- routePath: route.path,
- tokenConfigured: !!getVocToken(),
- probe: null,
- },
- };
- if (String(req.query.probe || '') === '1') {
- try {
- result.data.probe = await requestDouyinGateway(routeName, { aweme_id: cleanText(req.query.awemeId) || '7592116912205630761' });
- } catch (error) {
- result.data.probe = { success: false, error: error.message || '探测失败' };
- }
- }
- res.json(result);
- });
- function createTranscriptJob(input) {
- const now = new Date().toISOString();
- return {
- id: `transcript_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
- awemeId: cleanText(input.awemeId),
- analysisId: cleanText(input.analysisId),
- provider: cleanText(input.provider) || 'iflytek-gateway',
- status: 'queued',
- stageLabel: '已创建转写任务',
- progress: 0,
- warnings: [],
- createdAt: now,
- updatedAt: now
- };
- }
- function updateTranscriptJob(job, patch) {
- Object.assign(job, patch, { updatedAt: new Date().toISOString() });
- transcriptTasks.set(job.id, job);
- return job;
- }
- function findAwemeDetail(node, depth = 0) {
- if (!node || depth > 8) return null;
- if (Array.isArray(node)) {
- for (const item of node) {
- const found = findAwemeDetail(item, depth + 1);
- if (found) return found;
- }
- return null;
- }
- if (typeof node !== 'object') return null;
- if (node.aweme_id && node.video) return node;
- if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail;
- if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info;
- for (const value of Object.values(node)) {
- const found = findAwemeDetail(value, depth + 1);
- if (found) return found;
- }
- return null;
- }
- function extractAwemeId(value) {
- const text = cleanText(value);
- const patterns = [/aweme_id=(\d+)/, /modal_id=(\d+)/, /douyin\.com\/video\/(\d+)/, /douyin\.com\/share\/video\/(\d+)/, /\b(\d{15,25})\b/];
- for (const pattern of patterns) {
- const match = text.match(pattern);
- if (match?.[1]) return match[1];
- }
- return /^\d{15,25}$/.test(text) ? text : '';
- }
- function decodeMaybeBase64Url(value) {
- const text = cleanText(value);
- if (/^https?:\/\//i.test(text)) return text;
- if (!/^[A-Za-z0-9+/=_-]{20,}$/.test(text)) return '';
- try {
- const decoded = Buffer.from(text, 'base64').toString('utf8');
- return /^https?:\/\//i.test(decoded) ? decoded : '';
- } catch {
- return '';
- }
- }
- function isLikelyMediaUrl(url) {
- return /^https?:\/\//i.test(url) && !/\.(?:jpg|jpeg|png|webp|gif)(?:\?|$)/i.test(url);
- }
- function inferMediaKind(pathParts, url) {
- const joined = pathParts.join('.').toLowerCase();
- if (/audio|mp4a|music|sound/.test(joined) || /audio|mp4a/i.test(url)) return 'audio';
- if (/play_addr|download_addr|bit_rate|video|media/.test(joined) || isLikelyMediaUrl(url)) return 'video';
- return 'unknown';
- }
- function collectMediaCandidates(node, pathParts = [], out = []) {
- if (!node) return out;
- if (Array.isArray(node)) {
- node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out));
- return out;
- }
- if (typeof node !== 'object') return out;
- for (const [key, value] of Object.entries(node)) {
- const nextPath = [...pathParts, key];
- if (key === 'url_list' && Array.isArray(value)) {
- value.forEach((item, index) => {
- const url = decodeMaybeBase64Url(item);
- const kind = inferMediaKind(nextPath, url);
- if (url && ['audio', 'video'].includes(kind)) {
- out.push({
- url,
- kind,
- keyPath: nextPath.join('.'),
- index,
- dataSize: Number(node.data_size || node.size || 0),
- bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
- });
- }
- });
- } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') {
- const url = decodeMaybeBase64Url(value);
- const kind = inferMediaKind(nextPath, url);
- if (url && ['audio', 'video'].includes(kind)) {
- out.push({
- url,
- kind,
- keyPath: nextPath.join('.'),
- dataSize: Number(node.data_size || node.size || 0),
- bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
- });
- }
- }
- collectMediaCandidates(value, nextPath, out);
- }
- return out;
- }
- function selectMediaCandidate(detail) {
- const seen = new Set();
- const candidates = collectMediaCandidates(detail)
- .filter(item => isLikelyMediaUrl(item.url))
- .filter(item => {
- if (seen.has(item.url)) return false;
- seen.add(item.url);
- return true;
- });
- candidates.sort((a, b) => {
- const aPreferred = a.kind === 'audio' ? 0 : 1;
- const bPreferred = b.kind === 'audio' ? 0 : 1;
- if (aPreferred !== bPreferred) return aPreferred - bPreferred;
- const rank = item => {
- const keyPath = String(item.keyPath || '').toLowerCase();
- const url = String(item.url || '').toLowerCase();
- if (/strong_beat|beats_tr|music\./.test(`${keyPath} ${url}`)) return 5;
- if (item.kind === 'audio' && keyPath.includes('video.dynamic_audio_list')) return 0;
- if (item.kind === 'audio' && keyPath.includes('video.bit_rate_audio')) return 1;
- if (item.kind === 'audio' && keyPath.includes('music.')) return 4;
- return 2;
- };
- const aRank = rank(a);
- const bRank = rank(b);
- if (aRank !== bRank) return aRank - bRank;
- return Number(b.dataSize || 0) - Number(a.dataSize || 0);
- });
- return candidates[0] || null;
- }
- function durationFromDetail(detail) {
- const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs;
- const numeric = Number(raw || 0);
- if (!Number.isFinite(numeric) || numeric <= 0) return 0;
- return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
- }
- function extensionFromUrl(url, fallback) {
- try {
- const parsed = new URL(url);
- const ext = path.extname(parsed.pathname).toLowerCase();
- if (/^\.(mp4|m4a|mp3|wav|aac|mov|webm)$/i.test(ext)) return ext;
- } catch {}
- return fallback;
- }
- async function fetchTranscriptUpstream(url, init, timeoutMs, label) {
- try {
- return await fetch(url, {
- ...init,
- signal: AbortSignal.timeout(timeoutMs)
- });
- } catch (error) {
- throw new Error(`${label}失败:${formatNetworkError(error)}`);
- }
- }
- async function fetchDouyinDetailForTranscript(input) {
- const supplied = findAwemeDetail(input.detail);
- if (supplied?.aweme_id && supplied.video) return supplied;
- const token = getVocToken();
- if (!token) {
- throw Object.assign(new Error('未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN,无法获取抖音视频详情。'), {
- status: 'needs_provider_config'
- });
- }
- const awemeId = cleanText(input.awemeId) || extractAwemeId(input.sourceUrl || input.url);
- if (!awemeId) {
- throw Object.assign(new Error('缺少 awemeId,无法获取视频详情。'), { status: 'needs_media' });
- }
- const url = new URL(`${DOUYIN_API_BASE_URL}/douyin/app/v3/fetch_one_video_v3`);
- url.searchParams.set('aweme_id', awemeId);
- const response = await fetchTranscriptUpstream(url.toString(), {
- method: 'GET',
- headers: {
- Accept: 'application/json',
- Authorization: bearerAuth(token)
- }
- }, 45000, '获取抖音视频详情');
- const data = await response.json().catch(() => ({}));
- if (!response.ok || data.success === false) {
- throw new Error(data.error?.message || data.error || data.message || data.mess || `抖音详情获取失败 HTTP ${response.status}`);
- }
- const detail = findAwemeDetail(data);
- if (!detail?.aweme_id) {
- throw Object.assign(new Error('抖音详情响应中未找到 aweme_detail。'), { status: 'needs_media' });
- }
- return detail;
- }
- async function downloadTranscriptMedia(candidate, job) {
- fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
- const ext = extensionFromUrl(candidate.url, candidate.kind === 'audio' ? '.m4a' : '.mp4');
- const filePath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}-source${ext}`);
- const { response } = await fetchRemoteVideoResponse([candidate.url], {
- Accept: '*/*',
- 'User-Agent': 'Mozilla/5.0',
- Referer: 'https://www.douyin.com/',
- Origin: 'https://www.douyin.com'
- });
- await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath));
- return filePath;
- }
- function extractAudioForTranscript(inputPath, job) {
- return new Promise((resolve, reject) => {
- fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
- const outputPath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}.m4a`);
- const args = ['-y', '-i', inputPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath];
- const proc = spawn('ffmpeg', args, { cwd: PROJECT_ROOT });
- let stderr = '';
- proc.stderr.on('data', data => { stderr += data.toString(); });
- proc.on('error', err => {
- reject(Object.assign(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并加入 PATH。' : err.message), {
- status: 'needs_media_processing'
- }));
- });
- proc.on('close', code => {
- if (code !== 0 || !fs.existsSync(outputPath)) {
- const lastLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
- reject(Object.assign(new Error(`音频提取失败${lastLine ? `:${lastLine}` : ''}`), {
- status: 'needs_media_processing'
- }));
- return;
- }
- resolve(outputPath);
- });
- });
- }
- async function uploadTranscriptGateway(filePath, durationMs) {
- const token = getVocToken();
- if (!token) {
- throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。'), {
- status: 'needs_provider_config'
- });
- }
- if (!durationMs) {
- throw Object.assign(new Error('缺少音频时长 durationMs,无法提交转写网关。'), { status: 'needs_media' });
- }
- const buffer = fs.readFileSync(filePath);
- const form = new FormData();
- form.append('audio', new Blob([buffer], { type: 'audio/mp4' }), path.basename(filePath));
- form.append('durationMs', String(durationMs));
- form.append('roleType', '1');
- form.append('roleNum', '0');
- const response = await fetchTranscriptUpstream(`${TRANSCRIPTION_GATEWAY_BASE_URL}/upload`, {
- method: 'POST',
- headers: {
- Authorization: bearerAuth(token),
- Accept: 'application/json'
- },
- body: form
- }, 120000, '提交转写任务');
- const data = await response.json().catch(() => ({}));
- if (!response.ok || data.success === false) {
- throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${response.status}`);
- }
- const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
- if (!orderId) throw new Error('转写网关未返回 orderId。');
- return {
- orderId,
- estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0)
- };
- }
- async function queryTranscriptGateway(orderId) {
- const token = getVocToken();
- if (!token) {
- throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'), {
- status: 'needs_provider_config'
- });
- }
- const response = await fetchTranscriptUpstream(`${TRANSCRIPTION_GATEWAY_BASE_URL}/result`, {
- method: 'POST',
- headers: {
- Authorization: bearerAuth(token),
- Accept: 'application/json',
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ orderId })
- }, 45000, '查询转写结果');
- const data = await response.json().catch(() => ({}));
- if (!response.ok) {
- throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${response.status}`);
- }
- return data;
- }
- function gatewayValue(data, key) {
- return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
- }
- function normalizeSegmentTime(value) {
- const number = Number(value);
- if (!Number.isFinite(number)) return null;
- return number > 1000 ? number / 1000 : number;
- }
- function normalizeTranscriptSegments(segments) {
- return (Array.isArray(segments) ? segments : []).map(segment => ({
- start: normalizeSegmentTime(segment.start ?? segment.begin ?? segment.bg),
- end: normalizeSegmentTime(segment.end ?? segment.ed),
- text: cleanText(segment.text || segment.onebest || segment.content)
- })).filter(segment => segment.text);
- }
- async function pollTranscriptJob(job) {
- if (!job.orderId || job.status === 'completed' || job.status === 'failed') return job;
- const data = await queryTranscriptGateway(job.orderId);
- const status = cleanText(gatewayValue(data, 'status')).toLowerCase();
- const text = cleanText(gatewayValue(data, 'text'));
- const segments = normalizeTranscriptSegments(gatewayValue(data, 'segments'));
- if (status === 'completed' || text || segments.length) {
- return updateTranscriptJob(job, {
- status: 'completed',
- stageLabel: '转写完成',
- progress: 100,
- text: text || segments.map(segment => segment.text).join('\n'),
- segments
- });
- }
- if (status === 'failed' || status === 'error') {
- return updateTranscriptJob(job, {
- status: 'failed',
- stageLabel: '转写失败',
- errorMessage: gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写网关返回失败'
- });
- }
- return updateTranscriptJob(job, {
- status: 'polling_provider',
- stageLabel: '转写处理中',
- progress: Math.max(Number(job.progress || 0), 85),
- warnings: [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])]
- });
- }
- async function runTranscriptJob(job, input) {
- try {
- updateTranscriptJob(job, { status: 'resolving_detail', stageLabel: '正在获取视频详情', progress: 10 });
- const detail = await fetchDouyinDetailForTranscript(input);
- const awemeId = cleanText(detail.aweme_id || job.awemeId);
- if (awemeId && awemeId !== job.awemeId) updateTranscriptJob(job, { awemeId });
- updateTranscriptJob(job, { status: 'selecting_media', stageLabel: '正在选择可转写媒体', progress: 25 });
- const suppliedMediaUrl = cleanText(input.mediaUrl);
- const candidate = suppliedMediaUrl
- ? { url: suppliedMediaUrl, kind: /audio|m4a|mp3|aac/i.test(suppliedMediaUrl) ? 'audio' : 'video', keyPath: 'input.mediaUrl' }
- : selectMediaCandidate(detail);
- if (!candidate?.url) {
- updateTranscriptJob(job, {
- status: 'needs_media',
- stageLabel: '未找到可转写媒体',
- progress: 25,
- warnings: [...(job.warnings || []), '视频详情中未找到音频或视频下载地址。']
- });
- return;
- }
- updateTranscriptJob(job, {
- status: 'downloading_media',
- stageLabel: '正在下载媒体',
- progress: 45,
- mediaUrl: candidate.url,
- sourceKind: candidate.kind === 'audio' ? 'douyin_audio' : 'douyin_video'
- });
- const sourcePath = await downloadTranscriptMedia(candidate, job);
- updateTranscriptJob(job, { localVideoPath: sourcePath });
- const durationMs = Number(input.durationMs || durationFromDetail(detail) || 0);
- let audioPath = sourcePath;
- if (candidate.kind !== 'audio') {
- updateTranscriptJob(job, { status: 'extracting_audio', stageLabel: '正在提取音频', progress: 65 });
- audioPath = await extractAudioForTranscript(sourcePath, job);
- }
- updateTranscriptJob(job, {
- status: 'submitting_provider',
- stageLabel: '正在提交转写网关',
- progress: 80,
- localAudioPath: audioPath,
- durationMs
- });
- const uploaded = await uploadTranscriptGateway(audioPath, durationMs);
- updateTranscriptJob(job, {
- status: 'polling_provider',
- stageLabel: '转写任务已提交',
- progress: 85,
- orderId: uploaded.orderId,
- estimateTime: uploaded.estimateTime,
- warnings: [...(job.warnings || []), `已提交转写任务:${uploaded.orderId}`]
- });
- } catch (error) {
- updateTranscriptJob(job, {
- status: error.status || 'failed',
- stageLabel: '转写任务失败',
- errorMessage: error.message || '转写任务失败',
- warnings: [...(job.warnings || []), error.message || '转写任务失败']
- });
- }
- }
- app.post('/api/douyin/transcript/start', (req, res) => {
- const input = req.body || {};
- if (!cleanText(input.awemeId) && !cleanText(input.sourceUrl) && !cleanText(input.mediaUrl)) {
- return res.status(400).json({ success: false, error: '缺少 awemeId、sourceUrl 或 mediaUrl,无法创建转写任务。' });
- }
- const job = createTranscriptJob(input);
- updateTranscriptJob(job, {
- status: 'resolving_detail',
- stageLabel: '正在获取视频详情',
- progress: 5
- });
- res.json({ success: true, job: { ...job } });
- setImmediate(() => { void runTranscriptJob(job, input); });
- });
- app.get('/api/douyin/transcript/:jobId', async (req, res) => {
- const job = transcriptTasks.get(req.params.jobId);
- if (!job) {
- return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
- }
- try {
- if (job.status === 'polling_provider' && job.orderId) {
- await pollTranscriptJob(job);
- }
- res.json({ success: true, job: { ...job } });
- } catch (error) {
- updateTranscriptJob(job, {
- status: error.status || 'failed',
- stageLabel: '查询转写结果失败',
- errorMessage: error.message || '查询转写结果失败',
- warnings: [...(job.warnings || []), error.message || '查询转写结果失败']
- });
- res.json({ success: true, job: { ...job } });
- }
- });
- app.post('/api/douyin/transcript/:jobId/retry', (req, res) => {
- const previous = transcriptTasks.get(req.params.jobId);
- if (!previous) {
- return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
- }
- const body = req.body || {};
- const input = {
- ...body,
- awemeId: body.awemeId || previous.awemeId,
- analysisId: body.analysisId || previous.analysisId,
- mediaUrl: body.mediaUrl || previous.mediaUrl
- };
- const job = createTranscriptJob(input);
- updateTranscriptJob(job, {
- status: 'resolving_detail',
- stageLabel: '正在获取视频详情',
- progress: 5
- });
- res.json({ success: true, job: { ...job } });
- setImmediate(() => { void runTranscriptJob(job, input); });
- });
- // ==================== 健康检查 ====================
- registerHealthRoutes(app, {
- fs,
- projectRoot: PROJECT_ROOT,
- manifestPath: MANIFEST_PATH,
- whisperDir: WHISPER_DIR,
- dataVideoDir: DATA_VIDEO_DIR,
- legacyVideoDir: LEGACY_VIDEO_DIR,
- transcriptTempDir: TRANSCRIPT_TEMP_DIR,
- transcriptTasks,
- douyinApiBaseUrl: DOUYIN_API_BASE_URL,
- transcriptionGatewayBaseUrl: TRANSCRIPTION_GATEWAY_BASE_URL,
- douyinGatewayMaxAttempts: DOUYIN_GATEWAY_MAX_ATTEMPTS,
- getVocToken
- });
- // ==================== 启动 ====================
- app.listen(PORT, () => {
- printStartupLog({ port: PORT, projectRoot: PROJECT_ROOT });
- });
|