Skip to content

Commit dc0e923

Browse files
aixierclaude
andcommitted
feat: 添加视频拆解合成功能
- 新增视频拆解API接口,支持根据字幕拆解视频片段 - 实现视频片段合成功能,可生成带字幕的完整合成视频 - 添加ffmpeg集成,支持视频处理和字幕嵌入 - 集成OSS存储,自动上传处理结果 - 支持任务进度跟踪和状态管理 - 添加createCompilation参数控制是否生成合成视频 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f676e8e commit dc0e923

10 files changed

Lines changed: 1281 additions & 15 deletions

File tree

terminal-backend/ffmpeg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/usr/bin/ffmpeg
Lines changed: 164 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,145 @@
1+
import fs from 'fs'
2+
import path from 'path'
13
import dotenv from 'dotenv'
4+
25
dotenv.config()
36

7+
const toNumber = (value, fallback) => {
8+
const parsed = parseInt(value, 10)
9+
return Number.isNaN(parsed) ? fallback : parsed
10+
}
11+
12+
const parseArgs = value => {
13+
if (!value) return null
14+
return value
15+
.split(/\s+/)
16+
.map(item => item.trim())
17+
.filter(Boolean)
18+
}
19+
20+
const parseOptional = value => (value && value.trim()) || null
21+
22+
const resolvePath = (value, fallback) => {
23+
if (!value) return fallback
24+
return path.isAbsolute(value) ? value : path.join(process.cwd(), value)
25+
}
26+
27+
const ensureDir = dir => {
28+
try {
29+
fs.mkdirSync(dir, { recursive: true })
30+
} catch (error) {
31+
console.warn(`[config] Could not ensure directory ${dir}: ${error.message}`)
32+
}
33+
}
34+
35+
const workspaceRoot = resolvePath(
36+
process.env.YOUTUBE2POST_WORKSPACE_ROOT,
37+
path.join(process.cwd(), 'terminal-backend/data/users/default/workspace/card')
38+
)
39+
const tempRoot = resolvePath(
40+
process.env.YOUTUBE2POST_TEMP_ROOT,
41+
path.join(process.cwd(), 'terminal-backend/data/tmp/youtube2post')
42+
)
43+
const dataRoot = resolvePath(
44+
process.env.YOUTUBE2POST_DATA_ROOT,
45+
path.join(process.cwd(), 'terminal-backend/data/youtube2post')
46+
)
47+
const templateRoot = resolvePath(
48+
process.env.YOUTUBE2POST_TEMPLATE_ROOT,
49+
path.join(process.cwd(), 'terminal-backend/data/public_template/youtube2post')
50+
)
51+
52+
ensureDir(workspaceRoot)
53+
ensureDir(tempRoot)
54+
ensureDir(dataRoot)
55+
56+
const videoDecomposeWorkspaceRoot = resolvePath(
57+
process.env.VIDEO_DECOMPOSE_WORKSPACE_ROOT,
58+
path.join(process.cwd(), 'terminal-backend/data/users/default/workspace/video-decompose')
59+
)
60+
const videoDecomposeTempRoot = resolvePath(
61+
process.env.VIDEO_DECOMPOSE_TEMP_ROOT,
62+
path.join(process.cwd(), 'terminal-backend/data/tmp/video-decompose')
63+
)
64+
const videoDecomposeOutputRoot = resolvePath(
65+
process.env.VIDEO_DECOMPOSE_OUTPUT_ROOT,
66+
path.join(process.cwd(), 'terminal-backend/data/video-decompose')
67+
)
68+
69+
ensureDir(videoDecomposeWorkspaceRoot)
70+
ensureDir(videoDecomposeTempRoot)
71+
ensureDir(videoDecomposeOutputRoot)
72+
73+
const youtube2postConfig = {
74+
workerEnabled: process.env.YOUTUBE2POST_WORKER_ENABLED !== 'false',
75+
maxConcurrentTasks: toNumber(process.env.YOUTUBE2POST_MAX_CONCURRENT, 1),
76+
maxPendingTasks: toNumber(process.env.YOUTUBE2POST_MAX_PENDING, 20),
77+
pollIntervalMs: toNumber(process.env.YOUTUBE2POST_POLL_INTERVAL_MS, 5000),
78+
lockTtlMs: toNumber(process.env.YOUTUBE2POST_LOCK_TTL_MS, 10 * 60 * 1000),
79+
youtubeDlPath: process.env.YOUTUBE2POST_YOUTUBE_DL_PATH || path.join(process.cwd(), 'youtube-dl', 'bin', 'youtube-dl'),
80+
downloadTimeoutMs: toNumber(process.env.YOUTUBE2POST_DOWNLOAD_TIMEOUT_MS, 10 * 60 * 1000),
81+
youtubeDlArgs: parseArgs(process.env.YOUTUBE2POST_YOUTUBE_DL_ARGS) || ['-f', 'best'],
82+
ffmpegPath: process.env.YOUTUBE2POST_FFMPEG_PATH || 'ffmpeg',
83+
maxQuotes: toNumber(process.env.YOUTUBE2POST_MAX_QUOTES, 5),
84+
frameExtractionTimeoutMs: toNumber(process.env.YOUTUBE2POST_FRAME_TIMEOUT_MS, 60 * 1000),
85+
frameQuality: toNumber(process.env.YOUTUBE2POST_FRAME_QUALITY, 2),
86+
frameScaleFilter: parseOptional(process.env.YOUTUBE2POST_FRAME_SCALE_FILTER),
87+
transcriptionPollIntervalMs: toNumber(process.env.YOUTUBE2POST_TRANSCRIPTION_POLL_MS, 5000),
88+
transcriptionMaxAttempts: toNumber(process.env.YOUTUBE2POST_TRANSCRIPTION_MAX_ATTEMPTS, 120),
89+
ossEnabled: process.env.YOUTUBE2POST_OSS_ENABLED === 'true',
90+
ossProject: process.env.YOUTUBE2POST_OSS_PROJECT || 'youtube2post',
91+
ossBaseDir: process.env.YOUTUBE2POST_OSS_BASE_DIR || 'youtube2post',
92+
publicBaseUrl: process.env.YOUTUBE2POST_PUBLIC_BASE_URL || null,
93+
workspaceRoot,
94+
tempRoot,
95+
dataRoot,
96+
templateRoot
97+
}
98+
99+
const videoDecomposeConfig = {
100+
ffmpegPath: process.env.VIDEO_DECOMPOSE_FFMPEG_PATH
101+
|| process.env.YOUTUBE2POST_FFMPEG_PATH
102+
|| 'ffmpeg',
103+
screenshotQuality: toNumber(process.env.VIDEO_DECOMPOSE_SCREENSHOT_QUALITY, 2),
104+
frameScaleFilter: parseOptional(process.env.VIDEO_DECOMPOSE_FRAME_SCALE_FILTER),
105+
clipPreset: process.env.VIDEO_DECOMPOSE_FFMPEG_PRESET || 'fast',
106+
clipCrf: toNumber(process.env.VIDEO_DECOMPOSE_FFMPEG_CRF, 23),
107+
maxHighlights: toNumber(process.env.VIDEO_DECOMPOSE_MAX_HIGHLIGHTS, 5),
108+
minQuoteLength: toNumber(process.env.VIDEO_DECOMPOSE_MIN_QUOTE_LENGTH, 12),
109+
clipPaddingSeconds: Number.isNaN(Number(process.env.VIDEO_DECOMPOSE_CLIP_PADDING))
110+
? 0.5
111+
: Number(process.env.VIDEO_DECOMPOSE_CLIP_PADDING),
112+
ossBaseDir: process.env.VIDEO_DECOMPOSE_OSS_BASE_DIR || 'video-decompose',
113+
keepTemp: process.env.VIDEO_DECOMPOSE_KEEP_TEMP === 'true',
114+
workspaceRoot: videoDecomposeWorkspaceRoot,
115+
tempRoot: videoDecomposeTempRoot,
116+
outputRoot: videoDecomposeOutputRoot
117+
}
118+
4119
export default {
5120
port: process.env.PORT || 6000,
6121
nodeEnv: process.env.NODE_ENV || 'development',
7-
122+
8123
jwt: {
9124
secret: process.env.JWT_SECRET || 'default-secret',
10125
expireTime: process.env.JWT_EXPIRE_TIME || '24h'
11126
},
12-
127+
128+
aliyun: {
129+
apiKey: process.env.DASHSCOPE_API_KEY
130+
|| process.env.ALIYUN_API_KEY
131+
|| 'sk-4c89a24b73d24731b86bf26337398cef'
132+
},
133+
13134
terminal: {
14-
maxSessions: parseInt(process.env.MAX_TERMINAL_SESSIONS) || 10,
15-
timeout: parseInt(process.env.TERMINAL_TIMEOUT) || 600000
135+
maxSessions: toNumber(process.env.MAX_TERMINAL_SESSIONS, 10),
136+
timeout: toNumber(process.env.TERMINAL_TIMEOUT, 600000)
16137
},
17-
138+
18139
cors: {
19140
origins: process.env.ALLOWED_ORIGINS?.split(',') || [
20-
'http://localhost:5173',
21-
'http://localhost:5174',
141+
'http://localhost:5173',
142+
'http://localhost:5174',
22143
'http://localhost:6000',
23144
'http://127.0.0.1:5173',
24145
'http://127.0.0.1:5174',
@@ -34,16 +155,45 @@ export default {
34155
'http://card.paitongai.com',
35156
'https://card.paitongai.com',
36157
'http://card.paitongai.com:80',
37-
'http://cardapi.paitongai.com', // 新增API域名
38-
'https://cardapi.paitongai.com', // 新增API域名HTTPS
39-
'http://aicard.paitongai.com', // AI卡片域名
40-
'https://aicard.paitongai.com', // AI卡片域名HTTPS
158+
'http://cardapi.paitongai.com',
159+
'https://cardapi.paitongai.com',
160+
'http://aicard.paitongai.com',
161+
'https://aicard.paitongai.com',
41162
'http://ai-terminal-xnbmzvtedv.ap-northeast-1.fcapp.run',
42163
'https://ai-terminal-xnbmzvtedv.ap-northeast-1.fcapp.run'
43164
]
44165
},
45-
166+
46167
logging: {
47168
level: process.env.LOG_LEVEL || 'info'
48-
}
49-
}
169+
},
170+
171+
llm: {
172+
qwen: {
173+
enabled: true,
174+
apiKey: process.env.QWEN_API_KEY || 'sk-4c89a24b73d24731b86bf26337398cef',
175+
baseUrl: 'https://dashscope.aliyuncs.com/api/v1',
176+
model: 'qwen-max',
177+
temperature: 0.3,
178+
maxTokens: 8000,
179+
timeout: 90,
180+
maxRetries: 3,
181+
retryDelay: 3.0
182+
},
183+
'qwen-plus': {
184+
enabled: true,
185+
apiKey: process.env.QWEN_PLUS_API_KEY || 'sk-4c89a24b73d24731b86bf26337398cef',
186+
baseUrl: 'https://dashscope.aliyuncs.com/api/v1',
187+
model: 'qwen-plus',
188+
temperature: 0.3,
189+
maxTokens: 8000,
190+
timeout: 90,
191+
maxRetries: 3,
192+
retryDelay: 3.0,
193+
description: '阿里云通义千问Plus - 平衡性能和成本'
194+
}
195+
},
196+
197+
youtube2post: youtube2postConfig,
198+
videoDecompose: videoDecomposeConfig
199+
}

terminal-backend/src/index.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import generateRoutes from './routes/generate/index.js'
1818
import uploadRoutes from './routes/upload.js'
1919
import workspaceRoutes from './routes/workspace.js'
2020
import transcriptionRoutes from './routes/transcription.js'
21+
import youtube2postRoutes from './routes/youtube2post/index.js'
22+
import youtubeDownloadRoutes from './routes/youtubeDownload.js'
2123
import assetsRoutes from './routes/v2/assets.js'
2224
import { router as sseRouter } from './routes/v2/sse.js'
2325
import stsRoutes from './routes/sts.js'
@@ -26,6 +28,8 @@ import htmlEditRoutes from './routes/htmlEdit.js'
2628
import cardExtractorRoutes from './routes/cardExtractor.js'
2729
import { setupSocketHandlers } from './services/socketService.js'
2830
import websocketService from './services/websocketService.js'
31+
import { startYoutube2PostWorker } from './workers/youtube2postProcessor.js'
32+
import videoDecomposeRoutes from './routes/videoDecompose.js'
2933
// import { preventCommandInjection, limitRequestSize, auditLog, rateLimit } from './middleware/security.js'
3034
// import { verifyToken, optionalAuth } from './middleware/auth.js'
3135

@@ -285,6 +289,12 @@ console.log(' ✓ /api/workspace route registered')
285289

286290
app.use('/api/transcription', transcriptionRoutes)
287291
console.log(' ✓ /api/transcription route registered')
292+
app.use('/api/youtube2post', youtube2postRoutes)
293+
console.log(' ✓ /api/youtube2post route registered')
294+
app.use('/api/video-decompose', videoDecomposeRoutes)
295+
console.log(' ✓ /api/video-decompose route registered')
296+
app.use('/api/youtube', youtubeDownloadRoutes)
297+
console.log(' ✓ /api/youtube route registered')
288298
app.use('/api/sts', stsRoutes)
289299
console.log(' ✓ /api/sts route registered')
290300
app.use('/api/oss-direct', ossDirectRoutes)
@@ -612,6 +622,10 @@ httpServer.listen(PORT, HOST, () => {
612622
logger.info(`Server running on ${HOST}:${PORT} in ${config.nodeEnv} mode`)
613623
logger.info(`Server is accessible from any network interface`)
614624
logger.info(`HTTP timeout set to ${TIMEOUT_MS/1000}s for long-running requests`)
625+
626+
if (config.youtube2post?.workerEnabled !== false) {
627+
startYoutube2PostWorker()
628+
}
615629
})
616630

617-
export { io }
631+
export { io }
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import express from 'express'
2+
import logger from '../utils/logger.js'
3+
import {
4+
createVideoDecomposeTask,
5+
getVideoDecomposeTask
6+
} from '../services/videoDecompose/index.js'
7+
8+
const router = express.Router()
9+
10+
router.post('/jobs', (req, res) => {
11+
try {
12+
const {
13+
videoUrl,
14+
subtitleUrl,
15+
maxHighlights,
16+
minQuoteLength,
17+
clipPaddingSeconds,
18+
createCompilation
19+
} = req.body || {}
20+
21+
if (!videoUrl || !subtitleUrl) {
22+
return res.status(400).json({
23+
success: false,
24+
error: 'videoUrl and subtitleUrl are required'
25+
})
26+
}
27+
28+
logger.info('[videoDecompose] Creating job', {
29+
videoUrl,
30+
subtitleUrl,
31+
createCompilation
32+
})
33+
34+
const job = createVideoDecomposeTask({
35+
videoUrl,
36+
subtitleUrl,
37+
options: {
38+
maxHighlights: Number(maxHighlights),
39+
minQuoteLength: Number(minQuoteLength),
40+
clipPaddingSeconds: Number(clipPaddingSeconds),
41+
createCompilation: Boolean(createCompilation)
42+
}
43+
})
44+
45+
return res.json({
46+
success: true,
47+
data: job
48+
})
49+
} catch (error) {
50+
logger.error(`[videoDecompose] Failed to create job: ${error.message}`)
51+
return res.status(500).json({
52+
success: false,
53+
error: error.message || 'Failed to create job'
54+
})
55+
}
56+
})
57+
58+
router.get('/jobs/:id', (req, res) => {
59+
const job = getVideoDecomposeTask(req.params.id)
60+
if (!job) {
61+
return res.status(404).json({
62+
success: false,
63+
error: 'Job not found'
64+
})
65+
}
66+
67+
return res.json({
68+
success: true,
69+
data: job
70+
})
71+
})
72+
73+
export default router
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import logger from '../../utils/logger.js'
2+
import {
3+
createJob,
4+
getJob,
5+
getJobInternal,
6+
failJob
7+
} from './jobStore.js'
8+
import processJob from './processor.js'
9+
10+
const sanitizeOptions = input => {
11+
if (!input) return {}
12+
const options = {}
13+
14+
if (Number.isFinite(input.maxHighlights)) {
15+
options.maxHighlights = Math.max(1, Math.floor(input.maxHighlights))
16+
}
17+
18+
if (Number.isFinite(input.minQuoteLength)) {
19+
options.minQuoteLength = Math.max(1, Math.floor(input.minQuoteLength))
20+
}
21+
22+
if (Number.isFinite(input.clipPaddingSeconds)) {
23+
options.clipPaddingSeconds = Math.max(0, Number(input.clipPaddingSeconds))
24+
}
25+
26+
return options
27+
}
28+
29+
export function createVideoDecomposeTask(payload) {
30+
const job = createJob({
31+
videoUrl: payload.videoUrl,
32+
subtitleUrl: payload.subtitleUrl,
33+
options: sanitizeOptions(payload.options || payload)
34+
})
35+
36+
queueMicrotask(() => {
37+
processJob(job).catch(error => {
38+
logger.error(`[videoDecompose] Unexpected processing error for ${job.id}: ${error.message}`)
39+
const record = getJobInternal(job.id)
40+
if (record) {
41+
failJob(job.id, error)
42+
}
43+
})
44+
})
45+
46+
return getJob(job.id)
47+
}
48+
49+
export function getVideoDecomposeTask(jobId) {
50+
return getJob(jobId)
51+
}

0 commit comments

Comments
 (0)