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:
lc
2026-05-06 22:53:37 +08:00
parent e6daf7a8d8
commit 6eec0e8889
28 changed files with 2199 additions and 253 deletions

View File

@@ -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,
}