Repository navigation
feat(core): dedup scoring + re-mix trigger - #570
632850849-coder wants to merge 1 commit into
Conversation
Add core/dedup/ module with three sub-scoring pipelines: - FrameScorer: pHash-based perceptual hash comparison for keyframe dedup - AudioScorer: spectrum fingerprint cosine similarity for audio dedup - SubtitleScorer: embedding-based semantic similarity for subtitle dedup CompositeScorer fuses the three scorers with configurable weights (default frame=0.4, audio=0.3, subtitle=0.3) and a 0.7 threshold. RemixWrapper triggers secondary remix when composite score >= 0.7, applying: - Variable speed (0.95x-1.05x) - Frame dropping (5%) - Position offset (±2px) - Noise LUT layer (intensity 0.02) All wrapped as a non-invasive layer over existing Volcengine DirectEdit. Includes 43 vitest tests covering all three scorers, composite scorer, and remix wrapper.
There was a problem hiding this comment.
Code Review
This pull request introduces a new video deduplication and remixing module (DedupModule) consisting of frame, audio, and subtitle scorers, a composite scorer to weigh their results, and a remix wrapper to trigger secondary video editing via Volcengine DirectEdit. Key feedback points out that RemixWrapper currently relies on hardcoded placeholders and needs to accept actual video sources and durations. Additionally, the custom threshold parameter is ignored in CompositeScorer's evaluation, the OpenAI client in SubtitleScorer should be cached to prevent performance bottlenecks, and query parameters must be stripped from URLs in FrameScorer and AudioScorer to ensure deterministic seed generation.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async triggerRemix( | ||
| output: DedupScoringOutput, | ||
| remixParams?: Partial<RemixParamsConfig>, | ||
| ): Promise<RemixTriggerResult> { | ||
| if (!output.score.needsRemix) { | ||
| return { | ||
| triggered: false, | ||
| remixParams: this.getDefaultRemixParams(), | ||
| originalScore: output.score.composite, | ||
| } | ||
| } | ||
|
|
||
| this.logger.log({ | ||
| compositeScore: output.score.composite, | ||
| threshold: 0.7, | ||
| }, 'Dedup score exceeds threshold, triggering remix') | ||
|
|
||
| // 使用提供的参数或默认值 | ||
| const params = { | ||
| ...this.getDefaultRemixParams(), | ||
| ...remixParams, | ||
| } | ||
|
|
||
| // 构建混剪 EditParam | ||
| const editParam = this.buildRemixEditParam(params) | ||
|
|
||
| // 委托给 Volcengine DirectEdit | ||
| const taskId = await this.submitRemixTask(editParam) | ||
|
|
||
| return { | ||
| triggered: true, | ||
| remixVideoUrl: undefined, // 实际 URL 需在任务完成后获取 | ||
| remixParams: params, | ||
| originalScore: output.score.composite, | ||
| taskId: taskId, | ||
| } | ||
| } |
There was a problem hiding this comment.
triggerRemix 方法直接提交混剪任务,但目前没有接收任何关于原视频的标识(如 videoSource)或时长(durationMs),导致直接提交了硬编码的占位符。应该修改方法签名以接收这些参数并传递给 buildRemixEditParam。
async triggerRemix(
output: DedupScoringOutput,
videoSource: string,
durationMs: number,
remixParams?: Partial<RemixParamsConfig>,
): Promise<RemixTriggerResult> {
if (!output.score.needsRemix) {
return {
triggered: false,
remixParams: this.getDefaultRemixParams(),
originalScore: output.score.composite,
}
}
this.logger.log({
compositeScore: output.score.composite,
threshold: 0.7,
}, 'Dedup score exceeds threshold, triggering remix')
// 使用提供的参数或默认值
const params = {
...this.getDefaultRemixParams(),
...remixParams,
}
// 构建混剪 EditParam
const editParam = this.buildRemixEditParam(videoSource, durationMs, params)
// 委托给 Volcengine DirectEdit
const taskId = await this.submitRemixTask(editParam)
return {
triggered: true,
remixVideoUrl: undefined, // 实际 URL 需在任务完成后获取
remixParams: params,
originalScore: output.score.composite,
taskId: taskId,
}
}| private buildRemixEditParam(params: RemixParamsConfig): DirectEditParam { | ||
| // 随机选择变速因子 | ||
| const speedFactor = this.randomBetween(params.speedMin, params.speedMax) | ||
|
|
||
| // 抽帧:跳过指定比例的帧 | ||
| const dropFrameRatio = params.dropFrameRatio | ||
|
|
||
| // 噪点强度 | ||
| const noiseIntensity = params.noiseIntensity | ||
|
|
||
| // 随机位置偏移(±2px) | ||
| const offsetX = this.randomBetween(-REMIX_CONSTANTS.POSITION_OFFSET, REMIX_CONSTANTS.POSITION_OFFSET) | ||
| const offsetY = this.randomBetween(-REMIX_CONSTANTS.POSITION_OFFSET, REMIX_CONSTANTS.POSITION_OFFSET) | ||
|
|
||
| // 构建 Track 元素 | ||
| // 注意:这里假设源视频 VID 已预先通过 getVideoInfo 获取 | ||
| // 实际使用时,Caller 需要先获取 VID 并传入 | ||
| const track: TrackElement[][] = [ | ||
| // 视频轨道 | ||
| [ | ||
| { | ||
| Type: 'video', | ||
| Source: 'vid://SOURCE_VIDEO', // 占位符,实际由 Caller 替换 | ||
| TargetTime: [0, 10000], // 占位符,实际由 Caller 替换 | ||
| Extra: [ | ||
| // 变速滤镜 | ||
| { | ||
| Type: 'speed' as const, | ||
| Speed: speedFactor, | ||
| } as SpeedFilter, | ||
| // 变换滤镜(微偏移) | ||
| { | ||
| Type: 'transform' as const, | ||
| PosX: offsetX, | ||
| PosY: offsetY, | ||
| } as TransformFilter, | ||
| // 抽帧滤镜(模拟:通过 Trim 分段) | ||
| { | ||
| Type: 'trim' as const, | ||
| StartTime: 0, | ||
| EndTime: Math.floor(10000 * (1 - dropFrameRatio)), | ||
| } as TrimFilter, | ||
| // 噪点 LUT(模拟) | ||
| { | ||
| Type: 'lut_filter' as const, | ||
| TargetTime: [0, Math.floor(10000 * (1 - dropFrameRatio))], | ||
| Source: `noise_lut_${Math.round(noiseIntensity * 100)}`, | ||
| Intensity: noiseIntensity, | ||
| } as LutFilter, | ||
| ], | ||
| }, | ||
| ], | ||
| ] | ||
|
|
||
| return { | ||
| Canvas: { | ||
| Width: 1280, | ||
| Height: 720, | ||
| }, | ||
| Output: { | ||
| Fps: 30, | ||
| Codec: { | ||
| VideoCodec: 'h264', | ||
| AudioCodec: 'aac', | ||
| }, | ||
| } as OutputParam, | ||
| Track: track, | ||
| } | ||
| } |
There was a problem hiding this comment.
更新 buildRemixEditParam 方法以接收实际的 videoSource 和 durationMs,并对随机的位置偏移量进行四舍五入取整,避免浮点数像素偏移可能导致的渲染问题。
private buildRemixEditParam(
videoSource: string,
durationMs: number,
params: RemixParamsConfig,
): DirectEditParam {
// 随机选择变速因子
const speedFactor = this.randomBetween(params.speedMin, params.speedMax)
// 抽帧:跳过指定比例的帧
const dropFrameRatio = params.dropFrameRatio
// 噪点强度
const noiseIntensity = params.noiseIntensity
// 随机位置偏移(±2px)
const offsetX = Math.round(this.randomBetween(-REMIX_CONSTANTS.POSITION_OFFSET, REMIX_CONSTANTS.POSITION_OFFSET))
const offsetY = Math.round(this.randomBetween(-REMIX_CONSTANTS.POSITION_OFFSET, REMIX_CONSTANTS.POSITION_OFFSET))
// 构建 Track 元素
const track: TrackElement[][] = [
// 视频轨道
[
{
Type: 'video',
Source: videoSource,
TargetTime: [0, durationMs],
Extra: [
// 变速滤镜
{
Type: 'speed' as const,
Speed: speedFactor,
} as SpeedFilter,
// 变换滤镜(微偏移)
{
Type: 'transform' as const,
PosX: offsetX,
PosY: offsetY,
} as TransformFilter,
// 抽帧滤镜(模拟:通过 Trim 分段)
{
Type: 'trim' as const,
StartTime: 0,
EndTime: Math.floor(durationMs * (1 - dropFrameRatio)),
} as TrimFilter,
// 噪点 LUT(模拟)
{
Type: 'lut_filter' as const,
TargetTime: [0, Math.floor(durationMs * (1 - dropFrameRatio))],
Source: 'noise_lut_' + Math.round(noiseIntensity * 100),
Intensity: noiseIntensity,
} as LutFilter,
],
},
],
]
return {
Canvas: {
Width: 1280,
Height: 720,
},
Output: {
Fps: 30,
Codec: {
VideoCodec: 'h264',
AudioCodec: 'aac',
},
} as OutputParam,
Track: track,
}
}| // 4. 计算综合分 | ||
| const score = this.computeCompositeScore( | ||
| frameResult.similarity, | ||
| audioResult.similarity, | ||
| subtitleResult.similarity, | ||
| weights, | ||
| ) |
There was a problem hiding this comment.
在计算综合评分时,未将 threshold 参数传递给 computeCompositeScore 方法,导致用户自定义的阈值在判断是否需要混剪时被忽略。
| // 4. 计算综合分 | |
| const score = this.computeCompositeScore( | |
| frameResult.similarity, | |
| audioResult.similarity, | |
| subtitleResult.similarity, | |
| weights, | |
| ) | |
| // 4. 计算综合分 | |
| const score = this.computeCompositeScore( | |
| frameResult.similarity, | |
| audioResult.similarity, | |
| subtitleResult.similarity, | |
| weights, | |
| threshold, | |
| ) |
| private computeCompositeScore( | ||
| frameSim: number, | ||
| audioSim: number, | ||
| subtitleSim: number, | ||
| weights: { frame: number; audio: number; subtitle: number }, | ||
| ): DedupScore { | ||
| // 归一化权重 | ||
| const totalWeight = weights.frame + weights.audio + weights.subtitle | ||
| const wFrame = weights.frame / totalWeight | ||
| const wAudio = weights.audio / totalWeight | ||
| const wSubtitle = weights.subtitle / totalWeight | ||
|
|
||
| const composite = | ||
| frameSim * wFrame + | ||
| audioSim * wAudio + | ||
| subtitleSim * wSubtitle | ||
|
|
||
| return { | ||
| frame: frameSim, | ||
| audio: audioSim, | ||
| subtitle: subtitleSim, | ||
| composite: Math.round(composite * 10000) / 10000, | ||
| needsRemix: composite >= DEFAULT_THRESHOLD, | ||
| } | ||
| } |
There was a problem hiding this comment.
修改 computeCompositeScore 方法以接收 threshold 参数,并使用它来决定 needsRemix 的值,而不是硬编码使用 DEFAULT_THRESHOLD。
private computeCompositeScore(
frameSim: number,
audioSim: number,
subtitleSim: number,
weights: { frame: number; audio: number; subtitle: number },
threshold: number,
): DedupScore {
// 归一化权重
const totalWeight = weights.frame + weights.audio + weights.subtitle
const wFrame = weights.frame / totalWeight
const wAudio = weights.audio / totalWeight
const wSubtitle = weights.subtitle / totalWeight
const composite =
frameSim * wFrame +
audioSim * wAudio +
subtitleSim * wSubtitle
return {
frame: frameSim,
audio: audioSim,
subtitle: subtitleSim,
composite: Math.round(composite * 10000) / 10000,
needsRemix: composite >= threshold,
}
}| private async embedTextViaOpenAI(text: string): Promise<number[]> { | ||
| if (!this.openaiConfig?.apiKey) { | ||
| throw new Error('OpenAI API key not configured') | ||
| } | ||
|
|
||
| const OpenAI = await import('openai') | ||
| const client = new OpenAI.OpenAI({ | ||
| apiKey: this.openaiConfig.apiKey, | ||
| baseURL: this.openaiConfig.baseUrl, | ||
| timeout: 30000, | ||
| }) | ||
|
|
||
| const response = await client.embeddings.create({ | ||
| model: 'text-embedding-3-small', | ||
| input: text, | ||
| }) | ||
|
|
||
| const vector = response.data[0]?.embedding | ||
| if (!vector) { | ||
| throw new Error('Empty embedding response') | ||
| } | ||
| return vector | ||
| } |
There was a problem hiding this comment.
在 embedTextViaOpenAI 中,每次调用都会通过 new OpenAI.OpenAI(...) 创建一个新的客户端实例。在批量比对字幕时,这会造成极大的性能开销和连接浪费。建议将创建好的客户端实例缓存起来复用。
private openaiClient?: any
private async embedTextViaOpenAI(text: string): Promise<number[]> {
if (!this.openaiConfig?.apiKey) {
throw new Error('OpenAI API key not configured')
}
if (!this.openaiClient) {
const OpenAI = await import('openai')
this.openaiClient = new OpenAI.OpenAI({
apiKey: this.openaiConfig.apiKey,
baseURL: this.openaiConfig.baseUrl,
timeout: 30000,
})
}
const response = await this.openaiClient.embeddings.create({
model: 'text-embedding-3-small',
input: text,
})
const vector = response.data[0]?.embedding
if (!vector) {
throw new Error('Empty embedding response')
}
return vector
}| constructor( | ||
| @Optional() private readonly openaiService?: OpenaiService, | ||
| ) { | ||
| // 尝试从 openaiService 获取配置(通过私有属性反射) | ||
| try { | ||
| this.openaiConfig = (this.openaiService as any)?.config as OpenaiConfig | undefined | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } |
There was a problem hiding this comment.
建议直接在构造函数中可选注入 OpenaiConfig,而不是通过反射 (this.openaiService as any)?.config 获取私有属性,这样更加符合 NestJS 的依赖注入规范且更加健壮。
constructor(
@Optional() private readonly openaiService?: OpenaiService,
@Optional() private readonly openaiConfig?: OpenaiConfig,
) {
if (openaiConfig) {
this.openaiConfig = openaiConfig
} else {
try {
this.openaiConfig = (this.openaiService as any)?.config as OpenaiConfig | undefined
} catch {
// ignore
}
}
}| private urlToSeed(url: string): number { | ||
| let hash = 0 | ||
| for (let i = 0; i < url.length; i++) { | ||
| const char = url.charCodeAt(i) | ||
| hash = ((hash << 5) - hash) + char | ||
| hash |= 0 | ||
| } | ||
| return Math.abs(hash) | ||
| } |
There was a problem hiding this comment.
在通过 URL 生成确定性种子时,如果 URL 带有动态查询参数(例如 CDN 鉴权 token、时间戳等),会导致相同的视频生成完全不同的种子。建议在生成哈希前先剥离 URL 中的查询参数。
| private urlToSeed(url: string): number { | |
| let hash = 0 | |
| for (let i = 0; i < url.length; i++) { | |
| const char = url.charCodeAt(i) | |
| hash = ((hash << 5) - hash) + char | |
| hash |= 0 | |
| } | |
| return Math.abs(hash) | |
| } | |
| private urlToSeed(url: string): number { | |
| const cleanUrl = url.split('?')[0] | |
| let hash = 0 | |
| for (let i = 0; i < cleanUrl.length; i++) { | |
| const char = cleanUrl.charCodeAt(i) | |
| hash = ((hash << 5) - hash) + char | |
| hash |= 0 | |
| } | |
| return Math.abs(hash) | |
| } |
| private static urlToSeed(url: string): number { | ||
| let hash = 0 | ||
| for (let i = 0; i < url.length; i++) { | ||
| const char = url.charCodeAt(i) | ||
| hash = ((hash << 5) - hash) + char | ||
| hash |= 0 | ||
| } | ||
| return Math.abs(hash) || 1 | ||
| } |
There was a problem hiding this comment.
在通过 URL 生成确定性种子时,如果 URL 带有动态查询参数(例如 CDN 鉴权 token、时间戳等),会导致相同的音频生成完全不同的种子。建议在生成哈希前先剥离 URL 中的查询参数。
private static urlToSeed(url: string): number {
const cleanUrl = url.split('?')[0]
let hash = 0
for (let i = 0; i < cleanUrl.length; i++) {
const char = cleanUrl.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash |= 0
}
return Math.abs(hash) || 1
}
变更概述
在 Create Agent 的混剪管线后插入一层"去重评分 + 二次混剪触发器"。
新增
core/dedup/模块三个子评分器:
sharp对关键帧计算感知哈希,与源库比对综合评分:
二次混剪触发器(RemixWrapper):
测试
文件清单
apps/aitoearn-ai/src/core/dedup/— 13 个新文件(1673 行)apps/aitoearn-ai/src/core/ai/ai.module.ts— 注册 DedupModuleapps/aitoearn-ai/test/setup.ts— 测试 setup(修复缺失文件)