Viral Feel Parity: Making AI-Generated Ads Feel Like the Original

Viral Feel Parity: Making AI-Generated Ads Feel Like the Original

病毒式传播的质感:如何让 AI 生成的广告拥有“原版味”

The video generation platform I work on takes a reference viral ad and produces a new version with swapped product copy, fresh voice-over, and AI-generated scenes. Technically the output was correct — scenes rendered, VO synthesized, timeline stitched. But the editor’s feedback was blunt: “the avatar keeps changing, random boxes in the video, no bed sound, doesn’t feel the same.” 我所工作的视频生成平台会选取一个病毒式传播的参考广告,通过替换产品文案、录制新的配音以及生成 AI 场景来制作新版本。从技术上讲,输出结果是正确的——场景已渲染、配音已合成、时间轴已拼接。但剪辑师的反馈却很直接:“头像一直在变,视频里有乱七八糟的方框,没有背景音,感觉不对劲。”

That last phrase — doesn’t feel the same — is the whole problem. Viral ads work because of accumulated micro-decisions: one consistent face, ambient room tone under the narration, captions that sit naturally on screen, pacing that breathes between beats. Our pipeline was optimizing for structural fidelity to the blueprint while ignoring perceptual fidelity to the reference. 最后那句“感觉不对劲”正是问题的核心。病毒式广告之所以有效,是因为它积累了无数微小的决策:统一的人物面孔、旁白下方的环境音、自然嵌入屏幕的字幕,以及在节奏间隙中留有余地的语速。我们的流水线在优化时只关注了对蓝图的结构性还原,却忽略了对参考视频的感知还原。

Over roughly 2,800 lines across several PRs, I closed that gap. This post walks through eight concrete failure modes and the fixes that made generated ads feel like they belonged to the same family as the original. 在跨越多个 PR(代码合并请求)的约 2800 行代码修改中,我填补了这一差距。本文将介绍八种具体的失败模式,以及如何通过修复这些问题,让生成的广告看起来就像是原版视频的“同门作品”。

1. Avatar consistency

1. 头像一致性

The protagonist changed appearance between scenes. Scene one showed a woman with short dark hair; scene three had a different face entirely. Root cause: avatar selection was random per scene. Each scene generation call picked from the avatar pool independently, the same way we might pick a background variant — except viewers experience the protagonist as a continuous character, not a per-shot casting decision. 主角在不同场景间变换了外貌。第一幕是一个短发黑人女性,第三幕却换成了完全不同的面孔。根本原因在于:头像选择是按场景随机进行的。每次场景生成调用都会从头像池中独立选取,就像我们选择背景变体一样——但观众是将主角视为一个连续的角色,而不是每一镜都要重新选角。

The fix mirrored how we already handled voice and product: pick once, propagate everywhere. The start route and every runner resolve site now pass a single avatarSlug through the job context. Scene generators read that slug instead of rolling dice. 修复方案参考了我们处理配音和产品的方式:选定一次,全局应用。现在,启动路由和每个运行器解析点都会通过作业上下文(Job Context)传递一个唯一的 avatarSlug。场景生成器会读取该标识符,而不是随机掷骰子。

interface JobContext {
  avatarSlug: string;
  voiceId: string;
  productSlug: string;
}

function resolveAvatarForJob(
  blueprint: VideoBlueprint,
  overrides?: Partial<JobContext>,
): string {
  if (overrides?.avatarSlug) return overrides.avatarSlug;
  if (blueprint.avatar?.defaultSlug) return blueprint.avatar.defaultSlug;
  return pickDefaultAvatar(blueprint.demographics);
}

// Start route + all runner resolve sites
const ctx: JobContext = {
  avatarSlug: resolveAvatarForJob(blueprint, req.body),
  voiceId: resolveVoice(blueprint, req.body),
  productSlug: resolveProduct(blueprint, req.body),
};

One slug, one face, every scene. Simple invariant, large perceptual payoff. 一个标识符,一张脸,贯穿所有场景。简单的逻辑不变性,却带来了巨大的感知提升。

2. Caption rendering

2. 字幕渲染

Editors reported “random white boxes” floating over the video. The caption overlay tried to reproduce the reference’s on-screen text by drawing rectangles wherever the analyzer detected text regions. When the analyzer flagged an overlay it could not describe — a stylized graphic, a motion-blurred lower-third — the renderer still drew the pill background with no text inside. Blank boxes. 剪辑师报告说视频上漂浮着“随机的白色方框”。字幕叠加层试图通过在分析器检测到文本区域的地方绘制矩形来复刻参考视频的屏幕文字。当分析器标记了一个无法描述的覆盖层(如风格化图形或动态模糊的下三分之一字幕)时,渲染器依然会绘制一个胶囊状背景,但里面却没有文字。于是就出现了空白方框。

I rewrote caption rendering to draw real text on a pill background, driven by blueprint.captions.style: caps vs sentence case, accent color, light-vs-dark pill, vertical position. Undescribable overlays now draw nothing — not a placeholder rectangle. Emoji are stripped before render because our bundled font cannot glyph them reliably. And because Railway containers have no system fonts, I bundled DejaVuSans-Bold.ttf into the asset pipeline so caption typography is deterministic in production. 我重写了字幕渲染逻辑,使其能够根据 blueprint.captions.style 在胶囊背景上绘制真实的文本:包括大小写格式、强调色、明暗背景以及垂直位置。无法描述的覆盖层现在不再绘制任何东西,而不是显示占位矩形。表情符号在渲染前会被剔除,因为我们打包的字体无法可靠地显示它们。此外,由于 Railway 容器没有系统字体,我将 DejaVuSans-Bold.ttf 打包进了资源流水线,确保了生产环境中字幕排版的确定性。

interface CaptionStyle {
  uppercase: boolean;
  accentColor: string;
  pillVariant: 'light' | 'dark';
  position: 'top' | 'center' | 'bottom';
}

function renderCaptionOverlay(
  scene: SceneBlueprint,
  style: CaptionStyle,
): OverlayCommand[] {
  const text = scene.caption?.text;
  if (!text || scene.caption?.undescribable) return [];
  const sanitized = stripEmoji(text);
  const display = style.uppercase ? sanitized.toUpperCase() : sanitized;
  return [{
    type: 'text-pill',
    text: display,
    fontPath: bundledFont('DejaVuSans-Bold.ttf'),
    accentColor: style.accentColor,
    pillVariant: style.pillVariant,
    position: style.position,
  }];
}

Captions went from broken rectangles to readable, styled text that matched the reference’s visual language. 字幕从破碎的矩形变成了可读、有风格的文本,完美契合了参考视频的视觉语言。

3. Audio bed and background sound

3. 音频底噪与背景音

Output audio was clean VO only — technically pristine, perceptually sterile. The reference viral had ambient room tone, subtle music bed, the sense of a real environment. Our mix sounded like someone recorded voice-over in a vacuum. 输出的音频只有纯净的配音——技术上很完美,但感知上却很枯燥。参考的病毒式广告有环境底噪、微妙的音乐背景,给人一种真实环境的感觉。而我们的混音听起来就像是在真空中录制的配音。

I added an ambience bed generated via the ElevenLabs Sound Effects API, themed from the blueprint’s mood and setting descriptors. The bed mixes under the VO with sidechain ducking: the voice-over keys a compressor on the bed track, so speech dips the ambience and gaps between lines let it swell back. Tunable constants evolved through editor feedback — DEFAULT_BED_VOLUME went from 0.18 to 0.30 to 0.40, duck ratio softened from 8:1 to 4:1 to 3:1, threshold adjusted so ducking felt natural rather than pumping. 我通过 ElevenLabs 音效 API 添加了环境底噪,并根据蓝图的情绪和场景描述进行主题化处理。底噪通过侧链压缩(sidechain ducking)与配音混合:配音会触发底噪轨道上的压缩器,当有人说话时,环境音会降低,而在句子间隙,环境音又会自然回升。通过剪辑师的反馈,我不断调整参数常量——DEFAULT_BED_VOLUME 从 0.18 调至 0.30 再到 0.40,压缩比从 8:1 放宽到 4:1 再到 3:1,阈值也经过调整,使音量起伏听起来自然,而不是生硬的“抽吸感”。

interface AudioBedProvider {
  generateBed(prompt: string, durationSec: number): Promise<Buffer>;
}

const DEFAULT_BED_VOLUME = 0.40;
const DUCK_RATIO = 3;
const DUCK_THRESHOLD_DB = -24;

async function mixVoWithBed(
  voTrack: AudioBuffer,
  bedProvider: AudioBedProvider,
  blueprint: VideoBlueprint,
  bedVolume = DEFAULT_BED_VOLUME,
): Promise<Buffer> {
  const bed = await bedProvider.generateBed(
    buildBedPrompt(blueprint.style, blueprint.setting),
    voTrack.durationSec,
  );
  return ffmpegMix([
    { input: voTrack, filter: 'anull' },
    { input: bed, filter: `volume=${bedVolume},acompressor=threshold=${DUCK_THRESHOLD_DB}dB:ratio=${DUCK_RATIO}:sidechain=0`, sidechainFrom: voTrack },
  ]);
}

The AudioBedProvider interface keeps the ElevenLabs implementation swappable. Editors can also tune audioBedVolume (0..1) per job through the edit API without redeploying. AudioBedProvider 接口使得 ElevenLabs 的实现可以随时替换。剪辑师还可以通过编辑 API 为每个作业调整 audioBedVolume (0..1),无需重新部署。

4. Voice-over pacing

4. 配音节奏

Early versions sped up the VO to fit the reference’s scene timing. When synthesized speech ran longer than the reference clip, the pipeline applied atempo to compress it. The result was chipmunk narration — technically on-beat, obviously wrong. The rule is now absolute: never speed the VO. Instead, the stitcher sizes the timeline to max(reference total duration, VO length) and stretches the pict… 早期版本为了适配参考视频的场景时间,会强行加快配音速度。当合成语音比参考片段长时,流水线会使用 atempo 进行压缩。结果就是听起来像花栗鼠一样的旁白——虽然技术上卡在了节拍上,但听感上显然是错的。现在的规则是绝对的:永远不要加速配音。相反,拼接器会将时间轴设置为 max(参考总时长, 配音时长),并拉伸画面……