feat(skills): 完善视频生产 pipeline 及新增健身跟练账号
- SKILL.md: 新增工作流阶段定义、质量卡点、分镜规则 - manifest-schema.md: 补充完整字段规范及类型定义 - phase-tts.js: 优化 TTS 合成长逻辑,添加进度追踪 - capcut-tracks.js: 扩展轨道构建能力,支持更多元素类型 - capcut-timeline.js: 改进时间线生成,支持淡入淡出 - capcut_assemble.js: 新增 assemble 阶段完整实现 - cmd-init.js: 完善 init 命令逻辑 - qwen-tts.js: 调整超时配置 - accounts/禁忌帝王学: 更新拆分/图像/台词提示词 - accounts/健身跟练: 新增账号含 account.json 及全套提示词模板 - 新增 workflow-issues-20260501.md 参考文档 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
*
|
||||
* 所有 add* 函数 + 转场策略 + 账号配置读取。
|
||||
* Agent 修改字幕风格、Ken Burns、转场、特效等只需关注此文件。
|
||||
*
|
||||
* 音频策略(固化铁律):
|
||||
* - 音频由 TTS 1.15x 生成,导入 CapCut 时无 speed 字段(不可调速)
|
||||
* - 每个 item 的 segments[] 逐段添加,各段 start 按 startOffset 精确对齐
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
@@ -303,33 +307,233 @@ async function addVideos(draftUrl, inputDir, items, timeline, width, height, tra
|
||||
return allSegmentIds
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 将 segment 写入视频轨道时间线(slot)
|
||||
// 背景:add_videos 只负责把视频加入素材库,不自动上时间线。
|
||||
// 此函数在 add_videos 成功后调用,将每个 segment_id 写入第一个 video track。
|
||||
// ============================================================================
|
||||
|
||||
async function addSlots(draftUrl, items, timeline) {
|
||||
const { api: capcutApi, US } = require('./capcut-api')
|
||||
const { getManifestDir } = require('./pipeline-utils')
|
||||
const path = require('path')
|
||||
|
||||
// 获取当前云端草稿的 draft_content,获取第一个 video track 的 id
|
||||
let draftData
|
||||
try {
|
||||
draftData = (await capcutApi('get_draft', { draft_url: draftUrl })).data || {}
|
||||
} catch (err) {
|
||||
// get_draft 接口不可用,尝试从本地 manifest 目录寻找草稿
|
||||
const manifestDir = path.dirname(draftUrl.startsWith('http') ? draftUrl : '')
|
||||
console.log(' get_draft 不可用,切换本地写入模式')
|
||||
return addSlotsLocally(draftUrl, items, timeline)
|
||||
}
|
||||
|
||||
const tracks = draftData.tracks || []
|
||||
const videoTrack = tracks.find(t => t.type === 'video')
|
||||
if (!videoTrack) {
|
||||
console.log(' 未找到 video track,跳过 slot 写入')
|
||||
return
|
||||
}
|
||||
|
||||
// 构造 slot 数据
|
||||
const slots = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const tl = timeline[i]
|
||||
const segId = item.segmentId || item._segmentId
|
||||
if (!segId) continue
|
||||
|
||||
const slotId = generateUUID()
|
||||
slots.push({
|
||||
id: slotId,
|
||||
material_id: segId,
|
||||
track_id: videoTrack.id,
|
||||
render_index: i,
|
||||
type: 'video',
|
||||
common_property: {
|
||||
start_time: tl.start,
|
||||
source_timerange: { start: 0, duration: tl.duration },
|
||||
target_timerange: { start: tl.start, duration: tl.duration },
|
||||
is_avatar: false,
|
||||
audio_fade: { fade_in_duration: 0, fade_out_duration: 0 },
|
||||
volume: 1.0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (slots.length === 0) {
|
||||
console.log(' 无有效 slot 数据,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
// 通过 add_slots API 写入
|
||||
try {
|
||||
await capcutApi('add_slots', {
|
||||
draft_url: draftUrl,
|
||||
slots: JSON.stringify(slots),
|
||||
})
|
||||
console.log(` 已写入 ${slots.length} 个 slot 到视频轨道`)
|
||||
} catch (err) {
|
||||
// API 不支持时,降级为本地写入
|
||||
console.log(` add_slots API 不可用: ${err.message},降级为本地写入`)
|
||||
await addSlotsLocally(draftUrl, items, timeline, videoTrack.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 直接写入本地 draft_content.json 的 slot
|
||||
// options.draftId: 可选,直接指定 draftId(优先使用),否则从 draftUrl 提取
|
||||
async function addSlotsLocally(draftUrl, items, timeline, trackId, options = {}) {
|
||||
const { api: capcutApi, US } = require('./capcut-api')
|
||||
const fs = require('fs')
|
||||
|
||||
// 优先使用 options.draftId,否则从 draftUrl 提取
|
||||
let draftId = options.draftId || null
|
||||
if (!draftId) {
|
||||
try {
|
||||
draftId = new URL(draftUrl).searchParams.get('draft_id')
|
||||
} catch {
|
||||
console.log(' 无法解析 draftUrl,跳过本地 slot 写入')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!draftId) {
|
||||
console.log(' 无法提取 draft_id,跳过本地 slot 写入')
|
||||
return
|
||||
}
|
||||
|
||||
const { getConfig } = require('./capcut-api')
|
||||
const jianyingPath = getConfig().jianyingDraftPath
|
||||
const draftPath = path.join(jianyingPath, draftId, 'draft_content.json')
|
||||
if (!fs.existsSync(draftPath)) {
|
||||
console.log(` 本地草稿不存在: ${draftPath},跳过 slot 写入`)
|
||||
return
|
||||
}
|
||||
|
||||
let draft
|
||||
try {
|
||||
draft = JSON.parse(fs.readFileSync(draftPath, 'utf-8'))
|
||||
} catch {
|
||||
console.log(' draft_content.json 读取失败,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
// 找到第一个 video track
|
||||
const videoTrack = trackId
|
||||
? draft.tracks.find(t => t.id === trackId)
|
||||
: draft.tracks.find(t => t.type === 'video')
|
||||
|
||||
if (!videoTrack) {
|
||||
console.log(' 未找到 video track,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
const slots = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const tl = timeline[i]
|
||||
const segId = item.segmentId || item._segmentId
|
||||
if (!segId) {
|
||||
// 尝试从 materials.videos 匹配
|
||||
const fname = item.video ? path.basename(item.video) : ''
|
||||
const matVideo = (draft.materials.videos || []).find(v => {
|
||||
const matFname = path.basename(v.path || '')
|
||||
return fname && matFname.includes(fname.replace('videos/', ''))
|
||||
})
|
||||
if (matVideo) {
|
||||
items[i]._segmentId = matVideo.id
|
||||
slots.push(buildSlot(matVideo.id, videoTrack.id, i, tl, US))
|
||||
}
|
||||
} else {
|
||||
slots.push(buildSlot(segId, videoTrack.id, i, tl, US))
|
||||
}
|
||||
}
|
||||
|
||||
if (slots.length > 0) {
|
||||
videoTrack.slots = slots
|
||||
draft.duration = timeline.length > 0 ? timeline[timeline.length - 1].end : 0
|
||||
fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2), 'utf-8')
|
||||
console.log(` 已本地写入 ${slots.length} 个 slot 到视频轨道`)
|
||||
|
||||
// 触发剪映扫描
|
||||
triggerDirScan(path.dirname(draftPath))
|
||||
}
|
||||
}
|
||||
|
||||
function buildSlot(segId, trackId, index, tl, US) {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
material_id: segId,
|
||||
track_id: trackId,
|
||||
render_index: index,
|
||||
type: 'video',
|
||||
common_property: {
|
||||
start_time: tl.start,
|
||||
source_timerange: { start: 0, duration: tl.duration },
|
||||
target_timerange: { start: tl.start, duration: tl.duration },
|
||||
is_avatar: false,
|
||||
audio_fade: { fade_in_duration: 0, fade_out_duration: 0 },
|
||||
volume: 1.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16).toUpperCase()
|
||||
})
|
||||
}
|
||||
|
||||
function triggerDirScan(dir) {
|
||||
const { execFile } = require('child_process')
|
||||
const tmp = dir + '.slot_tmp'
|
||||
if (process.platform === 'darwin') {
|
||||
execFile('rsync', ['-a', dir + '/', tmp], (err) => {
|
||||
try { require('fs').rmSync(tmp, { recursive: true, force: true }) } catch {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 添加 TTS 配音
|
||||
// ============================================================================
|
||||
|
||||
async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) {
|
||||
const audioItems = items.filter(item => item.audio)
|
||||
if (audioItems.length === 0) {
|
||||
console.log(' 无 TTS 音频文件,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
const audioInfos = []
|
||||
const resolveAudio = (relPath) => {
|
||||
if (relPath.startsWith('http')) return relPath
|
||||
if (audioUrls[relPath]) return audioUrls[relPath]
|
||||
return path.isAbsolute(relPath) ? relPath : path.resolve(inputDir, relPath)
|
||||
}
|
||||
// 优先使用 segments[] 逐段添加(精确对齐)
|
||||
// 无 segments 时降级为旧的整段方式
|
||||
const segmentsFlat = []
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const tl = timeline[i]
|
||||
if (!item.audio) continue
|
||||
|
||||
if (item.audio) {
|
||||
const audioUrl = resolveAudio(item.audio)
|
||||
if (item.segments && item.segments.length > 0) {
|
||||
// 使用 segments 精确添加
|
||||
for (const seg of item.segments) {
|
||||
if (!seg.audio || seg.error) continue
|
||||
const audioUrl = seg.audio.startsWith('http')
|
||||
? seg.audio
|
||||
: (audioUrls[seg.audio] || path.resolve(inputDir, seg.audio))
|
||||
const segDurUs = Math.round(seg.duration * US)
|
||||
const segStartUs = tl.start + Math.round(seg.startOffset * US)
|
||||
segmentsFlat.push({
|
||||
audio_url: audioUrl,
|
||||
start: segStartUs,
|
||||
end: segStartUs + segDurUs,
|
||||
duration: segDurUs,
|
||||
volume: 1.0,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 降级:整段添加
|
||||
const audioUrl = item.audio.startsWith('http')
|
||||
? item.audio
|
||||
: (audioUrls[item.audio] || path.resolve(inputDir, item.audio))
|
||||
const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration
|
||||
|
||||
audioInfos.push({
|
||||
segmentsFlat.push({
|
||||
audio_url: audioUrl,
|
||||
start: tl.start,
|
||||
end: tl.start + audioDurUs,
|
||||
@@ -339,17 +543,26 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {})
|
||||
}
|
||||
}
|
||||
|
||||
if (audioInfos.length === 0) {
|
||||
console.log(' 无可用音频,跳过配音')
|
||||
if (segmentsFlat.length === 0) {
|
||||
console.log(' 无 TTS 音频文件,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
await api('add_audios', {
|
||||
draft_url: draftUrl,
|
||||
audio_infos: JSON.stringify(audioInfos),
|
||||
})
|
||||
const ossCount = audioInfos.filter(a => a.audio_url.startsWith('http')).length
|
||||
console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${ossCount > 0 ? `${ossCount} 段 OSS + ` : ''}${audioInfos.length - ossCount} 段本地)`)
|
||||
// 逐个添加音频(CapCut API 批量添加不稳定)
|
||||
let addedCount = 0
|
||||
for (const audioInfo of segmentsFlat) {
|
||||
try {
|
||||
await api('add_audios', {
|
||||
draft_url: draftUrl,
|
||||
audio_infos: JSON.stringify([audioInfo]),
|
||||
})
|
||||
addedCount++
|
||||
} catch (err) {
|
||||
console.error(` 音频添加失败: ${err.message.slice(0, 80)}`)
|
||||
}
|
||||
}
|
||||
const ossCount = segmentsFlat.filter(a => a.audio_url.startsWith('http')).length
|
||||
console.log(` 已添加 ${addedCount}/${segmentsFlat.length} 段 TTS 配音 (${ossCount} 段 OSS)`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -402,7 +615,24 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
|
||||
|
||||
const tl = timeline[i]
|
||||
|
||||
if (split) {
|
||||
if (split && item.segments && item.segments.length > 0) {
|
||||
// 精确字幕模式:使用 segments 实测时长,逐段添加字幕
|
||||
for (const seg of item.segments) {
|
||||
if (seg.error || !seg.text) continue
|
||||
const segStartUs = tl.start + Math.round(seg.startOffset * US)
|
||||
const segDurUs = Math.round(seg.duration * US)
|
||||
|
||||
const cap = {
|
||||
start: segStartUs,
|
||||
end: segStartUs + segDurUs,
|
||||
text: seg.text,
|
||||
}
|
||||
|
||||
applyAnimationProps(cap, animStyle)
|
||||
captions.push(cap)
|
||||
}
|
||||
} else if (split) {
|
||||
// 降级:按字符比例分配(无 segments 时)
|
||||
const sentences = splitTextIntoSentences(text)
|
||||
if (sentences.length === 0) continue
|
||||
|
||||
@@ -447,9 +677,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
|
||||
return
|
||||
}
|
||||
|
||||
await api('add_captions', {
|
||||
draft_url: draftUrl,
|
||||
captions: JSON.stringify(captions),
|
||||
const commonStyle = {
|
||||
font: style.font || null,
|
||||
font_size: style.fontSize || 15,
|
||||
text_color: style.color || '#ffffff',
|
||||
@@ -472,9 +700,23 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
|
||||
transform_x: 0,
|
||||
transform_y: style.transformY || 0,
|
||||
style_text: 0,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'} → ${animStyle.outAnimation || '无'})`)
|
||||
// 逐条添加字幕(CapCut API 批量添加不稳定)
|
||||
let addedCount = 0
|
||||
for (const cap of captions) {
|
||||
try {
|
||||
await api('add_captions', {
|
||||
draft_url: draftUrl,
|
||||
captions: JSON.stringify([cap]),
|
||||
...commonStyle,
|
||||
})
|
||||
addedCount++
|
||||
} catch (err) {
|
||||
console.error(` 字幕添加失败: ${err.message.slice(0, 80)}`)
|
||||
}
|
||||
}
|
||||
console.log(` 已添加 ${addedCount}/${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'} → ${animStyle.outAnimation || '无'})`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -583,6 +825,8 @@ module.exports = {
|
||||
addBGM,
|
||||
addSubtitles,
|
||||
addKeywordOverlays,
|
||||
addSlots,
|
||||
addSlotsLocally,
|
||||
addEffects,
|
||||
addFilter,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user