Skip to content

feat(core): dedup scoring + re-mix trigger - #570

Closed
632850849-coder wants to merge 1 commit into
yikart:mainfrom
632850849-coder:feat/dedup-scoring-remix-trigger
Closed

632850849-coder wants to merge 1 commit into
yikart:mainfrom
632850849-coder:feat/dedup-scoring-remix-trigger

Conversation

@632850849-coder

Copy link
Copy Markdown

变更概述

在 Create Agent 的混剪管线后插入一层"去重评分 + 二次混剪触发器"。

新增 core/dedup/ 模块

三个子评分器:

评分器 方法
帧级 pHash 利用 sharp 对关键帧计算感知哈希,与源库比对
音频频谱指纹 64-bin 频谱特征,余弦相似度比对
字幕 embedding 优先 OpenAI API,fallback 确定性模拟

综合评分:

  • 默认权重:帧 0.4 / 音频 0.3 / 字幕 0.3
  • 阈值 0.7:低于放行,≥0.7 触发二次混剪

二次混剪触发器(RemixWrapper):

  • 变速 0.95x~1.05x(随机)
  • 抽帧 5%
  • 位置偏移 ±2px
  • 噪点 LUT 层(强度 0.02)
  • 委托给现有 Volcengine DirectEdit 服务

测试

  • 43 个 vitest 用例,全部通过
  • 覆盖 frame-scorer / audio-scorer / subtitle-scorer / composite-scorer / remix-wrapper

文件清单

  • apps/aitoearn-ai/src/core/dedup/ — 13 个新文件(1673 行)
  • apps/aitoearn-ai/src/core/ai/ai.module.ts — 注册 DedupModule
  • apps/aitoearn-ai/test/setup.ts — 测试 setup(修复缺失文件)

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +57 to +93
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,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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

Comment on lines +98 to +166
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,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

更新 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,
    }
  }

Comment on lines +85 to +91
// 4. 计算综合分
const score = this.computeCompositeScore(
frameResult.similarity,
audioResult.similarity,
subtitleResult.similarity,
weights,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在计算综合评分时,未将 threshold 参数传递给 computeCompositeScore 方法,导致用户自定义的阈值在判断是否需要混剪时被忽略。

Suggested change
// 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,
)

Comment on lines +112 to +136
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,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

修改 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,
    }
  }

Comment on lines +57 to +79
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在 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
  }

Comment on lines +30 to +39
constructor(
@Optional() private readonly openaiService?: OpenaiService,
) {
// 尝试从 openaiService 获取配置(通过私有属性反射)
try {
this.openaiConfig = (this.openaiService as any)?.config as OpenaiConfig | undefined
} catch {
// ignore
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

建议直接在构造函数中可选注入 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
      }
    }
  }

Comment on lines +175 to +183
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在通过 URL 生成确定性种子时,如果 URL 带有动态查询参数(例如 CDN 鉴权 token、时间戳等),会导致相同的视频生成完全不同的种子。建议在生成哈希前先剥离 URL 中的查询参数。

Suggested change
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)
}

Comment on lines +65 to +73
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在通过 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
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants